feat(ql3): atomically withdraw quarantined automation

This commit is contained in:
whyour
2026-08-13 03:42:38 +08:00
parent a6ad636251
commit 4c2a0b6adf
39 changed files with 983 additions and 264 deletions
+2
View File
@@ -24,6 +24,8 @@
`10001:10001`、network none、read-only root、drop ALL、128 MiB/32 PIDs 下完成本地 TLS readiness 契约。14 个 Local Profile `10001:10001`、network none、read-only root、drop ALL、128 MiB/32 PIDs 下完成本地 TLS readiness 契约。14 个 Local Profile
artifact 与 D-302 对应字节数一致;PostgreSQL 18.4 arm64 HA 123 项 gate 全绿、timeline `1→2`,证据 SHA-256 为 artifact 与 D-302 对应字节数一致;PostgreSQL 18.4 arm64 HA 123 项 gate 全绿、timeline `1→2`,证据 SHA-256 为
`e7c1743e932f2d7c35dc9153cdf5bc4a03356a38d93fce5507354652aa207a05`,独立审计与 Docker 清理通过。完整验证证据见 ADR-0391。 `e7c1743e932f2d7c35dc9153cdf5bc4a03356a38d93fce5507354652aa207a05`,独立审计与 Docker 清理通过。完整验证证据见 ADR-0391。
- D-304/ADR-0392(已接受):Plugin Package 进入安全 quarantine 时,Workflow/Prompt automation publication 不再仅依赖运行时 start guard 间接拒绝,而是与 quarantine event、Package-owned Task disabled revisions、Project Tool snapshot 和 withdrawal receipt 在同一 SQLite/PostgreSQL 事务中收敛为 `withdrawn`。原先仅能引用普通 lifecycle event 的外键升级为 append-only disposition-event 联合引用,历史 migration 与 publication digest schema 保持不变;SQLite edge/standalone 崩溃矩阵覆盖 automation publication insert 后、event/task/receipt/COMMIT 前后,PostgreSQL 通过触发器在既有 `SECURITY DEFINER` quarantine commit 内登记 disposition,再由同一外层 SERIALIZABLE transaction CAS publication head。能力位为 `plugin_package_automation_security_withdrawal@1`;不新增 package、daemon、timer、连接或常驻缓存,适用于低配路由设备和集群节点。SQLite 全量 228/228PostgreSQL package 311 pass/1 条外部 URL 条件 skip;完整 18-package build/test 退出 0backend 1,188 pass/2 skippackage/dependency boundary 零 findingPostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `ab156901b9c96ec5a62259c44d83d24ded011e0616dc827d928f3e13efd11786`
- D-302/ADR-0390(已接受) - D-302/ADR-0390(已接受)
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、 reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
@@ -0,0 +1,41 @@
# ADR-0392Plugin Package Automation 安全隔离原子撤回
- 状态:Accepted
- 日期:2026-08-13
- 关联 RFCQL-RFC-0001 D-304
- 关联 ADRADR-0221、ADR-0222
## 问题
现有 quarantine 会原子撤回 Package-owned Task 与 Tool snapshot,并由 Run、Tool、Workflow start guard 阻止新执行;但 Workflow/Prompt automation publication head 仍可能保持 `active`。这会让产品可见状态、安全状态与恢复扫描语义不一致。
早期 automation publication 的 `lifecycle_event_digest` 又通过外键只允许引用普通 lifecycle event,因此 quarantine event 无法成为合法撤回证据。修改历史 migration 或发布摘要会破坏已部署数据库与 append-only 证据链。
## 决策
1. 新增 append-only automation disposition event 投影,统一登记 `lifecycle|quarantine`,但保留 publication v1 字段名与 digest 计算。
2. SQLite 以追加 migration 重建 publication 外键,运行期先插入 quarantine event,由 trigger 登记 disposition,再以窄接口只允许 `active -> withdrawn`,全部处于同一 `BEGIN IMMEDIATE`。迁移表重建窗口临时关闭 foreign key,提交后恢复并立即执行 readiness、`foreign_key_check` 与 schema lockstep。
3. PostgreSQL 以 `pg-0058` 追加表、触发器、外键与能力位。既有 quarantine `SECURITY DEFINER` function 写入事件时触发 dispositionrepository 随后在同一 SERIALIZABLE transaction 内 CAS automation publication;任一失败整体回滚。
4. `absent` 或已 `withdrawn` 的 publication 不制造无意义版本。publication target 与 quarantine install generation 不一致时 fail closed。
5. receipt replay 校验目标 generation 在隔离提交点之后不存在 active automation publication;缺失或被回拨的撤回证据返回 unavailable。
6. 能力位为 `plugin_package_automation_security_withdrawal@1`。实现不得增加 package、常驻进程、定时器、连接或缓存。
## 资源与部署影响
- edge/standalone:新增一个两列小表和两个 INSERT trigger;每次 lifecycle/quarantine 仅追加一行,常驻内存与连接数不变。
- cluster:新增同构表、两个 statement-local trigger 和一个只供 trigger 调用的函数;disposition 投影不向任何运行角色开放读取,无新 Pod、worker 或数据库连接。
- 历史 publication JSON、digest、lifecycle event 与 quarantine receipt 均不改写。
## 验证
- SQLite local package 全量测试、typed schema/readiness、备份恢复及 edge/standalone quarantine crash matrix。
- PostgreSQL package 全量测试、migration checksum、Drizzle/schema/readiness/least-privilege contract。
- PostgreSQL 18 physical-streaming HA Docker gate,验证 quarantine、automation withdrawn、COMMIT-response-loss、promotion 与 rewind 后证据存活。
## 当前证据
- SQLite 全量 228/228quarantine crash matrix 覆盖 edge/standalone × 6 个 event、automation withdrawal、Task/Tool、receipt 与 COMMIT 前后窗口。
- PostgreSQL package 312 项:311 pass、1 条仅在未提供独立测试数据库 URL 时条件 skip、0 failmigration checksum、Drizzle/schema/readiness 与最小权限契约全绿。
- Cluster Admin 304 项:302 pass/2 条件 skipCluster Control 232 项:230 pass/2 条件 skipLocal Owner CLI 168 项:163 pass/5 条件 skip;完整 18-package build/test 退出 0。
- backend 1,190 项:1,188 pass/2 条件 skip/0 failpackage boundary 保持精确 18 package、无 single-source/shallow package,两个 `ordered_ledger` 目录分别锁定为 PostgreSQL 59 与 SQLite 91 个 migration sourcecluster dependency audit 零 finding。
- PostgreSQL 18.4 arm64 physical-streaming HA 125 项 gate 全绿,timeline `1→2`;包含 quarantine COMMIT-response-loss、standby 上 disposition/withdrawn head、promotion 后精确 publication chain 与旧主 rewind/rejoin。私有报告 SHA-256 为 `ab156901b9c96ec5a62259c44d83d24ded011e0616dc827d928f3e13efd11786`,测试容器、网络与卷均已清理。
+1
View File
@@ -395,6 +395,7 @@
| [ADR-0389](./ADR-0389-owner-private-cluster-operator-context.md) | Owner-private Cluster Operator Context | Accepted | | [ADR-0389](./ADR-0389-owner-private-cluster-operator-context.md) | Owner-private Cluster Operator Context | Accepted |
| [ADR-0390](./ADR-0390-offline-cluster-operator-context-preflight.md) | Offline Cluster Operator Context Preflight | Accepted | | [ADR-0390](./ADR-0390-offline-cluster-operator-context-preflight.md) | Offline Cluster Operator Context Preflight | Accepted |
| [ADR-0391](./ADR-0391-read-only-cluster-operator-context-readiness.md) | Read-only Cluster Operator Context Readiness | Accepted | | [ADR-0391](./ADR-0391-read-only-cluster-operator-context-readiness.md) | Read-only Cluster Operator Context Readiness | Accepted |
| [ADR-0392](./ADR-0392-atomic-plugin-package-automation-security-withdrawal.md) | Plugin Package Automation 安全隔离原子撤回 | Accepted |
## 规则 ## 规则
+2 -2
View File
@@ -6,13 +6,13 @@
"reviewedDenseDirectories": [ "reviewedDenseDirectories": [
{ {
"kind": "ordered_ledger", "kind": "ordered_ledger",
"maxDirectSourceFiles": 58, "maxDirectSourceFiles": 59,
"path": "packages/ql3-cluster-postgres/src/migrations", "path": "packages/ql3-cluster-postgres/src/migrations",
"rationale": "PostgreSQL migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." "rationale": "PostgreSQL migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory."
}, },
{ {
"kind": "ordered_ledger", "kind": "ordered_ledger",
"maxDirectSourceFiles": 89, "maxDirectSourceFiles": 91,
"path": "packages/ql3-local-sqlite/src/migrations", "path": "packages/ql3-local-sqlite/src/migrations",
"rationale": "SQLite migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." "rationale": "SQLite migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory."
} }
@@ -170,6 +170,7 @@ function database(serverVersionNum = '160014') {
'plugin_package_tool_start_allowed', 'plugin_package_tool_start_allowed',
'plugin_package_workflow_admission_snapshot', 'plugin_package_workflow_admission_snapshot',
'plugin_package_workflow_task_attempt_snapshot', 'plugin_package_workflow_task_attempt_snapshot',
'register_plugin_package_automation_disposition_event',
].includes(functionName), ].includes(functionName),
isOwner: false, isOwner: false,
})), })),
@@ -198,6 +198,7 @@ function database(serverVersionNum = '160014') {
'plugin_package_workflow_admission_snapshot', 'plugin_package_workflow_admission_snapshot',
'plugin_package_workflow_task_attempt_snapshot', 'plugin_package_workflow_task_attempt_snapshot',
'lock_run_management_policy_fence', 'lock_run_management_policy_fence',
'register_plugin_package_automation_disposition_event',
].includes(functionName), ].includes(functionName),
isOwner: false, isOwner: false,
})), })),
@@ -197,15 +197,11 @@ function runtimePrivileges() {
plugin_package_lifecycle_tasks: [false, false, false, false], plugin_package_lifecycle_tasks: [false, false, false, false],
plugin_package_lifecycle_plans: [false, false, false, false], plugin_package_lifecycle_plans: [false, false, false, false],
plugin_package_automation_publications: [true, false, false, false], plugin_package_automation_publications: [true, false, false, false],
plugin_package_automation_disposition_events: [false, false, false, false],
plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false],
plugin_package_workflow_admissions: [true, true, false, false], plugin_package_workflow_admissions: [true, true, false, false],
plugin_package_workflow_admission_steps: [true, true, false, false], plugin_package_workflow_admission_steps: [true, true, false, false],
plugin_package_workflow_task_attempt_admissions: [ plugin_package_workflow_task_attempt_admissions: [true, true, false, false],
true,
true,
false,
false,
],
worker_execution_attestations: [true, false, false, false], worker_execution_attestations: [true, false, false, false],
run_events: [true, true, false, false], run_events: [true, true, false, false],
run_retry_policies: [true, true, true, false], run_retry_policies: [true, true, true, false],
@@ -111,6 +111,7 @@ function runtimePrivileges() {
plugin_package_lifecycle_tasks: [false, false, false, false], plugin_package_lifecycle_tasks: [false, false, false, false],
plugin_package_lifecycle_plans: [false, false, false, false], plugin_package_lifecycle_plans: [false, false, false, false],
plugin_package_automation_publications: [true, false, false, false], plugin_package_automation_publications: [true, false, false, false],
plugin_package_automation_disposition_events: [false, false, false, false],
plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false],
plugin_package_workflow_admissions: [true, true, false, false], plugin_package_workflow_admissions: [true, true, false, false],
plugin_package_workflow_admission_steps: [true, true, false, false], plugin_package_workflow_admission_steps: [true, true, false, false],
@@ -293,5 +293,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest =
checksum: checksum:
'ab2d0eee3d85a937e1e87243b1fd1e75181529122b64026303488404162e4ba7', 'ab2d0eee3d85a937e1e87243b1fd1e75181529122b64026303488404162e4ba7',
}), }),
Object.freeze({
id: 'pg-0058-plugin-package-automation-disposition-events',
checksum:
'd184324909f1e450f3c1b58d422796e3869a1360df60c3f2dfe4af0bacc37471',
}),
]), ]),
}); });
@@ -60,6 +60,7 @@ import { pg0054ApprovalManagementBoundaryMigration } from './pg-0054-approval-ma
import { pg0055RunAttemptLogRetentionMigration } from './pg-0055-run-attempt-log-retention'; import { pg0055RunAttemptLogRetentionMigration } from './pg-0055-run-attempt-log-retention';
import { pg0056RunManagementBoundaryMigration } from '../run-management/pg-0056-run-management-boundary'; import { pg0056RunManagementBoundaryMigration } from '../run-management/pg-0056-run-management-boundary';
import { pg0057RunManagementStopBoundaryMigration } from '../run-management/pg-0057-run-management-stop-boundary'; import { pg0057RunManagementStopBoundaryMigration } from '../run-management/pg-0057-run-management-stop-boundary';
import { pg0058PluginPackageAutomationDispositionEventsMigration } from './pg-0058-plugin-package-automation-disposition-events';
export const postgresqlMainMigrationStream: MigrationStreamDefinition<PostgresMigrationContext> = export const postgresqlMainMigrationStream: MigrationStreamDefinition<PostgresMigrationContext> =
Object.freeze({ Object.freeze({
@@ -125,5 +126,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition<PostgresMi
pg0055RunAttemptLogRetentionMigration, pg0055RunAttemptLogRetentionMigration,
pg0056RunManagementBoundaryMigration, pg0056RunManagementBoundaryMigration,
pg0057RunManagementStopBoundaryMigration, pg0057RunManagementStopBoundaryMigration,
pg0058PluginPackageAutomationDispositionEventsMigration,
]), ]),
}); });
@@ -0,0 +1,25 @@
import { CAPABILITIES_V56 } from '../run-management/pg-0057-run-management-stop-boundary';
import { definePostgresSqlMigration } from './sqlMigration';
export const CAPABILITIES_V57 = CAPABILITIES_V56.replace(
'"plugin_package_automation_publication":1,',
'"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,',
);
export const pg0058PluginPackageAutomationDispositionEventsMigration =
definePostgresSqlMigration({
id: 'pg-0058-plugin-package-automation-disposition-events',
statements: [
`CREATE TABLE "ql3"."plugin_package_automation_disposition_events" (event_digest char(64) PRIMARY KEY, event_kind varchar(16) NOT NULL, CONSTRAINT ql3_plugin_package_automation_disposition_kind_check CHECK (event_kind IN ('lifecycle', 'quarantine')), CONSTRAINT ql3_plugin_package_automation_disposition_digest_check CHECK (event_digest ~ '^[0-9a-f]{64}$'))`,
`INSERT INTO "ql3"."plugin_package_automation_disposition_events" (event_digest, event_kind) SELECT event_digest, 'lifecycle' FROM "ql3"."plugin_package_lifecycle_events"`,
`INSERT INTO "ql3"."plugin_package_automation_disposition_events" (event_digest, event_kind) SELECT event_digest, 'quarantine' FROM "ql3"."plugin_package_quarantine_events" ON CONFLICT (event_digest) DO NOTHING`,
`CREATE FUNCTION "ql3"."register_plugin_package_automation_disposition_event"() RETURNS trigger LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = pg_catalog, ql3 AS $ql3$ DECLARE kind_value varchar(16); BEGIN kind_value := CASE TG_TABLE_NAME WHEN 'plugin_package_lifecycle_events' THEN 'lifecycle' WHEN 'plugin_package_quarantine_events' THEN 'quarantine' ELSE NULL END; IF kind_value IS NULL THEN RAISE EXCEPTION 'unsupported automation disposition source' USING ERRCODE = 'check_violation'; END IF; INSERT INTO "ql3"."plugin_package_automation_disposition_events" (event_digest, event_kind) VALUES (NEW.event_digest, kind_value) ON CONFLICT (event_digest) DO NOTHING; RETURN NEW; END $ql3$`,
`REVOKE ALL ON FUNCTION "ql3"."register_plugin_package_automation_disposition_event"() FROM PUBLIC`,
`CREATE TRIGGER ql3_plugin_package_automation_lifecycle_disposition_insert AFTER INSERT ON "ql3"."plugin_package_lifecycle_events" FOR EACH ROW EXECUTE FUNCTION "ql3"."register_plugin_package_automation_disposition_event"()`,
`CREATE TRIGGER ql3_plugin_package_automation_quarantine_disposition_insert AFTER INSERT ON "ql3"."plugin_package_quarantine_events" FOR EACH ROW EXECUTE FUNCTION "ql3"."register_plugin_package_automation_disposition_event"()`,
`ALTER TABLE "ql3"."plugin_package_automation_publications" DROP CONSTRAINT ql3_plugin_package_automation_publication_lifecycle_fk`,
`ALTER TABLE "ql3"."plugin_package_automation_publications" ADD CONSTRAINT ql3_plugin_package_automation_publication_disposition_fk FOREIGN KEY (lifecycle_event_digest) REFERENCES "ql3"."plugin_package_automation_disposition_events" (event_digest) ON DELETE RESTRICT`,
`REVOKE ALL ON "ql3"."plugin_package_automation_disposition_events" FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`,
`DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 57, migration_id = 'pg-0058-plugin-package-automation-disposition-events', capabilities = '${CAPABILITIES_V57}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 56 AND migration_id = 'pg-0057-run-management-stop-boundary' AND capabilities = '${CAPABILITIES_V56}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 56' USING ERRCODE = 'check_violation'; END IF; END $ql3$`,
],
});
@@ -25,6 +25,7 @@ import {
normalizePluginPackageInstallRecord, normalizePluginPackageInstallRecord,
type PluginPackageInstallRecord, type PluginPackageInstallRecord,
} from '@qinglong/runtime-core/plugin-package-install'; } from '@qinglong/runtime-core/plugin-package-install';
import { createPluginPackageAutomationLifecyclePublication } from '@qinglong/runtime-core/plugin-package-automation-publication';
import { import {
createProjectToolDefinitionSnapshot, createProjectToolDefinitionSnapshot,
normalizeProjectToolDefinitionSnapshot, normalizeProjectToolDefinitionSnapshot,
@@ -56,15 +57,14 @@ import {
rollbackPostgresDefinitionTransaction, rollbackPostgresDefinitionTransaction,
} from '../../repository/definitionRepositorySupport'; } from '../../repository/definitionRepositorySupport';
import { isPostgresAvailabilityError } from '../../connection/pool'; import { isPostgresAvailabilityError } from '../../connection/pool';
import { PostgresPluginPackageAutomationPublicationRepository } from '../publication/pluginPackageAutomationPublicationRepository';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
type Queryable = Pick<PostgresQueryable, 'query'>; type Queryable = Pick<PostgresQueryable, 'query'>;
export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT = 128; export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT = 128;
function unavailable( function unavailable(cause?: unknown): PluginPackageQuarantineUnavailableError {
cause?: unknown,
): PluginPackageQuarantineUnavailableError {
return new PluginPackageQuarantineUnavailableError({ return new PluginPackageQuarantineUnavailableError({
cause: cause instanceof Error ? cause : undefined, cause: cause instanceof Error ? cause : undefined,
}); });
@@ -189,8 +189,10 @@ export class PostgresPluginPackageQuarantineRepository
if (result.rows.length !== 1) throw unavailable(); if (result.rows.length !== 1) throw unavailable();
try { try {
return normalizePluginPackageQuarantineEvent( return normalizePluginPackageQuarantineEvent(
recordJson(result.rows[0]!, 'eventJson') as unknown as recordJson(
PluginPackageQuarantineEvent, result.rows[0]!,
'eventJson',
) as unknown as PluginPackageQuarantineEvent,
); );
} catch (error) { } catch (error) {
if (error instanceof PluginPackageQuarantineUnavailableError) { if (error instanceof PluginPackageQuarantineUnavailableError) {
@@ -215,8 +217,10 @@ export class PostgresPluginPackageQuarantineRepository
if (result.rows.length !== 1) throw unavailable(); if (result.rows.length !== 1) throw unavailable();
try { try {
const receipt = normalizePluginPackageWithdrawalReceipt( const receipt = normalizePluginPackageWithdrawalReceipt(
recordJson(result.rows[0]!, 'receiptJson') as unknown as recordJson(
PluginPackageWithdrawalReceipt, result.rows[0]!,
'receiptJson',
) as unknown as PluginPackageWithdrawalReceipt,
); );
assertPluginPackageWithdrawalMatchesEvent(event, receipt); assertPluginPackageWithdrawalMatchesEvent(event, receipt);
await this.#assertReceiptRelations(queryable, receipt); await this.#assertReceiptRelations(queryable, receipt);
@@ -296,8 +300,10 @@ export class PostgresPluginPackageQuarantineRepository
if (snapshots.rows.length !== 1) throw unavailable(); if (snapshots.rows.length !== 1) throw unavailable();
try { try {
const snapshot = normalizeProjectToolDefinitionSnapshot( const snapshot = normalizeProjectToolDefinitionSnapshot(
recordJson(snapshots.rows[0]!, 'snapshotJson') as unknown as recordJson(
ProjectToolDefinitionSnapshot, snapshots.rows[0]!,
'snapshotJson',
) as unknown as ProjectToolDefinitionSnapshot,
); );
if ( if (
snapshot.sources.length !== receipt.capability.retainedSourceCount || snapshot.sources.length !== receipt.capability.retainedSourceCount ||
@@ -316,6 +322,31 @@ export class PostgresPluginPackageQuarantineRepository
} }
throw unavailable(error); throw unavailable(error);
} }
const automation = await queryable.query<Row>(
`SELECT state,
lifecycle_event_digest AS "lifecycleEventDigest"
FROM "ql3"."plugin_package_automation_publications"
WHERE project_id = $1 AND package_name = $2
AND installation_id = $3 AND lock_digest = $4
ORDER BY version DESC
LIMIT 1`,
[
receipt.target.projectId,
receipt.target.packageName,
receipt.target.installationId,
receipt.target.lockDigest,
],
);
if (automation.rows.length > 1) throw unavailable();
const publication = automation.rows[0];
if (
publication &&
(text(publication, 'state') === 'active' ||
(publication.lifecycleEventDigest === receipt.eventDigest &&
text(publication, 'state') !== 'withdrawn'))
) {
throw unavailable();
}
} }
async #findStored( async #findStored(
@@ -345,9 +376,7 @@ export class PostgresPluginPackageQuarantineRepository
LIMIT $2`, LIMIT $2`,
[lockDigest, CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT + 1], [lockDigest, CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT + 1],
); );
if ( if (result.rows.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT) {
result.rows.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT
) {
throw new PluginPackageQuarantineConflictError( throw new PluginPackageQuarantineConflictError(
'matching install targets exceed the Cluster limit', 'matching install targets exceed the Cluster limit',
); );
@@ -355,8 +384,10 @@ export class PostgresPluginPackageQuarantineRepository
return Object.freeze( return Object.freeze(
result.rows.map((row) => { result.rows.map((row) => {
const record = normalizePluginPackageInstallRecord( const record = normalizePluginPackageInstallRecord(
recordJson(row, 'recordJson') as unknown as recordJson(
PluginPackageInstallRecord, row,
'recordJson',
) as unknown as PluginPackageInstallRecord,
); );
return Object.freeze({ return Object.freeze({
projectId: record.projectId, projectId: record.projectId,
@@ -411,8 +442,10 @@ export class PostgresPluginPackageQuarantineRepository
let record: Readonly<PluginPackageInstallRecord>; let record: Readonly<PluginPackageInstallRecord>;
try { try {
record = normalizePluginPackageInstallRecord( record = normalizePluginPackageInstallRecord(
recordJson(result.rows[0]!, 'recordJson') as unknown as recordJson(
PluginPackageInstallRecord, result.rows[0]!,
'recordJson',
) as unknown as PluginPackageInstallRecord,
); );
} catch (error) { } catch (error) {
if (error instanceof PluginPackageQuarantineUnavailableError) { if (error instanceof PluginPackageQuarantineUnavailableError) {
@@ -436,12 +469,45 @@ export class PostgresPluginPackageQuarantineRepository
return record; return record;
} }
async #withdrawAutomation(
client: PostgresClient,
event: Readonly<PluginPackageQuarantineEvent>,
record: Readonly<PluginPackageInstallRecord>,
committedAtMs: number,
): Promise<void> {
const publications =
new PostgresPluginPackageAutomationPublicationRepository(this.pool);
const current = await publications.findCurrentInTransaction(
client,
event.target.projectId,
event.target.packageName,
);
if (!current) return;
if (
current.target.installationId !== event.target.installationId ||
current.target.lockDigest !== event.target.lockDigest ||
current.target.generation !== record.targetGeneration
) {
throw new PluginPackageQuarantineConflictError(
'Workflow/Prompt publication does not match the quarantined Package generation',
);
}
if (current.state === 'absent' || current.state === 'withdrawn') return;
await publications.publishSecurityWithdrawalInTransaction(
client,
createPluginPackageAutomationLifecyclePublication({
previous: current,
state: 'withdrawn',
lifecycleEventDigest: event.eventDigest,
publishedAtMs: committedAtMs,
}),
);
}
async #activeContributions( async #activeContributions(
queryable: Queryable, queryable: Queryable,
projectId: string, projectId: string,
): Promise< ): Promise<readonly Readonly<ProjectToolDefinitionSnapshotContribution>[]> {
readonly Readonly<ProjectToolDefinitionSnapshotContribution>[]
> {
const result = await queryable.query<Row>( const result = await queryable.query<Row>(
`SELECT revision.revision_json AS "revisionJson" `SELECT revision.revision_json AS "revisionJson"
FROM "ql3"."plugin_package_install_heads" AS head FROM "ql3"."plugin_package_install_heads" AS head
@@ -532,9 +598,7 @@ export class PostgresPluginPackageQuarantineRepository
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS + 1, MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS + 1,
], ],
); );
if ( if (result.rows.length > MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS) {
result.rows.length > MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS
) {
throw new PluginPackageQuarantineConflictError( throw new PluginPackageQuarantineConflictError(
'owned Tasks exceed the quarantine withdrawal limit', 'owned Tasks exceed the quarantine withdrawal limit',
); );
@@ -608,14 +672,11 @@ export class PostgresPluginPackageQuarantineRepository
'event digest is bound to another quarantine', 'event digest is bound to another quarantine',
); );
} }
const existingReceipt = await this.#receiptByEvent( const existingReceipt = await this.#receiptByEvent(client, existingEvent);
client,
existingEvent,
);
if (!existingReceipt) throw unavailable(); if (!existingReceipt) throw unavailable();
return Object.freeze({ created: false, receipt: existingReceipt }); return Object.freeze({ created: false, receipt: existingReceipt });
} }
await this.#install(client, event); const install = await this.#install(client, event);
const committedAtMs = Math.max( const committedAtMs = Math.max(
await this.#databaseNowMs(client), await this.#databaseNowMs(client),
event.occurredAtMs, event.occurredAtMs,
@@ -706,6 +767,9 @@ export class PostgresPluginPackageQuarantineRepository
) { ) {
throw unavailable(); throw unavailable();
} }
if (event.target.installState === 'active') {
await this.#withdrawAutomation(client, event, install, committedAtMs);
}
const stored = await this.#findStored(client, event.eventDigest); const stored = await this.#findStored(client, event.eventDigest);
if (!stored || !same(stored, receipt)) throw unavailable(); if (!stored || !same(stored, receipt)) throw unavailable();
return Object.freeze({ return Object.freeze({
@@ -1,8 +1,5 @@
// PostgreSQL adapter owned by Plugin Package publication and recovery. // PostgreSQL adapter owned by Plugin Package publication and recovery.
import type { import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
PostgresClient,
PostgresPool,
} from '@qinglong/runtime-core';
import { import {
InvalidPluginPackageAutomationPublicationError, InvalidPluginPackageAutomationPublicationError,
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES, MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES,
@@ -49,7 +46,10 @@ function unavailable(
}); });
} }
function targetIdentity(projectId: unknown, packageName: unknown): { function targetIdentity(
projectId: unknown,
packageName: unknown,
): {
readonly projectId: string; readonly projectId: string;
readonly packageName: string; readonly packageName: string;
} { } {
@@ -147,8 +147,7 @@ export class PostgresPluginPackageAutomationPublicationRepository
postgresRequiredString(row.generationDigest, unavailable) || postgresRequiredString(row.generationDigest, unavailable) ||
publication.target.materializedRevisionDigest !== publication.target.materializedRevisionDigest !==
postgresRequiredString(row.materializedRevisionDigest, unavailable) || postgresRequiredString(row.materializedRevisionDigest, unavailable) ||
publication.state !== publication.state !== postgresRequiredString(row.state, unavailable) ||
postgresRequiredString(row.state, unavailable) ||
publication.version !== publication.version !==
postgresRequiredInteger(row.version, unavailable) || postgresRequiredInteger(row.version, unavailable) ||
publication.publishedAtMs !== publication.publishedAtMs !==
@@ -158,9 +157,7 @@ export class PostgresPluginPackageAutomationPublicationRepository
} }
return publication; return publication;
} catch (error) { } catch (error) {
if ( if (error instanceof PluginPackageAutomationPublicationUnavailableError) {
error instanceof PluginPackageAutomationPublicationUnavailableError
) {
throw error; throw error;
} }
throw unavailable(error); throw unavailable(error);
@@ -393,9 +390,10 @@ export class PostgresPluginPackageAutomationPublicationRepository
return this.#findCurrent(client, projectId, packageName, true); return this.#findCurrent(client, projectId, packageName, true);
} }
async publishInTransaction( async #publishInTransaction(
client: PostgresClient, client: PostgresClient,
value: Readonly<PluginPackageAutomationPublication>, value: Readonly<PluginPackageAutomationPublication>,
securityWithdrawal: boolean,
): Promise< ): Promise<
Readonly<{ Readonly<{
status: 'created' | 'existing'; status: 'created' | 'existing';
@@ -443,10 +441,7 @@ export class PostgresPluginPackageAutomationPublicationRepository
); );
} }
try { try {
assertPluginPackageAutomationPublicationSuccessor( assertPluginPackageAutomationPublicationSuccessor(current, publication);
current,
publication,
);
} catch (error) { } catch (error) {
if (error instanceof InvalidPluginPackageAutomationPublicationError) { if (error instanceof InvalidPluginPackageAutomationPublicationError) {
throw new PluginPackageAutomationPublicationConflictError( throw new PluginPackageAutomationPublicationConflictError(
@@ -469,20 +464,14 @@ export class PostgresPluginPackageAutomationPublicationRepository
); );
if ( if (
revision.rows.length !== 1 || revision.rows.length !== 1 ||
postgresRequiredString( postgresRequiredString(revision.rows[0]!.revisionDigest, unavailable) !==
revision.rows[0]!.revisionDigest, publication.target.materializedRevisionDigest ||
unavailable,
) !== publication.target.materializedRevisionDigest ||
postgresRequiredString(revision.rows[0]!.projectId, unavailable) !== postgresRequiredString(revision.rows[0]!.projectId, unavailable) !==
publication.target.projectId || publication.target.projectId ||
postgresRequiredString( postgresRequiredString(revision.rows[0]!.packageName, unavailable) !==
revision.rows[0]!.packageName, publication.target.packageName ||
unavailable, postgresRequiredInteger(revision.rows[0]!.generation, unavailable) !==
) !== publication.target.packageName || publication.target.generation ||
postgresRequiredInteger(
revision.rows[0]!.generation,
unavailable,
) !== publication.target.generation ||
postgresRequiredString(revision.rows[0]!.lockDigest, unavailable) !== postgresRequiredString(revision.rows[0]!.lockDigest, unavailable) !==
publication.target.lockDigest publication.target.lockDigest
) { ) {
@@ -490,41 +479,43 @@ export class PostgresPluginPackageAutomationPublicationRepository
'materialized revision fence does not match publication target', 'materialized revision fence does not match publication target',
); );
} }
const securityFence = await client.query<Row>( if (!securityWithdrawal) {
`SELECT const securityFence = await client.query<Row>(
EXISTS ( `SELECT
SELECT 1 EXISTS (
FROM "ql3"."plugin_package_quarantine_events" AS quarantine SELECT 1
WHERE quarantine.project_id = $1 FROM "ql3"."plugin_package_quarantine_events" AS quarantine
AND quarantine.package_name = $2 WHERE quarantine.project_id = $1
AND quarantine.installation_id = $3 AND quarantine.package_name = $2
AND quarantine.lock_digest = $4 AND quarantine.installation_id = $3
) OR EXISTS ( AND quarantine.lock_digest = $4
SELECT 1 ) OR EXISTS (
FROM "ql3"."plugin_package_publisher_provenance" AS provenance SELECT 1
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked FROM "ql3"."plugin_package_publisher_provenance" AS provenance
ON revoked.publisher = provenance.publisher JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
AND revoked.key_id = provenance.key_id ON revoked.publisher = provenance.publisher
WHERE provenance.installation_id = $3 AND revoked.key_id = provenance.key_id
AND provenance.lock_digest = $4 WHERE provenance.installation_id = $3
) AS "blocked"`, AND provenance.lock_digest = $4
[ ) AS "blocked"`,
publication.target.projectId, [
publication.target.packageName, publication.target.projectId,
publication.target.installationId, publication.target.packageName,
publication.target.lockDigest, publication.target.installationId,
], publication.target.lockDigest,
); ],
if (
securityFence.rows.length !== 1 ||
typeof securityFence.rows[0]?.blocked !== 'boolean'
) {
throw unavailable();
}
if (securityFence.rows[0].blocked) {
throw new PluginPackageAutomationPublicationConflictError(
'security-fenced Package generation cannot publish automation',
); );
if (
securityFence.rows.length !== 1 ||
typeof securityFence.rows[0]?.blocked !== 'boolean'
) {
throw unavailable();
}
if (securityFence.rows[0].blocked) {
throw new PluginPackageAutomationPublicationConflictError(
'security-fenced Package generation cannot publish automation',
);
}
} }
await client.query( await client.query(
`INSERT INTO "ql3"."plugin_package_automation_publications" ( `INSERT INTO "ql3"."plugin_package_automation_publications" (
@@ -601,13 +592,41 @@ export class PostgresPluginPackageAutomationPublicationRepository
}); });
} }
async publish( publishInTransaction(
client: PostgresClient,
value: Readonly<PluginPackageAutomationPublication>, value: Readonly<PluginPackageAutomationPublication>,
): Promise< ): Promise<
Readonly<{ Readonly<{
status: 'created' | 'existing'; status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>; publication: Readonly<PluginPackageAutomationPublication>;
}> }>
> {
return this.#publishInTransaction(client, value, false);
}
publishSecurityWithdrawalInTransaction(
client: PostgresClient,
value: Readonly<PluginPackageAutomationPublication>,
): Promise<
Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}>
> {
const publication = normalizePluginPackageAutomationPublication(value);
if (publication.state !== 'withdrawn') {
throw new PluginPackageAutomationPublicationConflictError(
'security withdrawal must narrow automation state',
);
}
return this.#publishInTransaction(client, publication, true);
}
async publish(value: Readonly<PluginPackageAutomationPublication>): Promise<
Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}>
> { > {
for ( for (
let attempt = 0; let attempt = 0;
@@ -1707,6 +1707,24 @@ export const pluginPackageLifecyclePlans = ql3Schema.table(
], ],
); );
export const pluginPackageAutomationDispositionEvents = ql3Schema.table(
'plugin_package_automation_disposition_events',
{
eventDigest: char('event_digest', { length: 64 }).primaryKey(),
eventKind: varchar('event_kind', { length: 16 }).notNull(),
},
(table) => [
check(
'ql3_plugin_package_automation_disposition_kind_check',
sql`${table.eventKind} in ('lifecycle','quarantine')`,
),
check(
'ql3_plugin_package_automation_disposition_digest_check',
sql`${table.eventDigest} ~ '^[0-9a-f]{64}$'`,
),
],
);
export const pluginPackageAutomationPublications = ql3Schema.table( export const pluginPackageAutomationPublications = ql3Schema.table(
'plugin_package_automation_publications', 'plugin_package_automation_publications',
{ {
@@ -1743,9 +1761,9 @@ export const pluginPackageAutomationPublications = ql3Schema.table(
foreignColumns: [table.publicationDigest], foreignColumns: [table.publicationDigest],
}).onDelete('restrict'), }).onDelete('restrict'),
foreignKey({ foreignKey({
name: 'ql3_plugin_package_automation_publication_lifecycle_fk', name: 'ql3_plugin_package_automation_publication_disposition_fk',
columns: [table.lifecycleEventDigest], columns: [table.lifecycleEventDigest],
foreignColumns: [pluginPackageLifecycleEvents.eventDigest], foreignColumns: [pluginPackageAutomationDispositionEvents.eventDigest],
}).onDelete('restrict'), }).onDelete('restrict'),
uniqueIndex('ql3_plugin_package_automation_publication_version_key').on( uniqueIndex('ql3_plugin_package_automation_publication_version_key').on(
table.projectId, table.projectId,
@@ -5653,9 +5671,11 @@ export const pluginPackageWorkflowTaskAttemptAdmissions = ql3Schema.table(
uniqueIndex( uniqueIndex(
'plugin_package_workflow_task_attempt_admissions_event_id_key', 'plugin_package_workflow_task_attempt_admissions_event_id_key',
).on(table.eventId), ).on(table.eventId),
uniqueIndex( uniqueIndex('plugin_package_workflow_task_attempt_admissions_epoch_key').on(
'plugin_package_workflow_task_attempt_admissions_epoch_key', table.runId,
).on(table.runId, table.stepRunId, table.stepRunVersion), table.stepRunId,
table.stepRunVersion,
),
uniqueIndex( uniqueIndex(
'plugin_package_workflow_task_attempt_admissions_number_key', 'plugin_package_workflow_task_attempt_admissions_number_key',
).on(table.runId, table.attemptNumber), ).on(table.runId, table.attemptNumber),
@@ -5684,10 +5704,7 @@ export const pluginPackageWorkflowTaskAttemptAdmissions = ql3Schema.table(
}).onDelete('restrict'), }).onDelete('restrict'),
foreignKey({ foreignKey({
name: 'ql3_pp_workflow_task_attempt_reconciliation_fk', name: 'ql3_pp_workflow_task_attempt_reconciliation_fk',
columns: [ columns: [table.generationDigest, table.taskReconciliationReceiptDigest],
table.generationDigest,
table.taskReconciliationReceiptDigest,
],
foreignColumns: [ foreignColumns: [
pluginPackageTaskReconciliations.generationDigest, pluginPackageTaskReconciliations.generationDigest,
pluginPackageTaskReconciliations.receiptDigest, pluginPackageTaskReconciliations.receiptDigest,
@@ -5724,9 +5741,11 @@ export const pluginPackageWorkflowTaskAttemptAdmissions = ql3Schema.table(
'ql3_plugin_package_workflow_task_attempt_admission_json_check', 'ql3_plugin_package_workflow_task_attempt_admission_json_check',
sql`jsonb_typeof(${table.receiptJson}) = 'object' and octet_length(${table.receiptJson}::text) between 2 and 16384 and ${table.receiptJson} @> jsonb_build_object('schema', 'qinglong/plugin-package-workflow-task-attempt-admission@v1', 'receiptDigest', ${table.receiptDigest}, 'attemptId', ${table.attemptId}, 'planDigest', ${table.planDigest}, 'runId', ${table.runId}, 'stepRunId', ${table.stepRunId}, 'stepRunVersion', ${table.stepRunVersion}, 'stepRunDigest', ${table.stepRunDigest}, 'resourceTaskId', ${table.resourceTaskId}, 'taskReconciliationReceiptDigest', ${table.taskReconciliationReceiptDigest}, 'taskId', ${table.taskId}, 'taskRevision', ${table.taskRevision}, 'taskDefinitionDigest', ${table.taskDefinitionDigest}, 'executorType', ${table.executorType}, 'executionDigest', ${table.executionDigest}, 'attemptNumber', ${table.attemptNumber}, 'eventId', ${table.eventId}, 'runVersion', ${table.runVersion}, 'runEventSequence', ${table.runEventSequence}, 'admittedAtMs', ${table.admittedAtMs})`, sql`jsonb_typeof(${table.receiptJson}) = 'object' and octet_length(${table.receiptJson}::text) between 2 and 16384 and ${table.receiptJson} @> jsonb_build_object('schema', 'qinglong/plugin-package-workflow-task-attempt-admission@v1', 'receiptDigest', ${table.receiptDigest}, 'attemptId', ${table.attemptId}, 'planDigest', ${table.planDigest}, 'runId', ${table.runId}, 'stepRunId', ${table.stepRunId}, 'stepRunVersion', ${table.stepRunVersion}, 'stepRunDigest', ${table.stepRunDigest}, 'resourceTaskId', ${table.resourceTaskId}, 'taskReconciliationReceiptDigest', ${table.taskReconciliationReceiptDigest}, 'taskId', ${table.taskId}, 'taskRevision', ${table.taskRevision}, 'taskDefinitionDigest', ${table.taskDefinitionDigest}, 'executorType', ${table.executorType}, 'executionDigest', ${table.executionDigest}, 'attemptNumber', ${table.attemptNumber}, 'eventId', ${table.eventId}, 'runVersion', ${table.runVersion}, 'runEventSequence', ${table.runEventSequence}, 'admittedAtMs', ${table.admittedAtMs})`,
), ),
index( index('ql3_pp_workflow_task_attempt_candidate_idx').on(
'ql3_pp_workflow_task_attempt_candidate_idx', table.runId,
).on(table.runId, table.stepRunId, table.admittedAtMs), table.stepRunId,
table.admittedAtMs,
),
], ],
); );
@@ -5803,6 +5822,7 @@ export const ql3PostgresTables = [
pluginPackageLifecycleReceipts, pluginPackageLifecycleReceipts,
pluginPackageLifecycleTasks, pluginPackageLifecycleTasks,
pluginPackageLifecyclePlans, pluginPackageLifecyclePlans,
pluginPackageAutomationDispositionEvents,
pluginPackageAutomationPublications, pluginPackageAutomationPublications,
pluginPackageAutomationPublicationHeads, pluginPackageAutomationPublicationHeads,
pluginPackageWorkflowAdmissions, pluginPackageWorkflowAdmissions,
@@ -15,8 +15,8 @@ export interface PostgresSchemaContractFunction {
export interface PostgresSchemaContract { export interface PostgresSchemaContract {
readonly schema: 'ql3'; readonly schema: 'ql3';
readonly contractName: 'control-core'; readonly contractName: 'control-core';
readonly contractVersion: 56; readonly contractVersion: 57;
readonly migrationId: 'pg-0057-run-management-stop-boundary'; readonly migrationId: 'pg-0058-plugin-package-automation-disposition-events';
readonly minimumServerMajor: 16; readonly minimumServerMajor: 16;
readonly maximumServerMajor: 18; readonly maximumServerMajor: 18;
readonly capabilities: Readonly<{ readonly capabilities: Readonly<{
@@ -44,6 +44,7 @@ export interface PostgresSchemaContract {
plugin_package_admission: 1; plugin_package_admission: 1;
plugin_package_authority_split: 1; plugin_package_authority_split: 1;
plugin_package_automation_publication: 1; plugin_package_automation_publication: 1;
plugin_package_automation_security_withdrawal: 1;
plugin_package_automation_start_guard: 1; plugin_package_automation_start_guard: 1;
plugin_package_workflow_admission: 1; plugin_package_workflow_admission: 1;
plugin_package_workflow_run_list: 1; plugin_package_workflow_run_list: 1;
@@ -103,8 +104,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
Object.freeze({ Object.freeze({
schema: 'ql3', schema: 'ql3',
contractName: 'control-core', contractName: 'control-core',
contractVersion: 56, contractVersion: 57,
migrationId: 'pg-0057-run-management-stop-boundary', migrationId: 'pg-0058-plugin-package-automation-disposition-events',
minimumServerMajor: 16, minimumServerMajor: 16,
maximumServerMajor: 18, maximumServerMajor: 18,
capabilities: Object.freeze({ capabilities: Object.freeze({
@@ -125,6 +126,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
plugin_package_admission: 1, plugin_package_admission: 1,
plugin_package_authority_split: 1, plugin_package_authority_split: 1,
plugin_package_automation_publication: 1, plugin_package_automation_publication: 1,
plugin_package_automation_security_withdrawal: 1,
plugin_package_automation_start_guard: 1, plugin_package_automation_start_guard: 1,
plugin_package_workflow_admission: 1, plugin_package_workflow_admission: 1,
plugin_package_workflow_run_list: 1, plugin_package_workflow_run_list: 1,
@@ -382,6 +384,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
'expires_at_ms', 'expires_at_ms',
'plan_json', 'plan_json',
]), ]),
table('plugin_package_automation_disposition_events', [
'event_digest',
'event_kind',
]),
table('plugin_package_automation_publications', [ table('plugin_package_automation_publications', [
'publication_digest', 'publication_digest',
'project_id', 'project_id',
@@ -1445,6 +1451,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
'ql3_plugin_package_lifecycle_plan_impact_key', 'ql3_plugin_package_lifecycle_plan_impact_key',
'ql3_plugin_package_lifecycle_plan_expiry_idx', 'ql3_plugin_package_lifecycle_plan_expiry_idx',
'plugin_package_automation_publications_pkey', 'plugin_package_automation_publications_pkey',
'plugin_package_automation_disposition_events_pkey',
'ql3_plugin_package_automation_publication_version_key', 'ql3_plugin_package_automation_publication_version_key',
'ql3_plugin_package_automation_publication_previous_key', 'ql3_plugin_package_automation_publication_previous_key',
'ql3_plugin_package_automation_publication_generation_idx', 'ql3_plugin_package_automation_publication_generation_idx',
@@ -1736,6 +1743,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
'ql3_plugin_package_automation_publication_digest_check', 'ql3_plugin_package_automation_publication_digest_check',
'ql3_plugin_package_automation_publication_json_check', 'ql3_plugin_package_automation_publication_json_check',
'ql3_plugin_package_automation_publication_head_state_check', 'ql3_plugin_package_automation_publication_head_state_check',
'ql3_plugin_package_automation_disposition_kind_check',
'ql3_plugin_package_automation_disposition_digest_check',
'ql3_plugin_package_workflow_admission_identity_check', 'ql3_plugin_package_workflow_admission_identity_check',
'ql3_plugin_package_workflow_admission_digest_check', 'ql3_plugin_package_workflow_admission_digest_check',
'ql3_plugin_package_workflow_admission_json_check', 'ql3_plugin_package_workflow_admission_json_check',
@@ -2175,7 +2184,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
'ql3_plugin_package_lifecycle_plan_install_fk', 'ql3_plugin_package_lifecycle_plan_install_fk',
'ql3_plugin_package_automation_publication_revision_fk', 'ql3_plugin_package_automation_publication_revision_fk',
'ql3_plugin_package_automation_publication_previous_fk', 'ql3_plugin_package_automation_publication_previous_fk',
'ql3_plugin_package_automation_publication_lifecycle_fk', 'ql3_plugin_package_automation_publication_disposition_fk',
'ql3_plugin_package_automation_publication_head_publication_fk', 'ql3_plugin_package_automation_publication_head_publication_fk',
'ql3_plugin_package_workflow_admission_run_fk', 'ql3_plugin_package_workflow_admission_run_fk',
'ql3_plugin_package_workflow_admission_publication_fk', 'ql3_plugin_package_workflow_admission_publication_fk',
@@ -2302,6 +2311,14 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
'ql3_run_retry_policies_run_fk', 'ql3_run_retry_policies_run_fk',
]), ]),
functions: Object.freeze([ functions: Object.freeze([
Object.freeze({
name: 'register_plugin_package_automation_disposition_event',
identityArguments: '',
owner: 'ql3_migration',
securityDefiner: true,
volatility: 'volatile',
configuration: Object.freeze(['search_path=pg_catalog, ql3']),
}),
Object.freeze({ Object.freeze({
name: 'enforce_plugin_package_stage_provenance', name: 'enforce_plugin_package_stage_provenance',
identityArguments: '', identityArguments: '',
@@ -233,6 +233,12 @@ const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({
update: false, update: false,
delete: false, delete: false,
}), }),
plugin_package_automation_disposition_events: Object.freeze({
select: false,
insert: false,
update: false,
delete: false,
}),
plugin_package_automation_publication_heads: Object.freeze({ plugin_package_automation_publication_heads: Object.freeze({
select: true, select: true,
insert: false, insert: false,
@@ -740,6 +746,12 @@ const REQUIRED_ADMIN_PRIVILEGES = Object.freeze({
update: false, update: false,
delete: false, delete: false,
}), }),
plugin_package_automation_disposition_events: Object.freeze({
select: false,
insert: false,
update: false,
delete: false,
}),
plugin_package_automation_publication_heads: Object.freeze({ plugin_package_automation_publication_heads: Object.freeze({
select: false, select: false,
insert: false, insert: false,
@@ -1492,6 +1504,7 @@ const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges =
plugin_package_lifecycle_blocking_runs: false, plugin_package_lifecycle_blocking_runs: false,
plugin_package_run_start_allowed: true, plugin_package_run_start_allowed: true,
plugin_package_tool_start_allowed: true, plugin_package_tool_start_allowed: true,
register_plugin_package_automation_disposition_event: false,
}); });
const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges =
@@ -1509,6 +1522,7 @@ const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges =
plugin_package_lifecycle_blocking_runs: false, plugin_package_lifecycle_blocking_runs: false,
plugin_package_run_start_allowed: false, plugin_package_run_start_allowed: false,
plugin_package_tool_start_allowed: false, plugin_package_tool_start_allowed: false,
register_plugin_package_automation_disposition_event: false,
}); });
const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges =
@@ -1526,6 +1540,7 @@ const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges
plugin_package_lifecycle_blocking_runs: true, plugin_package_lifecycle_blocking_runs: true,
plugin_package_run_start_allowed: false, plugin_package_run_start_allowed: false,
plugin_package_tool_start_allowed: false, plugin_package_tool_start_allowed: false,
register_plugin_package_automation_disposition_event: false,
}); });
const REQUIRED_WORKER_CREDENTIAL_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = const REQUIRED_WORKER_CREDENTIAL_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges =
@@ -108,6 +108,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async ()
'pg-0055-run-attempt-log-retention', 'pg-0055-run-attempt-log-retention',
'pg-0056-run-management-boundary', 'pg-0056-run-management-boundary',
'pg-0057-run-management-stop-boundary', 'pg-0057-run-management-stop-boundary',
'pg-0058-plugin-package-automation-disposition-events',
], ],
); );
for (const migration of postgresqlMainMigrationStream.migrations) { for (const migration of postgresqlMainMigrationStream.migrations) {
@@ -528,6 +529,11 @@ test('freezes every published PostgreSQL migration checksum', () => {
checksum: checksum:
'ab2d0eee3d85a937e1e87243b1fd1e75181529122b64026303488404162e4ba7', 'ab2d0eee3d85a937e1e87243b1fd1e75181529122b64026303488404162e4ba7',
}, },
{
id: 'pg-0058-plugin-package-automation-disposition-events',
checksum:
'd184324909f1e450f3c1b58d422796e3869a1360df60c3f2dfe4af0bacc37471',
},
]; ];
assert.deepEqual( assert.deepEqual(
postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({ postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({
@@ -85,6 +85,7 @@ function validPrivileges() {
plugin_package_lifecycle_tasks: [false, false, false, false], plugin_package_lifecycle_tasks: [false, false, false, false],
plugin_package_lifecycle_plans: [false, false, false, false], plugin_package_lifecycle_plans: [false, false, false, false],
plugin_package_automation_publications: [true, false, false, false], plugin_package_automation_publications: [true, false, false, false],
plugin_package_automation_disposition_events: [false, false, false, false],
plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false],
plugin_package_workflow_admissions: [true, true, false, false], plugin_package_workflow_admissions: [true, true, false, false],
plugin_package_workflow_admission_steps: [true, true, false, false], plugin_package_workflow_admission_steps: [true, true, false, false],
@@ -202,6 +203,7 @@ function validAdminPrivileges() {
plugin_package_lifecycle_tasks: [false, false, false, false], plugin_package_lifecycle_tasks: [false, false, false, false],
plugin_package_lifecycle_plans: [false, false, false, false], plugin_package_lifecycle_plans: [false, false, false, false],
plugin_package_automation_publications: [false, false, false, false], plugin_package_automation_publications: [false, false, false, false],
plugin_package_automation_disposition_events: [false, false, false, false],
plugin_package_automation_publication_heads: [false, false, false, false], plugin_package_automation_publication_heads: [false, false, false, false],
plugin_package_workflow_admissions: [false, false, false, false], plugin_package_workflow_admissions: [false, false, false, false],
plugin_package_workflow_admission_steps: [false, false, false, false], plugin_package_workflow_admission_steps: [false, false, false, false],
@@ -748,7 +750,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro
serverMajor: 16, serverMajor: 16,
currentUser: 'ql3_runtime', currentUser: 'ql3_runtime',
contractName: 'control-core', contractName: 'control-core',
contractVersion: 56, contractVersion: 57,
migrationIds: [ migrationIds: [
'pg-0001-schema-capability', 'pg-0001-schema-capability',
'pg-0002-run-core', 'pg-0002-run-core',
@@ -807,6 +809,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro
'pg-0055-run-attempt-log-retention', 'pg-0055-run-attempt-log-retention',
'pg-0056-run-management-boundary', 'pg-0056-run-management-boundary',
'pg-0057-run-management-stop-boundary', 'pg-0057-run-management-stop-boundary',
'pg-0058-plugin-package-automation-disposition-events',
], ],
}); });
}); });
@@ -837,10 +840,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async (
}), }),
); );
assert.equal(report.currentUser, 'ql3_admin'); assert.equal(report.currentUser, 'ql3_admin');
assert.equal(report.contractVersion, 56); assert.equal(report.contractVersion, 57);
assert.equal( assert.equal(
report.migrationIds.at(-1), report.migrationIds.at(-1),
'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events',
); );
}); });
@@ -853,10 +856,10 @@ test('accepts the isolated least-privilege automation manager role', async () =>
}), }),
); );
assert.equal(report.currentUser, 'ql3_automation_manager'); assert.equal(report.currentUser, 'ql3_automation_manager');
assert.equal(report.contractVersion, 56); assert.equal(report.contractVersion, 57);
assert.equal( assert.equal(
report.migrationIds.at(-1), report.migrationIds.at(-1),
'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events',
); );
const widened = automationManagerPrivileges(); const widened = automationManagerPrivileges();
@@ -885,10 +888,10 @@ test('accepts the isolated least-privilege human Approval manager role', async (
}), }),
); );
assert.equal(report.currentUser, 'ql3_approval_manager'); assert.equal(report.currentUser, 'ql3_approval_manager');
assert.equal(report.contractVersion, 56); assert.equal(report.contractVersion, 57);
assert.equal( assert.equal(
report.migrationIds.at(-1), report.migrationIds.at(-1),
'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events',
); );
const widened = approvalManagerPrivileges(); const widened = approvalManagerPrivileges();
@@ -919,10 +922,10 @@ test('accepts the isolated least-privilege Run manager role', async () => {
}), }),
); );
assert.equal(report.currentUser, 'ql3_run_manager'); assert.equal(report.currentUser, 'ql3_run_manager');
assert.equal(report.contractVersion, 56); assert.equal(report.contractVersion, 57);
assert.equal( assert.equal(
report.migrationIds.at(-1), report.migrationIds.at(-1),
'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events',
); );
const widened = runManagerPrivileges(); const widened = runManagerPrivileges();
@@ -1054,10 +1057,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => {
}), }),
); );
assert.equal(report.currentUser, 'ql3_worker_ingress'); assert.equal(report.currentUser, 'ql3_worker_ingress');
assert.equal(report.contractVersion, 56); assert.equal(report.contractVersion, 57);
assert.equal( assert.equal(
report.migrationIds.at(-1), report.migrationIds.at(-1),
'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events',
); );
}); });
@@ -363,9 +363,9 @@ function composeDockerHarness(
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js', '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
], ],
Labels: { Labels: {
'io.qinglong.local.sqlite-contract-min': '44', 'io.qinglong.local.sqlite-contract-min': '45',
'io.qinglong.local.sqlite-contract-max': '44', 'io.qinglong.local.sqlite-contract-max': '45',
'io.qinglong.local.sqlite-write-contract': '44', 'io.qinglong.local.sqlite-write-contract': '45',
'io.qinglong.local.application-config': '2', 'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1', 'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded', 'io.qinglong.ai': 'excluded',
@@ -975,9 +975,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js', '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
], ],
Labels: { Labels: {
'io.qinglong.local.sqlite-contract-min': '44', 'io.qinglong.local.sqlite-contract-min': '45',
'io.qinglong.local.sqlite-contract-max': '44', 'io.qinglong.local.sqlite-contract-max': '45',
'io.qinglong.local.sqlite-write-contract': '44', 'io.qinglong.local.sqlite-write-contract': '45',
'io.qinglong.local.application-config': '2', 'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1', 'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded', 'io.qinglong.ai': 'excluded',
@@ -1029,7 +1029,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async
assert.equal(result.status, 'ready'); assert.equal(result.status, 'ready');
assert.equal(result.generation, 1); assert.equal(result.generation, 1);
assert.equal(result.profile, 'edge'); assert.equal(result.profile, 'edge');
assert.equal(result.sqlite.contractVersion, 44); assert.equal(result.sqlite.contractVersion, 45);
assert.equal(result.image.architecture, 'arm64'); assert.equal(result.image.architecture, 'arm64');
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']); assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
@@ -1129,8 +1129,8 @@ test('applies one Compose generation and exactly replays its health receipt', as
assert.equal(mode(receiptPath), 0o600); assert.equal(mode(receiptPath), 0o600);
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
assert.deepEqual(receipt.sqlite, { assert.deepEqual(receipt.sqlite, {
contractVersion: 44, contractVersion: 45,
writeContractVersion: 44, writeContractVersion: 45,
writeObservation: 'unchanged', writeObservation: 'unchanged',
backup: null, backup: null,
}); });
@@ -1427,8 +1427,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
`${command.request.rolloutId}.sqlite`, `${command.request.rolloutId}.sqlite`,
); );
assert.equal(mode(backupPath), 0o600); assert.equal(mode(backupPath), 0o600);
assert.equal(receipt.sqlite.contractVersion, 44); assert.equal(receipt.sqlite.contractVersion, 45);
assert.equal(receipt.sqlite.writeContractVersion, 44); assert.equal(receipt.sqlite.writeContractVersion, 45);
assert.equal(receipt.sqlite.writeObservation, 'changed'); assert.equal(receipt.sqlite.writeObservation, 'changed');
assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/); assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/);
assert.equal(receipt.sqlite.backup.bytes > 0, true); assert.equal(receipt.sqlite.backup.bytes > 0, true);
@@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async
assert.equal(result.status, 'ready'); assert.equal(result.status, 'ready');
assert.equal(result.profile, 'edge'); assert.equal(result.profile, 'edge');
assert.equal(result.storage.contractName, 'local-control-core'); assert.equal(result.storage.contractName, 'local-control-core');
assert.equal(result.storage.contractVersion, 44); assert.equal(result.storage.contractVersion, 45);
assert.equal(result.storage.migrationCount, 88); assert.equal(result.storage.migrationCount, 90);
assert.equal(result.storage.journalMode, 'delete'); assert.equal(result.storage.journalMode, 'delete');
assert.equal(JSON.stringify(result).includes(state.directory), false); assert.equal(JSON.stringify(result).includes(state.directory), false);
}); });
@@ -193,12 +193,10 @@ function packageArtifact(manifest) {
path: 'package.json', path: 'package.json',
body: Buffer.from(serializePluginPackageManifest(manifest)), body: Buffer.from(serializePluginPackageManifest(manifest)),
}, },
...CLI_PACKAGE_RESOURCES ...CLI_PACKAGE_RESOURCES.map(({ reference, body }) => ({
.map(({ reference, body }) => ({ path: reference.path,
path: reference.path, body,
body, })).sort((left, right) => left.path.localeCompare(right.path)),
}))
.sort((left, right) => left.path.localeCompare(right.path)),
]); ]);
} }
@@ -259,13 +257,11 @@ function actionInput() {
artifactDigest, artifactDigest,
artifactBytes: artifact.byteLength, artifactBytes: artifact.byteLength,
contentDigest: pluginPackageContentTreeDigest( contentDigest: pluginPackageContentTreeDigest(
CLI_PACKAGE_RESOURCES CLI_PACKAGE_RESOURCES.map(({ reference, body }) => ({
.map(({ reference, body }) => ({ path: reference.path,
path: reference.path, bytes: body.byteLength,
bytes: body.byteLength, digest: digest(body),
digest: digest(body), })).sort((left, right) => left.path.localeCompare(right.path)),
}))
.sort((left, right) => left.path.localeCompare(right.path)),
), ),
}, },
architecture: 'arm64', architecture: 'arm64',
@@ -478,9 +474,7 @@ async function activatePackageAutomation(databasePath, lock, manifest) {
const revision = materializePluginPackageResources({ const revision = materializePluginPackageResources({
generation, generation,
lock, lock,
manifestBytes: Buffer.from( manifestBytes: Buffer.from(serializePluginPackageManifest(manifest)),
serializePluginPackageManifest(manifest),
),
resources: generation.resources.map((reference) => ({ resources: generation.resources.map((reference) => ({
reference, reference,
bytes: bodies.get(`${reference.kind}\0${reference.path}`), bytes: bodies.get(`${reference.kind}\0${reference.path}`),
@@ -521,10 +515,8 @@ function publisherTrustRunnerWithOneSnapshotFault() {
inspect: inspectLocalPluginPackagePublisherTrust, inspect: inspectLocalPluginPackagePublisherTrust,
publish: publishLocalPluginPackagePublisherTrust, publish: publishLocalPluginPackagePublisherTrust,
retire: retireLocalPluginPackagePublisherKey, retire: retireLocalPluginPackagePublisherKey,
analyzePublisherKey: analyzePublisherKey: analyzeLocalPluginPackageRecoveryCatalogPublisherKey,
analyzeLocalPluginPackageRecoveryCatalogPublisherKey, proposeRevocation: proposeLocalPluginPackagePublisherKeyRevocation,
proposeRevocation:
proposeLocalPluginPackagePublisherKeyRevocation,
async confirmRevocation(options) { async confirmRevocation(options) {
return confirmLocalPluginPackagePublisherKeyRevocation({ return confirmLocalPluginPackagePublisherKeyRevocation({
...options, ...options,
@@ -896,10 +888,7 @@ test('runs the private command-file package lifecycle with replay-safe IDs', asy
const catalogHelp = spawnSync( const catalogHelp = spawnSync(
process.execPath, process.execPath,
[ [
path.join( path.join(__dirname, '../dist/plugin-package/pluginPackageCatalogCli.js'),
__dirname,
'../dist/plugin-package/pluginPackageCatalogCli.js',
),
'--help', '--help',
], ],
{ encoding: 'utf8' }, { encoding: 'utf8' },
@@ -1183,15 +1172,29 @@ test('runs the private command-file package lifecycle with replay-safe IDs', asy
WHERE project_id = ? AND package_name = ?`, WHERE project_id = ? AND package_name = ?`,
) )
.get(lock.projectId, lock.packageName); .get(lock.projectId, lock.packageName);
assert.deepEqual({ ...automationHead }, { const automationRepository =
publicationDigest: new LocalSqlitePluginPackageAutomationPublicationRepository(
activeAutomation.publication.publicationDigest,
state: 'active',
});
assert.equal(
await new LocalSqlitePluginPackageAutomationPublicationRepository(
quarantineAuthority, quarantineAuthority,
).isStartAllowed( );
const withdrawnAutomation = await automationRepository.findCurrent(
lock.projectId,
lock.packageName,
);
assert.ok(withdrawnAutomation);
assert.equal(withdrawnAutomation.state, 'withdrawn');
assert.equal(
withdrawnAutomation.previousPublicationDigest,
activeAutomation.publication.publicationDigest,
);
assert.deepEqual(
{ ...automationHead },
{
publicationDigest: withdrawnAutomation.publicationDigest,
state: 'withdrawn',
},
);
assert.equal(
await automationRepository.isStartAllowed(
lock.projectId, lock.projectId,
lock.packageName, lock.packageName,
activeAutomation.publication.publicationDigest, activeAutomation.publication.publicationDigest,
@@ -98,6 +98,8 @@ import { local0085PluginPackageWorkflowRunListIndexMigration } from '../migratio
import { local0086CapabilityV43Migration } from '../migrations/0086-capability-v43'; import { local0086CapabilityV43Migration } from '../migrations/0086-capability-v43';
import { local0087RunAttemptLogRetentionMigration } from '../migrations/0087-run-attempt-log-retention'; import { local0087RunAttemptLogRetentionMigration } from '../migrations/0087-run-attempt-log-retention';
import { local0088CapabilityV44Migration } from '../migrations/0088-capability-v44'; import { local0088CapabilityV44Migration } from '../migrations/0088-capability-v44';
import { local0089PluginPackageAutomationDispositionEventsMigration } from '../migrations/0089-plugin-package-automation-disposition-events';
import { local0090CapabilityV45Migration } from '../migrations/0090-capability-v45';
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration'; import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
import { import {
LOCAL_SQLITE_MIGRATION_STREAM_ID, LOCAL_SQLITE_MIGRATION_STREAM_ID,
@@ -208,6 +210,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
local0086CapabilityV43Migration, local0086CapabilityV43Migration,
local0087RunAttemptLogRetentionMigration, local0087RunAttemptLogRetentionMigration,
local0088CapabilityV44Migration, local0088CapabilityV44Migration,
local0089PluginPackageAutomationDispositionEventsMigration,
local0090CapabilityV45Migration,
]), ]),
}); });
@@ -452,5 +452,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
checksum: checksum:
'c47a61b140b54d448c30ce7d5f7927c0d16fb897ab36dbe1d6010da2c39075a7', 'c47a61b140b54d448c30ce7d5f7927c0d16fb897ab36dbe1d6010da2c39075a7',
}), }),
Object.freeze({
id: '0089-plugin-package-automation-disposition-events',
checksum:
'3eaff9c7621fc4a69a7605fd7de29d38dfde856df13b5e467ccc2bf1245e21aa',
}),
Object.freeze({
id: '0090-capability-v45',
checksum:
'1919987d29ef581e150116c590f1dc98f5d327791fc6425d5f1a27b8f6de5475',
}),
]), ]),
}); });
@@ -70,9 +70,7 @@ CREATE TABLE IF NOT EXISTS "QingLong3SchemaMigrations" (
).map(record); ).map(record);
} }
async findById( async findById(migrationId: string): Promise<MigrationStreamRecord | null> {
migrationId: string,
): Promise<MigrationStreamRecord | null> {
const row = this.client const row = this.client
.prepare( .prepare(
`SELECT migration_id, stream_id, dialect, checksum, applied_at_ms `SELECT migration_id, stream_id, dialect, checksum, applied_at_ms
@@ -88,6 +86,11 @@ CREATE TABLE IF NOT EXISTS "QingLong3SchemaMigrations" (
transaction: MigrationStreamTransaction<LocalSqliteMigrationContext>, transaction: MigrationStreamTransaction<LocalSqliteMigrationContext>,
) => Promise<T>, ) => Promise<T>,
): Promise<T> { ): Promise<T> {
const foreignKeys = this.client.prepare('PRAGMA foreign_keys').get() as
| { foreign_keys?: unknown }
| undefined;
const restoreForeignKeys = foreignKeys?.foreign_keys === 1;
if (restoreForeignKeys) this.client.exec('PRAGMA foreign_keys = OFF');
this.client.exec('BEGIN IMMEDIATE'); this.client.exec('BEGIN IMMEDIATE');
try { try {
const result = await work({ const result = await work({
@@ -116,9 +119,11 @@ CREATE TABLE IF NOT EXISTS "QingLong3SchemaMigrations" (
}, },
}); });
this.client.exec('COMMIT'); this.client.exec('COMMIT');
if (restoreForeignKeys) this.client.exec('PRAGMA foreign_keys = ON');
return result; return result;
} catch (error) { } catch (error) {
if (this.client.isTransaction) this.client.exec('ROLLBACK'); if (this.client.isTransaction) this.client.exec('ROLLBACK');
if (restoreForeignKeys) this.client.exec('PRAGMA foreign_keys = ON');
throw error; throw error;
} }
} }
@@ -0,0 +1,105 @@
import { defineLocalSqliteMigration } from './sqlMigration';
export const local0089PluginPackageAutomationDispositionEventsMigration =
defineLocalSqliteMigration({
id: '0089-plugin-package-automation-disposition-events',
statements: [
`
CREATE TABLE "QingLong3PluginPackageAutomationDispositionEvents" (
event_digest TEXT PRIMARY KEY NOT NULL,
event_kind TEXT NOT NULL
CONSTRAINT ql3_plugin_package_automation_disposition_kind_check
CHECK (event_kind IN ('lifecycle','quarantine')),
CONSTRAINT ql3_plugin_package_automation_disposition_digest_check CHECK (
length(event_digest) = 64 AND
event_digest NOT GLOB '*[^0-9a-f]*'
)
)
`,
`INSERT INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) SELECT event_digest, 'lifecycle' FROM "QingLong3PluginPackageLifecycleEvents"`,
`INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) SELECT event_digest, 'quarantine' FROM "QingLong3PluginPackageQuarantineEvents"`,
`CREATE TRIGGER ql3_plugin_package_automation_lifecycle_disposition_insert AFTER INSERT ON "QingLong3PluginPackageLifecycleEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'lifecycle'); END`,
`CREATE TRIGGER ql3_plugin_package_automation_quarantine_disposition_insert AFTER INSERT ON "QingLong3PluginPackageQuarantineEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'quarantine'); END`,
`PRAGMA defer_foreign_keys = ON`,
`PRAGMA legacy_alter_table = ON`,
`ALTER TABLE "QingLong3PluginPackageAutomationPublications" RENAME TO "QingLong3PluginPackageAutomationPublicationsBeforeDisposition"`,
`
CREATE TABLE "QingLong3PluginPackageAutomationPublications" (
publication_digest TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL,
package_name TEXT NOT NULL,
installation_id TEXT NOT NULL,
lock_digest TEXT NOT NULL,
generation INTEGER NOT NULL,
generation_digest TEXT NOT NULL,
materialized_revision_digest TEXT NOT NULL,
state TEXT NOT NULL,
version INTEGER NOT NULL,
previous_publication_digest TEXT,
lifecycle_event_digest TEXT,
published_at_ms INTEGER NOT NULL,
publication_json TEXT NOT NULL,
CONSTRAINT ql3_plugin_package_automation_publication_revision_fk
FOREIGN KEY (generation_digest)
REFERENCES "QingLong3PluginPackageMaterializedRevisions" (generation_digest)
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_plugin_package_automation_publication_previous_fk
FOREIGN KEY (previous_publication_digest)
REFERENCES "QingLong3PluginPackageAutomationPublications" (publication_digest)
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_plugin_package_automation_publication_disposition_fk
FOREIGN KEY (lifecycle_event_digest)
REFERENCES "QingLong3PluginPackageAutomationDispositionEvents" (event_digest)
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_plugin_package_automation_publication_identity_check CHECK (
length(project_id) BETWEEN 1 AND 128 AND
length(package_name) BETWEEN 1 AND 63 AND
length(installation_id) BETWEEN 1 AND 128 AND
generation BETWEEN 1 AND 2147483647 AND
state IN ('active','withdrawn','absent') AND
version BETWEEN 1 AND 2147483647 AND published_at_ms >= 0 AND
(version = 1 AND state IN ('active','absent') AND
previous_publication_digest IS NULL AND lifecycle_event_digest IS NULL OR
version > 1 AND previous_publication_digest IS NOT NULL) AND
(state <> 'withdrawn' OR lifecycle_event_digest IS NOT NULL)
),
CONSTRAINT ql3_plugin_package_automation_publication_digest_check CHECK (
length(publication_digest) = 64 AND publication_digest NOT GLOB '*[^0-9a-f]*' AND
length(lock_digest) = 64 AND lock_digest NOT GLOB '*[^0-9a-f]*' AND
length(generation_digest) = 64 AND generation_digest NOT GLOB '*[^0-9a-f]*' AND
length(materialized_revision_digest) = 64 AND materialized_revision_digest NOT GLOB '*[^0-9a-f]*' AND
(previous_publication_digest IS NULL OR length(previous_publication_digest) = 64 AND previous_publication_digest NOT GLOB '*[^0-9a-f]*') AND
(lifecycle_event_digest IS NULL OR length(lifecycle_event_digest) = 64 AND lifecycle_event_digest NOT GLOB '*[^0-9a-f]*')
),
CONSTRAINT ql3_plugin_package_automation_publication_json_check CHECK (
length(CAST(publication_json AS BLOB)) BETWEEN 2 AND 12582912 AND
json_valid(publication_json) AND json_type(publication_json) = 'object' AND
json_extract(publication_json, '$.schema') = 'qinglong/plugin-package-automation-publication@v1' AND
json_extract(publication_json, '$.target.projectId') = project_id AND
json_extract(publication_json, '$.target.packageName') = package_name AND
json_extract(publication_json, '$.target.installationId') = installation_id AND
json_extract(publication_json, '$.target.lockDigest') = lock_digest AND
json_extract(publication_json, '$.target.generation') = generation AND
json_extract(publication_json, '$.target.generationDigest') = generation_digest AND
json_extract(publication_json, '$.target.materializedRevisionDigest') = materialized_revision_digest AND
json_extract(publication_json, '$.state') = state AND
json_extract(publication_json, '$.version') = version AND
(previous_publication_digest IS NULL AND json_type(publication_json, '$.previousPublicationDigest') = 'null' OR json_extract(publication_json, '$.previousPublicationDigest') = previous_publication_digest) AND
(lifecycle_event_digest IS NULL AND json_type(publication_json, '$.lifecycleEventDigest') = 'null' OR json_extract(publication_json, '$.lifecycleEventDigest') = lifecycle_event_digest) AND
json_extract(publication_json, '$.publishedAtMs') = published_at_ms AND
json_extract(publication_json, '$.publicationDigest') = publication_digest AND
json_type(publication_json, '$.definitions.workflows') = 'array' AND
json_type(publication_json, '$.definitions.prompts') = 'array' AND
(state = 'absent' AND json_array_length(json_extract(publication_json, '$.definitions.workflows')) + json_array_length(json_extract(publication_json, '$.definitions.prompts')) = 0 OR
state <> 'absent' AND json_array_length(json_extract(publication_json, '$.definitions.workflows')) + json_array_length(json_extract(publication_json, '$.definitions.prompts')) > 0)
)
)
`,
`INSERT INTO "QingLong3PluginPackageAutomationPublications" SELECT * FROM "QingLong3PluginPackageAutomationPublicationsBeforeDisposition" ORDER BY project_id, package_name, version`,
`DROP TABLE "QingLong3PluginPackageAutomationPublicationsBeforeDisposition"`,
`CREATE UNIQUE INDEX ql3_plugin_package_automation_publication_version_uidx ON "QingLong3PluginPackageAutomationPublications" (project_id, package_name, version)`,
`CREATE UNIQUE INDEX ql3_plugin_package_automation_publication_previous_uidx ON "QingLong3PluginPackageAutomationPublications" (previous_publication_digest) WHERE previous_publication_digest IS NOT NULL`,
`CREATE INDEX ql3_plugin_package_automation_publication_generation_idx ON "QingLong3PluginPackageAutomationPublications" (generation_digest, publication_digest)`,
`PRAGMA legacy_alter_table = OFF`,
],
});
@@ -0,0 +1,14 @@
import { CAPABILITIES_V44 } from './0088-capability-v44';
import { defineLocalSqliteMigration } from './sqlMigration';
export const CAPABILITIES_V45 = CAPABILITIES_V44.replace(
'"plugin_package_automation_publication":1,',
'"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,',
);
export const local0090CapabilityV45Migration = defineLocalSqliteMigration({
id: '0090-capability-v45',
statements: [
`UPDATE "QingLong3SchemaCapabilities" SET contract_version = 45, migration_id = '0089-plugin-package-automation-disposition-events', capabilities = '${CAPABILITIES_V45}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 44 AND migration_id = '0087-run-attempt-log-retention' AND capabilities = '${CAPABILITIES_V44}'`,
],
});
@@ -44,7 +44,10 @@ function integer(row: Row, key: string): number {
return value as number; return value as number;
} }
function targetIdentity(projectId: unknown, packageName: unknown): { function targetIdentity(
projectId: unknown,
packageName: unknown,
): {
readonly projectId: string; readonly projectId: string;
readonly packageName: string; readonly packageName: string;
} { } {
@@ -119,19 +122,18 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
#parse(row: Row): Readonly<PluginPackageAutomationPublication> { #parse(row: Row): Readonly<PluginPackageAutomationPublication> {
try { try {
const publication = normalizePluginPackageAutomationPublication( const publication = normalizePluginPackageAutomationPublication(
JSON.parse(text(row, 'publicationJson')) as JSON.parse(
PluginPackageAutomationPublication, text(row, 'publicationJson'),
) as PluginPackageAutomationPublication,
); );
if ( if (
publication.publicationDigest !== publication.publicationDigest !== text(row, 'publicationDigest') ||
text(row, 'publicationDigest') ||
publication.target.projectId !== text(row, 'projectId') || publication.target.projectId !== text(row, 'projectId') ||
publication.target.packageName !== text(row, 'packageName') || publication.target.packageName !== text(row, 'packageName') ||
publication.target.installationId !== text(row, 'installationId') || publication.target.installationId !== text(row, 'installationId') ||
publication.target.lockDigest !== text(row, 'lockDigest') || publication.target.lockDigest !== text(row, 'lockDigest') ||
publication.target.generation !== integer(row, 'generation') || publication.target.generation !== integer(row, 'generation') ||
publication.target.generationDigest !== publication.target.generationDigest !== text(row, 'generationDigest') ||
text(row, 'generationDigest') ||
publication.target.materializedRevisionDigest !== publication.target.materializedRevisionDigest !==
text(row, 'materializedRevisionDigest') || text(row, 'materializedRevisionDigest') ||
publication.state !== text(row, 'state') || publication.state !== text(row, 'state') ||
@@ -142,9 +144,7 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
} }
return publication; return publication;
} catch (error) { } catch (error) {
if ( if (error instanceof PluginPackageAutomationPublicationUnavailableError) {
error instanceof PluginPackageAutomationPublicationUnavailableError
) {
throw error; throw error;
} }
throw new PluginPackageAutomationPublicationUnavailableError(); throw new PluginPackageAutomationPublicationUnavailableError();
@@ -389,8 +389,9 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
return this.#findCurrent(projectId, packageName); return this.#findCurrent(projectId, packageName);
} }
publishInTransaction( #publishInTransaction(
value: Readonly<PluginPackageAutomationPublication>, value: Readonly<PluginPackageAutomationPublication>,
securityWithdrawal: boolean,
): Readonly<{ ): Readonly<{
status: 'created' | 'existing'; status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>; publication: Readonly<PluginPackageAutomationPublication>;
@@ -430,10 +431,7 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
); );
} }
try { try {
assertPluginPackageAutomationPublicationSuccessor( assertPluginPackageAutomationPublicationSuccessor(current, publication);
current,
publication,
);
} catch (error) { } catch (error) {
if (error instanceof InvalidPluginPackageAutomationPublicationError) { if (error instanceof InvalidPluginPackageAutomationPublicationError) {
throw new PluginPackageAutomationPublicationConflictError( throw new PluginPackageAutomationPublicationConflictError(
@@ -467,33 +465,35 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
'materialized revision fence does not match publication target', 'materialized revision fence does not match publication target',
); );
} }
const securityFence = client if (!securityWithdrawal) {
.prepare( const securityFence = client
`SELECT EXISTS ( .prepare(
SELECT 1 `SELECT EXISTS (
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine SELECT 1
WHERE quarantine.project_id = ? FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
AND quarantine.package_name = ? WHERE quarantine.project_id = ?
AND quarantine.installation_id = ? AND quarantine.package_name = ?
AND quarantine.lock_digest = ? AND quarantine.installation_id = ?
) AS "blocked"`, AND quarantine.lock_digest = ?
) ) AS "blocked"`,
.get( )
publication.target.projectId, .get(
publication.target.packageName, publication.target.projectId,
publication.target.installationId, publication.target.packageName,
publication.target.lockDigest, publication.target.installationId,
) as Row | undefined; publication.target.lockDigest,
if ( ) as Row | undefined;
!securityFence || if (
(securityFence.blocked !== 0 && securityFence.blocked !== 1) !securityFence ||
) { (securityFence.blocked !== 0 && securityFence.blocked !== 1)
throw new PluginPackageAutomationPublicationUnavailableError(); ) {
} throw new PluginPackageAutomationPublicationUnavailableError();
if (securityFence.blocked === 1) { }
throw new PluginPackageAutomationPublicationConflictError( if (securityFence.blocked === 1) {
'quarantined Package generation cannot publish automation', throw new PluginPackageAutomationPublicationConflictError(
); 'quarantined Package generation cannot publish automation',
);
}
} }
client client
.prepare( .prepare(
@@ -570,9 +570,31 @@ export class LocalSqlitePluginPackageAutomationPublicationRepository
}); });
} }
publish( publishInTransaction(
value: Readonly<PluginPackageAutomationPublication>, value: Readonly<PluginPackageAutomationPublication>,
): Promise< ): Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}> {
return this.#publishInTransaction(value, false);
}
publishSecurityWithdrawalInTransaction(
value: Readonly<PluginPackageAutomationPublication>,
): Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}> {
const publication = normalizePluginPackageAutomationPublication(value);
if (publication.state !== 'withdrawn') {
throw new PluginPackageAutomationPublicationConflictError(
'security withdrawal must narrow automation state',
);
}
return this.#publishInTransaction(publication, true);
}
publish(value: Readonly<PluginPackageAutomationPublication>): Promise<
Readonly<{ Readonly<{
status: 'created' | 'existing'; status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>; publication: Readonly<PluginPackageAutomationPublication>;
@@ -20,6 +20,7 @@ import {
normalizePluginPackageInstallRecord, normalizePluginPackageInstallRecord,
type PluginPackageInstallRecord, type PluginPackageInstallRecord,
} from '@qinglong/runtime-core/plugin-package-install'; } from '@qinglong/runtime-core/plugin-package-install';
import { createPluginPackageAutomationLifecyclePublication } from '@qinglong/runtime-core/plugin-package-automation-publication';
import { import {
createProjectToolDefinitionSnapshot, createProjectToolDefinitionSnapshot,
normalizeProjectToolDefinitionSnapshot, normalizeProjectToolDefinitionSnapshot,
@@ -40,6 +41,7 @@ import {
} from '@qinglong/runtime-core/task-spec-semantic'; } from '@qinglong/runtime-core/task-spec-semantic';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority'; import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqlitePluginPackageAutomationPublicationRepository } from './pluginPackageAutomationPublicationRepository';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
@@ -314,6 +316,30 @@ export class LocalSqlitePluginPackageQuarantineRepository
if (error instanceof PluginPackageQuarantineUnavailableError) throw error; if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
throw new PluginPackageQuarantineUnavailableError(); throw new PluginPackageQuarantineUnavailableError();
} }
const automation = this.#authority.client
.prepare(
`SELECT state,
lifecycle_event_digest AS "lifecycleEventDigest"
FROM "QingLong3PluginPackageAutomationPublications"
WHERE project_id = ? AND package_name = ?
AND installation_id = ? AND lock_digest = ?
ORDER BY version DESC
LIMIT 1`,
)
.get(
receipt.target.projectId,
receipt.target.packageName,
receipt.target.installationId,
receipt.target.lockDigest,
) as Row | undefined;
if (
automation &&
(text(automation, 'state') === 'active' ||
(automation.lifecycleEventDigest === receipt.eventDigest &&
text(automation, 'state') !== 'withdrawn'))
) {
throw new PluginPackageQuarantineUnavailableError();
}
} }
#findStored( #findStored(
@@ -433,6 +459,40 @@ export class LocalSqlitePluginPackageQuarantineRepository
return record; return record;
} }
#withdrawAutomation(
event: Readonly<PluginPackageQuarantineEvent>,
record: Readonly<PluginPackageInstallRecord>,
committedAtMs: number,
): void {
const publications =
new LocalSqlitePluginPackageAutomationPublicationRepository(
this.#authority,
);
const current = publications.findCurrentInTransaction(
event.target.projectId,
event.target.packageName,
);
if (!current) return;
if (
current.target.installationId !== event.target.installationId ||
current.target.lockDigest !== event.target.lockDigest ||
current.target.generation !== record.targetGeneration
) {
throw new PluginPackageQuarantineConflictError(
'Workflow/Prompt publication does not match the quarantined Package generation',
);
}
if (current.state === 'absent' || current.state === 'withdrawn') return;
publications.publishSecurityWithdrawalInTransaction(
createPluginPackageAutomationLifecyclePublication({
previous: current,
state: 'withdrawn',
lifecycleEventDigest: event.eventDigest,
publishedAtMs: committedAtMs,
}),
);
}
#activeContributions( #activeContributions(
projectId: string, projectId: string,
): readonly Readonly<ProjectToolDefinitionSnapshotContribution>[] { ): readonly Readonly<ProjectToolDefinitionSnapshotContribution>[] {
@@ -792,7 +852,7 @@ export class LocalSqlitePluginPackageQuarantineRepository
'target lock is already quarantined by another event', 'target lock is already quarantined by another event',
); );
} }
this.#install(event); const install = this.#install(event);
const clock = client const clock = client
.prepare( .prepare(
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS "nowMs"`, `SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS "nowMs"`,
@@ -857,6 +917,7 @@ export class LocalSqlitePluginPackageQuarantineRepository
), ),
); );
this.#insertEvent(event); this.#insertEvent(event);
this.#withdrawAutomation(event, install, committedAtMs);
this.#publishSnapshot(snapshot, committedAtMs); this.#publishSnapshot(snapshot, committedAtMs);
const receipt = createPluginPackageWithdrawalReceipt({ const receipt = createPluginPackageWithdrawalReceipt({
eventDigest: event.eventDigest, eventDigest: event.eventDigest,
@@ -8,7 +8,20 @@ import {
} from '../run/stepRunSchemaContract'; } from '../run/stepRunSchemaContract';
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core'; export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
export const LOCAL_SQLITE_CONTRACT_VERSION = 44; export const LOCAL_SQLITE_CONTRACT_VERSION = 45;
const PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS = Object.freeze([
Object.freeze({
name: 'ql3_plugin_package_automation_lifecycle_disposition_insert',
tableName: 'QingLong3PluginPackageLifecycleEvents',
sql: `CREATE TRIGGER ql3_plugin_package_automation_lifecycle_disposition_insert AFTER INSERT ON "QingLong3PluginPackageLifecycleEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'lifecycle'); END`,
}),
Object.freeze({
name: 'ql3_plugin_package_automation_quarantine_disposition_insert',
tableName: 'QingLong3PluginPackageQuarantineEvents',
sql: `CREATE TRIGGER ql3_plugin_package_automation_quarantine_disposition_insert AFTER INSERT ON "QingLong3PluginPackageQuarantineEvents" BEGIN INSERT OR IGNORE INTO "QingLong3PluginPackageAutomationDispositionEvents" (event_digest, event_kind) VALUES (NEW.event_digest, 'quarantine'); END`,
}),
]);
const OPTIONAL_FEATURE_TABLE_NAMES = new Set([ const OPTIONAL_FEATURE_TABLE_NAMES = new Set([
'QingLong3AiSchemaMigrations', 'QingLong3AiSchemaMigrations',
@@ -1007,6 +1020,10 @@ const REQUIRED_SCHEMA = Object.freeze({
'ql3_plugin_package_automation_publication_generation_idx', 'ql3_plugin_package_automation_publication_generation_idx',
]), ]),
}), }),
QingLong3PluginPackageAutomationDispositionEvents: Object.freeze({
columns: Object.freeze(['event_digest', 'event_kind']),
indexes: Object.freeze([]),
}),
QingLong3PluginPackageAutomationPublicationHeads: Object.freeze({ QingLong3PluginPackageAutomationPublicationHeads: Object.freeze({
columns: Object.freeze([ columns: Object.freeze([
'project_id', 'project_id',
@@ -1702,9 +1719,10 @@ function assertRequiredSchema(client: DatabaseSync): number {
ORDER BY name`, ORDER BY name`,
) )
.all(...ownedTableNames) as unknown as TriggerRow[]; .all(...ownedTableNames) as unknown as TriggerRow[];
const expectedTriggers = [...LOCAL_STEP_RUN_REFERENCE_TRIGGERS].sort( const expectedTriggers = [
(left, right) => left.name.localeCompare(right.name), ...LOCAL_STEP_RUN_REFERENCE_TRIGGERS,
); ...PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS,
].sort((left, right) => left.name.localeCompare(right.name));
if ( if (
triggerRows.length !== expectedTriggers.length || triggerRows.length !== expectedTriggers.length ||
triggerRows.some((row, index) => { triggerRows.some((row, index) => {
@@ -2097,8 +2115,15 @@ function assertPluginPackageAutomationPublicationIntegrity(
LEFT JOIN "QingLong3PluginPackageAutomationPublications" AS previous LEFT JOIN "QingLong3PluginPackageAutomationPublications" AS previous
ON previous.publication_digest = ON previous.publication_digest =
publication.previous_publication_digest publication.previous_publication_digest
LEFT JOIN "QingLong3PluginPackageAutomationDispositionEvents"
AS disposition
ON disposition.event_digest = publication.lifecycle_event_digest
LEFT JOIN "QingLong3PluginPackageLifecycleEvents" AS lifecycle LEFT JOIN "QingLong3PluginPackageLifecycleEvents" AS lifecycle
ON lifecycle.event_digest = publication.lifecycle_event_digest ON lifecycle.event_digest = publication.lifecycle_event_digest
AND disposition.event_kind = 'lifecycle'
LEFT JOIN "QingLong3PluginPackageQuarantineEvents" AS quarantine
ON quarantine.event_digest = publication.lifecycle_event_digest
AND disposition.event_kind = 'quarantine'
WHERE materialized.generation_digest IS NULL WHERE materialized.generation_digest IS NULL
OR materialized.project_id <> publication.project_id OR materialized.project_id <> publication.project_id
OR materialized.package_name <> publication.package_name OR materialized.package_name <> publication.package_name
@@ -2129,14 +2154,25 @@ function assertPluginPackageAutomationPublicationIntegrity(
) )
OR ( OR (
publication.lifecycle_event_digest IS NOT NULL AND ( publication.lifecycle_event_digest IS NOT NULL AND (
lifecycle.event_digest IS NULL OR disposition.event_digest IS NULL OR
lifecycle.project_id <> publication.project_id OR disposition.event_kind = 'lifecycle' AND (
lifecycle.package_name <> publication.package_name OR lifecycle.event_digest IS NULL OR
lifecycle.installation_id <> publication.installation_id OR lifecycle.project_id <> publication.project_id OR
lifecycle.lock_digest <> publication.lock_digest OR lifecycle.package_name <> publication.package_name OR
lifecycle.generation_digest <> publication.generation_digest OR lifecycle.installation_id <> publication.installation_id OR
lifecycle.materialized_revision_digest <> lifecycle.lock_digest <> publication.lock_digest OR
publication.materialized_revision_digest OR lifecycle.generation_digest <> publication.generation_digest OR
lifecycle.materialized_revision_digest <>
publication.materialized_revision_digest
) OR
disposition.event_kind = 'quarantine' AND (
quarantine.event_digest IS NULL OR
quarantine.project_id <> publication.project_id OR
quarantine.package_name <> publication.package_name OR
quarantine.installation_id <> publication.installation_id OR
quarantine.lock_digest <> publication.lock_digest OR
publication.state <> 'withdrawn'
) OR
previous.installation_id <> publication.installation_id OR previous.installation_id <> publication.installation_id OR
previous.lock_digest <> publication.lock_digest OR previous.lock_digest <> publication.lock_digest OR
previous.generation_digest <> publication.generation_digest OR previous.generation_digest <> publication.generation_digest OR
@@ -2496,10 +2532,11 @@ export async function auditLocalSqliteReadiness(
!capability || !capability ||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME || capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION || capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
capability.migration_id !== '0087-run-attempt-log-retention' || capability.migration_id !==
'0089-plugin-package-automation-disposition-events' ||
typeof capability.capabilities !== 'string' || typeof capability.capabilities !== 'string' ||
capability.capabilities !== capability.capabilities !==
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' || '{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
typeof capability.updated_at_ms !== 'number' || typeof capability.updated_at_ms !== 'number' ||
!Number.isSafeInteger(capability.updated_at_ms) || !Number.isSafeInteger(capability.updated_at_ms) ||
capability.updated_at_ms < 0 capability.updated_at_ms < 0
@@ -4486,6 +4486,24 @@ export const pluginPackageLifecycleTasks = sqliteTable(
], ],
); );
export const pluginPackageAutomationDispositionEvents = sqliteTable(
'QingLong3PluginPackageAutomationDispositionEvents',
{
eventDigest: text('event_digest').primaryKey(),
eventKind: text('event_kind').notNull(),
},
(table) => [
check(
'ql3_plugin_package_automation_disposition_kind_check',
sql`${table.eventKind} in ('lifecycle','quarantine')`,
),
check(
'ql3_plugin_package_automation_disposition_digest_check',
sql`length(${table.eventDigest}) = 64 and ${table.eventDigest} not glob '*[^0-9a-f]*'`,
),
],
);
export const pluginPackageAutomationPublications = sqliteTable( export const pluginPackageAutomationPublications = sqliteTable(
'QingLong3PluginPackageAutomationPublications', 'QingLong3PluginPackageAutomationPublications',
{ {
@@ -4510,7 +4528,7 @@ export const pluginPackageAutomationPublications = sqliteTable(
{ onDelete: 'restrict', onUpdate: 'restrict' }, { onDelete: 'restrict', onUpdate: 'restrict' },
), ),
lifecycleEventDigest: text('lifecycle_event_digest').references( lifecycleEventDigest: text('lifecycle_event_digest').references(
() => pluginPackageLifecycleEvents.eventDigest, () => pluginPackageAutomationDispositionEvents.eventDigest,
{ onDelete: 'restrict', onUpdate: 'restrict' }, { onDelete: 'restrict', onUpdate: 'restrict' },
), ),
publishedAtMs: integer('published_at_ms').notNull(), publishedAtMs: integer('published_at_ms').notNull(),
@@ -4877,6 +4895,7 @@ export const localSqliteSchema = Object.freeze({
pluginPackageLifecycleHeads, pluginPackageLifecycleHeads,
pluginPackageLifecycleReceipts, pluginPackageLifecycleReceipts,
pluginPackageLifecycleTasks, pluginPackageLifecycleTasks,
pluginPackageAutomationDispositionEvents,
pluginPackageAutomationPublications, pluginPackageAutomationPublications,
pluginPackageAutomationPublicationHeads, pluginPackageAutomationPublicationHeads,
pluginPackageWorkflowAdmissions, pluginPackageWorkflowAdmissions,
@@ -138,9 +138,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
'0086-capability-v43', '0086-capability-v43',
'0087-run-attempt-log-retention', '0087-run-attempt-log-retention',
'0088-capability-v44', '0088-capability-v44',
'0089-plugin-package-automation-disposition-events',
'0090-capability-v45',
]); ]);
assert.equal(migrated.readiness.contractName, 'local-control-core'); assert.equal(migrated.readiness.contractName, 'local-control-core');
assert.equal(migrated.readiness.contractVersion, 44); assert.equal(migrated.readiness.contractVersion, 45);
assert.equal(migrated.readiness.journalMode, 'delete'); assert.equal(migrated.readiness.journalMode, 'delete');
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600); assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
@@ -503,8 +505,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
.get(), .get(),
}, },
{ {
contract_version: 44, contract_version: 45,
migration_id: '0087-run-attempt-log-retention', migration_id: '0089-plugin-package-automation-disposition-events',
}, },
); );
} finally { } finally {
@@ -691,19 +693,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
const options = { databasePath, profile: 'edge' }; const options = { databasePath, profile: 'edge' };
await migrateLocalSqlitePath(options); await migrateLocalSqlitePath(options);
const client = new DatabaseSync(databasePath); const client = new DatabaseSync(databasePath);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 78); assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
client.exec( client.exec(
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)', 'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
); );
client.close(); client.close();
assert.equal((await auditLocalSqlitePath(options)).tableCount, 78); assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
const unknownClient = new DatabaseSync(databasePath); const unknownClient = new DatabaseSync(databasePath);
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)'); unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
unknownClient.close(); unknownClient.close();
assert.equal((await auditLocalSqlitePath(options)).tableCount, 79); assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
const triggerClient = new DatabaseSync(databasePath); const triggerClient = new DatabaseSync(databasePath);
triggerClient.exec(` triggerClient.exec(`
@@ -4,6 +4,9 @@ const { DatabaseSync } = require('node:sqlite');
const { const {
createPluginPackageQuarantineEvent, createPluginPackageQuarantineEvent,
} = require('@qinglong/runtime-core/plugin-package-quarantine'); } = require('@qinglong/runtime-core/plugin-package-quarantine');
const {
createInitialPluginPackageAutomationPublication,
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
const { const {
activateInstall, activateInstall,
pluginPackageTaskReconciliationFixture, pluginPackageTaskReconciliationFixture,
@@ -14,6 +17,9 @@ const {
const { const {
LocalSqlitePluginPackageInstallRepository, LocalSqlitePluginPackageInstallRepository,
} = require('../../dist/plugin-package/pluginPackageInstallRepository'); } = require('../../dist/plugin-package/pluginPackageInstallRepository');
const {
LocalSqlitePluginPackageAutomationPublicationRepository,
} = require('../../dist/plugin-package/pluginPackageAutomationPublicationRepository');
const { const {
LocalSqlitePluginPackageMaterializedRevisionRepository, LocalSqlitePluginPackageMaterializedRevisionRepository,
} = require('../../dist/plugin-package/pluginPackageMaterializedRevisionRepository'); } = require('../../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
@@ -30,6 +36,11 @@ const DIGEST_D = 'd'.repeat(64);
const DIGEST_E = 'e'.repeat(64); const DIGEST_E = 'e'.repeat(64);
const CRASH_POINTS = Object.freeze({ const CRASH_POINTS = Object.freeze({
after_automation_withdrawal: Object.freeze({
timing: 'afterRun',
sql: 'INSERT INTO "QingLong3PluginPackageAutomationPublications"',
durable: false,
}),
after_task_disable: Object.freeze({ after_task_disable: Object.freeze({
timing: 'afterRun', timing: 'afterRun',
sql: 'INSERT INTO "QingLong3TaskDefinitionRevisions"', sql: 'INSERT INTO "QingLong3TaskDefinitionRevisions"',
@@ -60,6 +71,24 @@ const CRASH_POINTS = Object.freeze({
function fixture(profile) { function fixture(profile) {
return pluginPackageTaskReconciliationFixture(`quarantine-crash-${profile}`, { return pluginPackageTaskReconciliationFixture(`quarantine-crash-${profile}`, {
profile, profile,
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'daily',
name: 'Daily workflow',
enabled: true,
steps: [{ id: 'run', task: 'alpha', needs: [] }],
},
],
prompts: [
{
schema: 'qinglong/plugin-package-prompt-resource@v1',
id: 'operator',
name: 'Operator prompt',
template: 'Run {{task}}',
parameters: [{ name: 'task', required: true }],
},
],
}); });
} }
@@ -119,6 +148,15 @@ async function setupScenario({ databasePath, profile }) {
); );
await activateInstall(install, value); await activateInstall(install, value);
await materialized.publish(value.revision); await materialized.publish(value.revision);
await new LocalSqlitePluginPackageAutomationPublicationRepository(
authority,
).publish(
createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
value.install.active.updatedAtMs,
),
);
await reconciliation.reconcile(value.revision, { await reconciliation.reconcile(value.revision, {
async findActiveResourceGeneration() { async findActiveResourceGeneration() {
return value.revision.generation; return value.revision.generation;
@@ -259,6 +297,24 @@ async function verifyScenario({ databasePath, pointName, profile }) {
) { ) {
throw new Error(`${profile}/${pointName} Task withdrawal is incomplete`); throw new Error(`${profile}/${pointName} Task withdrawal is incomplete`);
} }
const automation = database
.prepare(
`SELECT publication.state,
publication.lifecycle_event_digest AS "lifecycleEventDigest"
FROM "QingLong3PluginPackageAutomationPublicationHeads" AS head
JOIN "QingLong3PluginPackageAutomationPublications" AS publication
ON publication.publication_digest = head.publication_digest
WHERE head.project_id = ? AND head.package_name = ?`,
)
.get(value.projectId, value.packageName);
if (
automation?.state !== 'withdrawn' ||
automation.lifecycleEventDigest !== quarantineEvent.eventDigest
) {
throw new Error(
`${profile}/${pointName} automation withdrawal is incomplete`,
);
}
await auditLocalSqliteReadiness(database); await auditLocalSqliteReadiness(database);
const integrity = database.prepare('PRAGMA integrity_check').get(); const integrity = database.prepare('PRAGMA integrity_check').get();
const foreignKey = database const foreignKey = database
@@ -60,10 +60,10 @@ test(
assert.equal(reports.at(-1).durableAfterCrash, point.durable); assert.equal(reports.at(-1).durableAfterCrash, point.durable);
} }
} }
assert.equal(reports.length, 10); assert.equal(reports.length, 12);
assert.equal( assert.equal(
reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length, reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length,
8, 10,
); );
assert.equal( assert.equal(
reports.filter(({ durableAfterCrash }) => durableAfterCrash).length, reports.filter(({ durableAfterCrash }) => durableAfterCrash).length,
@@ -7,6 +7,9 @@ const {
PluginPackageQuarantineUnavailableError, PluginPackageQuarantineUnavailableError,
createPluginPackageQuarantineEvent, createPluginPackageQuarantineEvent,
} = require('@qinglong/runtime-core/plugin-package-quarantine'); } = require('@qinglong/runtime-core/plugin-package-quarantine');
const {
createInitialPluginPackageAutomationPublication,
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
const { const {
RunRepositoryConstraintError, RunRepositoryConstraintError,
} = require('@qinglong/runtime-core/run-repository'); } = require('@qinglong/runtime-core/run-repository');
@@ -17,13 +20,18 @@ const {
activateInstall, activateInstall,
pluginPackageTaskReconciliationFixture, pluginPackageTaskReconciliationFixture,
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs'); } = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority'); const {
LocalSqliteOperationAuthority,
} = require('../dist/authority/operationAuthority');
const { const {
LocalSqlitePluginPackageInstallRepository, LocalSqlitePluginPackageInstallRepository,
} = require('../dist/plugin-package/pluginPackageInstallRepository'); } = require('../dist/plugin-package/pluginPackageInstallRepository');
const { const {
LocalSqlitePluginPackageMaterializedRevisionRepository, LocalSqlitePluginPackageMaterializedRevisionRepository,
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository'); } = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
const {
LocalSqlitePluginPackageAutomationPublicationRepository,
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
const { const {
LocalSqlitePluginPackageTaskReconciliationRepository, LocalSqlitePluginPackageTaskReconciliationRepository,
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository'); } = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
@@ -46,6 +54,24 @@ const digest = (value) => value.repeat(64);
async function harness(t, namespace) { async function harness(t, namespace) {
const fixture = pluginPackageTaskReconciliationFixture(namespace, { const fixture = pluginPackageTaskReconciliationFixture(namespace, {
profile: 'edge', profile: 'edge',
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'daily',
name: 'Daily workflow',
enabled: true,
steps: [{ id: 'run', task: 'alpha', needs: [] }],
},
],
prompts: [
{
schema: 'qinglong/plugin-package-prompt-resource@v1',
id: 'operator',
name: 'Operator prompt',
template: 'Run {{task}}',
parameters: [{ name: 'task', required: true }],
},
],
}); });
const client = new DatabaseSync(':memory:'); const client = new DatabaseSync(':memory:');
client.exec('PRAGMA foreign_keys = ON'); client.exec('PRAGMA foreign_keys = ON');
@@ -67,6 +93,9 @@ async function harness(t, namespace) {
authority, authority,
fixture.registry, fixture.registry,
), ),
automation: new LocalSqlitePluginPackageAutomationPublicationRepository(
authority,
),
reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository( reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository(
authority, authority,
fixture.registry, fixture.registry,
@@ -108,6 +137,13 @@ function quarantineEvent(fixture, record = fixture.install.active) {
async function publishActivePackage(value) { async function publishActivePackage(value) {
await activateInstall(value.install, value.fixture); await activateInstall(value.install, value.fixture);
await value.materialized.publish(value.fixture.revision); await value.materialized.publish(value.fixture.revision);
await value.automation.publish(
createInitialPluginPackageAutomationPublication(
value.fixture.revision,
value.fixture.registry,
value.fixture.install.active.updatedAtMs,
),
);
await value.reconciliation.reconcile(value.fixture.revision, { await value.reconciliation.reconcile(value.fixture.revision, {
async findActiveResourceGeneration() { async findActiveResourceGeneration() {
return value.fixture.revision.generation; return value.fixture.revision.generation;
@@ -199,6 +235,13 @@ test('withdraws active Package Tasks and Tool source in one exact replayable tra
.snapshotDigest, .snapshotDigest,
created.receipt.capability.currentToolSnapshotDigest, created.receipt.capability.currentToolSnapshotDigest,
); );
const automation = await value.automation.findCurrent(
value.fixture.projectId,
value.fixture.packageName,
);
assert.equal(automation.state, 'withdrawn');
assert.equal(automation.lifecycleEventDigest, event.eventDigest);
assert.equal(automation.version, 2);
const replay = await value.quarantine.quarantine(event, () => { const replay = await value.quarantine.quarantine(event, () => {
authorizationChecks += 1; authorizationChecks += 1;
@@ -264,6 +307,51 @@ test('rolls back every withdrawal fact when the target install advanced', async
.get(value.fixture.projectId).count, .get(value.fixture.projectId).count,
0, 0,
); );
assert.equal(
(
await value.automation.findCurrent(
value.fixture.projectId,
value.fixture.packageName,
)
).state,
'active',
);
});
test('fails closed when quarantine automation withdrawal evidence is rewound', async (t) => {
const value = await harness(t, 'sqlite-quarantine-automation-corrupt');
await publishActivePackage(value);
const event = quarantineEvent(value.fixture);
await value.quarantine.quarantine(event, () => {});
const active = value.client
.prepare(
`SELECT publication_digest AS "publicationDigest"
FROM "QingLong3PluginPackageAutomationPublications"
WHERE project_id = ? AND package_name = ? AND state = 'active'`,
)
.get(value.fixture.projectId, value.fixture.packageName);
value.client.exec('PRAGMA foreign_keys = OFF');
value.client
.prepare(
`UPDATE "QingLong3PluginPackageAutomationPublicationHeads"
SET publication_digest = ?, state = 'active', version = 1
WHERE project_id = ? AND package_name = ?`,
)
.run(
active.publicationDigest,
value.fixture.projectId,
value.fixture.packageName,
);
value.client
.prepare(
`DELETE FROM "QingLong3PluginPackageAutomationPublications"
WHERE project_id = ? AND package_name = ? AND state = 'withdrawn'`,
)
.run(value.fixture.projectId, value.fixture.packageName);
await assert.rejects(
value.quarantine.findByEventDigest(event.eventDigest),
PluginPackageQuarantineUnavailableError,
);
}); });
test('rolls back withdrawal when the in-transaction Owner fence changes before commit', async (t) => { test('rolls back withdrawal when the in-transaction Owner fence changes before commit', async (t) => {
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
}, },
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 }, { runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
); );
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 44); assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
}); });
test('runs an optional authorization guard inside new and replay transactions', async (t) => { test('runs an optional authorization guard inside new and replay transactions', async (t) => {
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
}, },
{ status: 'running', version: 5, eventSequence: 5 }, { status: 'running', version: 5, eventSequence: 5 },
); );
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 44); assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
}); });
test('fails closed before writing when the exact installation is not active', async (t) => { test('fails closed before writing when the exact installation is not active', async (t) => {
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
stepAttemptCount: 0, stepAttemptCount: 0,
}, },
); );
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 44); assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
}); });
test('bounds candidate paging before SQL and fences cancellation', async (t) => { test('bounds candidate paging before SQL and fences cancellation', async (t) => {
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
await migrateLocalSqlitePath(state); await migrateLocalSqlitePath(state);
const prepared = await createLocalSqliteRolloutBackup(state); const prepared = await createLocalSqliteRolloutBackup(state);
assert.equal(prepared.status, 'prepared'); assert.equal(prepared.status, 'prepared');
assert.equal(prepared.contractVersion, 44); assert.equal(prepared.contractVersion, 45);
assert.equal(prepared.writeContractVersion, 44); assert.equal(prepared.writeContractVersion, 45);
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 44); assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 45);
assert.match(prepared.sha256, /^[0-9a-f]{64}$/); assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
assert.equal(prepared.bytes > 0, true); assert.equal(prepared.bytes > 0, true);
assert.equal(prepared.pageCount > 0, true); assert.equal(prepared.pageCount > 0, true);
+84 -10
View File
@@ -7705,6 +7705,17 @@ async function runPluginPackageQuarantineMatrix(options) {
), ),
false, false,
); );
const withdrawnAutomation = await automationRepository.findCurrent(
fixture.projectId,
fixture.packageName,
);
assert.ok(withdrawnAutomation);
assert.equal(withdrawnAutomation.state, 'withdrawn');
assert.equal(withdrawnAutomation.lifecycleEventDigest, event.eventDigest);
assert.equal(
withdrawnAutomation.previousPublicationDigest,
lifecycle.automationPublication.publicationDigest,
);
const currentSnapshot = await snapshotRepository.findCurrent( const currentSnapshot = await snapshotRepository.findCurrent(
fixture.projectId, fixture.projectId,
); );
@@ -7740,6 +7751,20 @@ async function runPluginPackageQuarantineMatrix(options) {
FROM "ql3"."project_tool_definition_snapshot_sources" FROM "ql3"."project_tool_definition_snapshot_sources"
WHERE project_id = $2 AND active_vector_digest = $3) WHERE project_id = $2 AND active_vector_digest = $3)
AS "retainedSources", AS "retainedSources",
(SELECT count(*)::integer
FROM "ql3"."plugin_package_automation_disposition_events"
WHERE event_digest = $1 AND event_kind = 'quarantine')
AS "automationDispositionEvents",
(SELECT count(*)::integer
FROM "ql3"."plugin_package_automation_publication_heads" AS head
JOIN "ql3"."plugin_package_automation_publications" AS publication
ON publication.project_id = head.project_id
AND publication.package_name = head.package_name
AND publication.publication_digest = head.publication_digest
WHERE head.project_id = $2
AND publication.lifecycle_event_digest = $1
AND publication.state = 'withdrawn')
AS "withdrawnAutomationHeads",
(SELECT count(*)::integer (SELECT count(*)::integer
FROM "ql3"."plugin_package_workflow_admissions" FROM "ql3"."plugin_package_workflow_admissions"
WHERE plan_digest = $4) AS "workflowAdmissions", WHERE plan_digest = $4) AS "workflowAdmissions",
@@ -7791,6 +7816,8 @@ async function runPluginPackageQuarantineMatrix(options) {
row?.taskCount === 2 && row?.taskCount === 2 &&
row?.disabledTasks === 2 && row?.disabledTasks === 2 &&
row?.retainedSources === 0 && row?.retainedSources === 0 &&
row?.automationDispositionEvents === 1 &&
row?.withdrawnAutomationHeads === 1 &&
row?.workflowAdmissions === 1 && row?.workflowAdmissions === 1 &&
row?.workflowEvents === 5 && row?.workflowEvents === 5 &&
row?.workflowStepMutations === 3 && row?.workflowStepMutations === 3 &&
@@ -7857,6 +7884,7 @@ async function runPluginPackageQuarantineMatrix(options) {
automationStartAllowedBeforeRevocationReceipt: true, automationStartAllowedBeforeRevocationReceipt: true,
automationStartAllowedAfterRevocationReceipt: false, automationStartAllowedAfterRevocationReceipt: false,
automationStartAllowedAfterQuarantine: false, automationStartAllowedAfterQuarantine: false,
automationPublicationWithdrawnAtomically: true,
taskWithdrawals: taskWithdrawals:
responseLossResult.receipt.capability.taskWithdrawals.length, responseLossResult.receipt.capability.taskWithdrawals.length,
retainedSources: retainedSources:
@@ -7868,6 +7896,7 @@ async function runPluginPackageQuarantineMatrix(options) {
automationRecoverySourceConverged: true, automationRecoverySourceConverged: true,
automationStartGuardRuntimeOnly: false, automationStartGuardRuntimeOnly: false,
automationStartFenceSurvivedPromotion: false, automationStartFenceSurvivedPromotion: false,
automationPublicationSurvivedPromotion: false,
workflowAdmissionCreatedAtomically: true, workflowAdmissionCreatedAtomically: true,
workflowAuthorizedAdmissionAtomic: true, workflowAuthorizedAdmissionAtomic: true,
workflowAdmissionExactReplay: true, workflowAdmissionExactReplay: true,
@@ -8312,6 +8341,20 @@ async function verifyPluginPackageQuarantineAfterPromotion(options) {
), ),
false, false,
); );
const withdrawnAutomation =
await new PostgresPluginPackageAutomationPublicationRepository(
packageDatabase.pool,
).findCurrent(quarantine.report.projectId, quarantine.report.packageName);
assert.ok(withdrawnAutomation);
assert.equal(withdrawnAutomation.state, 'withdrawn');
assert.equal(
withdrawnAutomation.lifecycleEventDigest,
quarantine.event.eventDigest,
);
assert.equal(
withdrawnAutomation.previousPublicationDigest,
quarantine.lifecycle.automationPublication.publicationDigest,
);
const workflowAdmissions = const workflowAdmissions =
new PostgresAuthorizedPluginPackageWorkflowAdmissionRepository( new PostgresAuthorizedPluginPackageWorkflowAdmissionRepository(
runtimeDatabase.pool, runtimeDatabase.pool,
@@ -8568,6 +8611,7 @@ async function verifyPluginPackageQuarantineAfterPromotion(options) {
assert.equal(executorWorkflowTaskAttemptDenied, true); assert.equal(executorWorkflowTaskAttemptDenied, true);
quarantine.report.automationStartGuardRuntimeOnly = true; quarantine.report.automationStartGuardRuntimeOnly = true;
quarantine.report.automationStartFenceSurvivedPromotion = true; quarantine.report.automationStartFenceSurvivedPromotion = true;
quarantine.report.automationPublicationSurvivedPromotion = true;
quarantine.report.workflowAdmissionRuntimeOnly = true; quarantine.report.workflowAdmissionRuntimeOnly = true;
quarantine.report.workflowAdmissionSurvivedPromotion = true; quarantine.report.workflowAdmissionSurvivedPromotion = true;
quarantine.report.workflowAuthorizedAdmissionSurvivedPromotion = true; quarantine.report.workflowAuthorizedAdmissionSurvivedPromotion = true;
@@ -8592,6 +8636,20 @@ async function verifyPluginPackageQuarantineAfterPromotion(options) {
(SELECT count(*)::integer (SELECT count(*)::integer
FROM "ql3"."plugin_package_withdrawal_tasks" FROM "ql3"."plugin_package_withdrawal_tasks"
WHERE event_digest = $1) AS "taskCount", WHERE event_digest = $1) AS "taskCount",
(SELECT count(*)::integer
FROM "ql3"."plugin_package_automation_disposition_events"
WHERE event_digest = $1 AND event_kind = 'quarantine')
AS "automationDispositionEventCount",
(SELECT count(*)::integer
FROM "ql3"."plugin_package_automation_publication_heads" AS head
JOIN "ql3"."plugin_package_automation_publications" AS publication
ON publication.project_id = head.project_id
AND publication.package_name = head.package_name
AND publication.publication_digest = head.publication_digest
WHERE head.project_id = $7
AND publication.lifecycle_event_digest = $1
AND publication.state = 'withdrawn')
AS "withdrawnAutomationHeadCount",
(SELECT count(*)::integer (SELECT count(*)::integer
FROM "ql3"."plugin_package_publisher_revocation_receipts" FROM "ql3"."plugin_package_publisher_revocation_receipts"
WHERE receipt_digest = $2) AS "revocationReceiptCount", WHERE receipt_digest = $2) AS "revocationReceiptCount",
@@ -8637,6 +8695,7 @@ async function verifyPluginPackageQuarantineAfterPromotion(options) {
quarantine.report.proposalDigest, quarantine.report.proposalDigest,
quarantine.workflowAdmission.plan.planDigest, quarantine.workflowAdmission.plan.planDigest,
quarantine.workflowAdmission.plan.runId, quarantine.workflowAdmission.plan.runId,
quarantine.report.projectId,
], ],
); );
assert.deepEqual(facts.rows, [ assert.deepEqual(facts.rows, [
@@ -8644,6 +8703,8 @@ async function verifyPluginPackageQuarantineAfterPromotion(options) {
eventCount: 1, eventCount: 1,
receiptCount: 1, receiptCount: 1,
taskCount: 2, taskCount: 2,
automationDispositionEventCount: 1,
withdrawnAutomationHeadCount: 1,
revocationReceiptCount: 1, revocationReceiptCount: 1,
impactCount: 1, impactCount: 1,
impactItemCount: 1, impactItemCount: 1,
@@ -8696,9 +8757,8 @@ async function verifyPluginPackageLifecycleAfterPromotion(options) {
lifecycle.enableReceipt.lifecycle, lifecycle.enableReceipt.lifecycle,
); );
assert.deepEqual( assert.deepEqual(
await automations.findCurrent( await automations.findByDigest(
lifecycle.report.projectId, lifecycle.automationPublication.publicationDigest,
lifecycle.report.packageName,
), ),
lifecycle.automationPublication, lifecycle.automationPublication,
); );
@@ -8716,6 +8776,10 @@ async function verifyPluginPackageLifecycleAfterPromotion(options) {
(SELECT count(*)::integer (SELECT count(*)::integer
FROM "ql3"."plugin_package_lifecycle_tasks" FROM "ql3"."plugin_package_lifecycle_tasks"
WHERE event_digest IN ($1, $2)) AS "taskCount", WHERE event_digest IN ($1, $2)) AS "taskCount",
(SELECT count(*)::integer
FROM "ql3"."plugin_package_automation_publications"
WHERE publication_digest = $5)
AS "lifecycleAutomationPublicationCount",
(SELECT count(*)::integer (SELECT count(*)::integer
FROM "ql3"."plugin_package_automation_publications" FROM "ql3"."plugin_package_automation_publications"
WHERE project_id = $3 AND package_name = $4) WHERE project_id = $3 AND package_name = $4)
@@ -8737,6 +8801,7 @@ async function verifyPluginPackageLifecycleAfterPromotion(options) {
lifecycle.enableEvent.eventDigest, lifecycle.enableEvent.eventDigest,
lifecycle.report.projectId, lifecycle.report.projectId,
lifecycle.report.packageName, lifecycle.report.packageName,
lifecycle.automationPublication.publicationDigest,
], ],
); );
assert.deepEqual(facts.rows, [ assert.deepEqual(facts.rows, [
@@ -8744,9 +8809,10 @@ async function verifyPluginPackageLifecycleAfterPromotion(options) {
eventCount: 2, eventCount: 2,
receiptCount: 2, receiptCount: 2,
taskCount: 4, taskCount: 4,
automationPublicationCount: 5, lifecycleAutomationPublicationCount: 1,
automationState: 'active', automationPublicationCount: 6,
automationVersion: 5, automationState: 'withdrawn',
automationVersion: 6,
version: lifecycle.report.lifecycleVersion, version: lifecycle.report.lifecycleVersion,
disposition: 'active', disposition: 'active',
}, },
@@ -11356,10 +11422,13 @@ async function main(argv = process.argv.slice(2)) {
.keyId, .keyId,
), ),
}; };
assert.deepEqual( assert.deepEqual(promotedPromptExecution, {
promotedPromptExecution, ...pluginPackageQuarantine.promptExecution.beforePromotion,
pluginPackageQuarantine.promptExecution.beforePromotion, catalog: {
); ...pluginPackageQuarantine.promptExecution.beforePromotion.catalog,
publicationState: 'withdrawn',
},
});
pluginPackageQuarantine.promptExecution.afterPromotion = pluginPackageQuarantine.promptExecution.afterPromotion =
promotedPromptExecution; promotedPromptExecution;
pluginPackageQuarantine.promptExecution.survivedPromotion = true; pluginPackageQuarantine.promptExecution.survivedPromotion = true;
@@ -12391,6 +12460,11 @@ async function main(argv = process.argv.slice(2)) {
pluginPackageLifecycle.report.automationPublicationSurvivedPromotion, pluginPackageLifecycle.report.automationPublicationSurvivedPromotion,
pluginPackageAutomationRecoverySourceConverges: pluginPackageAutomationRecoverySourceConverges:
pluginPackageQuarantine.report.automationRecoverySourceConverged, pluginPackageQuarantine.report.automationRecoverySourceConverged,
pluginPackageQuarantineWithdrawsAutomationAtomically:
pluginPackageQuarantine.report
.automationPublicationWithdrawnAtomically,
pluginPackageQuarantineAutomationWithdrawalSurvivesPromotion:
pluginPackageQuarantine.report.automationPublicationSurvivedPromotion,
pluginPackagePublisherRevocationImmediatelyFencesAutomation: pluginPackagePublisherRevocationImmediatelyFencesAutomation:
pluginPackageQuarantine.report pluginPackageQuarantine.report
.automationStartAllowedBeforeRevocationReceipt && .automationStartAllowedBeforeRevocationReceipt &&
+6 -6
View File
@@ -74,12 +74,12 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
[ [
{ {
directory: 'packages/ql3-cluster-postgres/src/migrations', directory: 'packages/ql3-cluster-postgres/src/migrations',
directSourceFiles: 58, directSourceFiles: 59,
reviewKind: 'ordered_ledger', reviewKind: 'ordered_ledger',
}, },
{ {
directory: 'packages/ql3-local-sqlite/src/migrations', directory: 'packages/ql3-local-sqlite/src/migrations',
directSourceFiles: 89, directSourceFiles: 91,
reviewKind: 'ordered_ledger', reviewKind: 'ordered_ledger',
}, },
], ],
@@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, rootSourceFileRoles: clusterPostgres.rootSourceFileRoles,
}, },
{ {
sourceFiles: 153, sourceFiles: 154,
rootSourceFiles: 1, rootSourceFiles: 1,
rootSourceLines: 125, rootSourceLines: 125,
nestedSourceFiles: 152, nestedSourceFiles: 153,
rootSourceFileRoles: { 'index.ts': 'public_export' }, rootSourceFileRoles: { 'index.ts': 'public_export' },
}, },
); );
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localSqlite.rootSourceFileRoles, rootSourceFileRoles: localSqlite.rootSourceFileRoles,
}, },
{ {
sourceFiles: 179, sourceFiles: 181,
rootSourceFiles: 1, rootSourceFiles: 1,
rootSourceLines: 31, rootSourceLines: 31,
nestedSourceFiles: 178, nestedSourceFiles: 180,
rootSourceFileRoles: { 'index.ts': 'public_export' }, rootSourceFileRoles: { 'index.ts': 'public_export' },
}, },
); );