@zq-silk/yui 2.8.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commandCatalog.js +56 -21
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli/interactionPolicy.js +2 -2
  5. package/dist/commands/durableJobCommands.js +50 -0
  6. package/dist/commands/globalRoleCommands.js +30 -9
  7. package/dist/commands/inboxCommands.js +79 -22
  8. package/dist/commands/projectCommands.js +4 -33
  9. package/dist/commands/scheduleCommands.js +45 -0
  10. package/dist/commands/taskActivationCommands.js +19 -5
  11. package/dist/commands/taskCommands.js +8 -2
  12. package/dist/commands/taskFactCommands.js +24 -12
  13. package/dist/commands/taskProjectDeliveryCommand.js +56 -0
  14. package/dist/commands/taskPublicationCommands.js +6 -3
  15. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  16. package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
  17. package/dist/context/runContextPack.js +25 -4
  18. package/dist/context/runInputContract.js +1 -0
  19. package/dist/context/taskContext.js +53 -16
  20. package/dist/context/taskPhase.js +99 -0
  21. package/dist/controlPlaneCli.js +38 -17
  22. package/dist/controller/controller.js +35 -0
  23. package/dist/controller/fileSchedulerStoreAdapter.js +75 -16
  24. package/dist/controller/globalInputDelivery.js +17 -7
  25. package/dist/controller/inboxTurnCheck.js +64 -0
  26. package/dist/controller/jobControl.js +42 -12
  27. package/dist/controller/jobDefinitionControl.js +304 -0
  28. package/dist/controller/jobSupervisor.js +47 -32
  29. package/dist/controller/providerRetryAdmission.js +2 -0
  30. package/dist/controller/runtime.js +11 -2
  31. package/dist/controller/scheduleControl.js +176 -0
  32. package/dist/coordination/workMailbox.js +3 -3
  33. package/dist/external/githubPublicationVerifier.js +14 -5
  34. package/dist/external/gitlabPublicationVerifier.js +12 -29
  35. package/dist/grant/taskAuthorization.js +7 -2
  36. package/dist/interaction/notificationPresentation.js +12 -3
  37. package/dist/interaction/operatorPresentation.js +5 -0
  38. package/dist/job/builtinJob.js +58 -0
  39. package/dist/job/durableJob.js +72 -49
  40. package/dist/job/jobDefinition.js +68 -0
  41. package/dist/job/jobRunner.js +2 -1
  42. package/dist/job/projectRefreshRunner.js +48 -0
  43. package/dist/message/globalInboxRecovery.js +44 -0
  44. package/dist/message/globalInterrupt.js +1 -1
  45. package/dist/message/inboxReceipt.js +14 -6
  46. package/dist/message/inboxTurn.js +86 -0
  47. package/dist/message/message.js +11 -0
  48. package/dist/message/taskInboxOwnership.js +20 -6
  49. package/dist/repository/projectRefresh.js +58 -0
  50. package/dist/repository/taskWorkspacePreparer.js +2 -1
  51. package/dist/repository/workspaceCleanupInspection.js +6 -1
  52. package/dist/schedule/schedule.js +108 -0
  53. package/dist/scheduler/operatorEvent.js +2 -0
  54. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  55. package/dist/storage/contextRecords.js +8 -0
  56. package/dist/storage/jobSchema.js +15 -0
  57. package/dist/storage/maintenanceCaller.js +1 -1
  58. package/dist/storage/recordValidation.js +5 -0
  59. package/dist/storage/scheduleSchema.js +12 -0
  60. package/dist/storage/sqliteSchema.js +95 -24
  61. package/dist/storage/sqliteStore.js +123 -10
  62. package/dist/storage/storageVersions.js +1 -1
  63. package/dist/storage/storeRpc.js +5 -0
  64. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  65. package/dist/task/archiveDiagnostics.js +1 -1
  66. package/dist/task/archivePreflight.js +1 -1
  67. package/dist/task/projectDeliveryScope.js +20 -0
  68. package/dist/task/publicationToolObservation.js +126 -0
  69. package/dist/task/remoteDelivery.js +19 -8
  70. package/dist/task/remoteDeliveryService.js +5 -4
  71. package/dist/task/taskActivationService.js +35 -0
  72. package/dist/web/assets/client/app/settings.js +7 -1
  73. package/dist/web/assets/client/i18n/en.js +65 -2
  74. package/dist/web/assets/client/i18n/zh-CN.js +65 -2
  75. package/dist/web/assets/client/index.js +2 -0
  76. package/dist/web/assets/client/views/jobs.js +222 -0
  77. package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
  78. package/dist/web/assets/client/views/task/page.js +11 -1
  79. package/dist/web/assets/shared/manageNav.js +1 -0
  80. package/dist/web/assets/shared/taskTabs.js +2 -2
  81. package/dist/web/assets/shell/settingsContent.js +3 -0
  82. package/dist/web/assets/styles/index.js +2 -0
  83. package/dist/web/assets/styles/views/jobs.js +19 -0
  84. package/dist/web/controllerWeb.js +73 -21
  85. package/dist/web/taskPreviews.js +21 -13
  86. package/dist/web/webAccess.js +109 -0
  87. package/dist/web/webJobs.js +52 -0
  88. package/dist/web/webServer.js +76 -44
  89. package/docs/architecture/capabilities-and-resources.md +6 -4
  90. package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
  91. package/docs/cli-information-contract.md +57 -4
  92. package/docs/cli-information-contract.zh-CN.md +41 -2
  93. package/docs/job-runs.md +224 -0
  94. package/docs/project-delivery-scope.md +59 -0
  95. package/docs/release-workflow.md +86 -0
  96. package/docs/release-workflow.zh-CN.md +68 -0
  97. package/docs/schedules.md +132 -0
  98. package/docs/storage-baseline.md +31 -1
  99. package/docs/storage-baseline.zh-CN.md +25 -1
  100. package/docs/task-delivery.md +74 -2
  101. package/docs/task-delivery.zh-CN.md +39 -1
  102. package/docs/testing/verification-levels.md +6 -2
  103. package/docs/testing/verification-levels.zh-CN.md +6 -3
  104. package/i18n/README.zh-CN.md +6 -3
  105. package/package.json +4 -3
  106. package/skills/yui-leader/SKILL.md +44 -1
  107. package/skills/yui-leader/VERSION +1 -1
  108. package/skills/yui-leader/references/planning.md +9 -1
  109. package/skills/yui-operator/SKILL.md +31 -0
  110. package/skills/yui-operator/VERSION +1 -1
  111. package/skills/yui-reviewer/SKILL.md +4 -0
  112. package/skills/yui-reviewer/VERSION +1 -1
  113. package/skills/yui-runtime/SKILL.md +67 -8
  114. package/skills/yui-runtime/VERSION +1 -1
  115. package/skills/yui-runtime/references/context-ordering.md +103 -0
  116. package/skills/yui-runtime/references/publication.md +44 -4
  117. package/skills/yui-runtime/references/recovery.md +21 -0
  118. package/skills/yui-worker/SKILL.md +5 -0
  119. package/skills/yui-worker/VERSION +1 -1
@@ -40,6 +40,7 @@ import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
40
40
  import { validateTaskBrief } from "../brief/taskBrief.js";
41
41
  import { validateContextSnapshot } from "../context/contextSnapshot.js";
42
42
  import { consumePendingBatch, mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
43
+ import { createTaskEvent } from "../event/taskEvent.js";
43
44
  import { enqueueDurableOperatorFact } from "../scheduler/operatorEvent.js";
44
45
  import { validateDurableJob, validDurableJobTransition } from "../job/durableJob.js";
45
46
  import { validateGlobalRoleMessage } from "../message/message.js";
@@ -60,6 +61,8 @@ import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
60
61
  import { operationalTaskRecords, TASK_RECORD_RETIRED_EVENT } from "../task/taskRecordRetirement.js";
61
62
  import { writeTelemetryBatch } from "../telemetry/sqliteTelemetryBatch.js";
62
63
  import { STORED_RECORD_TABLES, validateStoredRecord } from "./recordValidation.js";
64
+ import { jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
65
+ import { validateSchedule } from "../schedule/schedule.js";
63
66
  import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
64
67
  import { validateWorkItem } from "../workItem/workItem.js";
65
68
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
@@ -986,10 +989,22 @@ export class SqliteTaskStore {
986
989
  const params = { name: roleName };
987
990
  if (query.handling)
988
991
  where.push(`json_extract(payload, '$.inbox.claim') IS NOT NULL
989
- AND json_extract(payload, '$.inbox.ack') IS NULL`);
992
+ AND json_extract(payload, '$.inbox.ack') IS NULL
993
+ AND json_extract(payload, '$.inbox.retirement') IS NULL`);
994
+ if (query.unclaimed)
995
+ where.push("json_extract(payload, '$.inbox') IS NULL");
996
+ if (query.handlingExceptSession !== undefined) {
997
+ if (!query.handling)
998
+ throw new Error("A handling Session filter requires a handling query.");
999
+ where.push(`(json_extract(payload, '$.inbox.claim.actor.agentId') != @handlingAgent
1000
+ OR json_extract(payload, '$.inbox.claim.actor.nativeSessionId') != @handlingSession)`);
1001
+ params.handlingAgent = query.handlingExceptSession.agentId;
1002
+ params.handlingSession = query.handlingExceptSession.nativeSessionId;
1003
+ }
990
1004
  if (query.pending)
991
1005
  where.push(`json_extract(payload, '$.delivery') IS NULL
992
1006
  AND json_extract(payload, '$.inbox.ack') IS NULL
1007
+ AND json_extract(payload, '$.inbox.retirement') IS NULL
993
1008
  AND json_extract(payload, '$.notDelivered') IS NULL
994
1009
  AND (json_extract(payload, '$.inputControl.action') = 'queue'
995
1010
  OR json_extract(payload, '$.interruptThen') IS NOT NULL)`);
@@ -1056,6 +1071,25 @@ export class SqliteTaskStore {
1056
1071
  throw new StorageRecordError("Task id is required.");
1057
1072
  }
1058
1073
  this.#mutate(() => {
1074
+ const schedules = this.listSchedules({ kind: "task", taskId: task.id });
1075
+ if (task.status === "completed" && schedules.some(s => s.rule.kind === "once"
1076
+ && (s.state === "enabled" || s.state === "needs-attention"))) {
1077
+ throw new StorageRecordError("Task has unfulfilled once Schedules; inspect and explicitly disable them before completion.");
1078
+ }
1079
+ if (["completed", "cancelled", "archived"].includes(task.status)) {
1080
+ for (const schedule of schedules.filter(s => s.state === "enabled" || s.state === "needs-attention")) {
1081
+ this.saveSchedule({ ...schedule, state: "retired", revision: schedule.revision + 1,
1082
+ reason: `Task ${task.status}; no future admission. Pending intent and Run history retained.`,
1083
+ updatedAt: task.updatedAt });
1084
+ }
1085
+ }
1086
+ else if (task.executionGate.state === "stopped") {
1087
+ for (const schedule of schedules.filter(s => s.state === "enabled")) {
1088
+ this.saveSchedule({ ...schedule, state: "needs-attention", revision: schedule.revision + 1,
1089
+ reason: "Task execution stopped; inspect and explicitly enable after execution resumes.",
1090
+ updatedAt: task.updatedAt });
1091
+ }
1092
+ }
1059
1093
  for (const binding of task.projectBindings) {
1060
1094
  const found = this.#db.prepare("SELECT 1 FROM projects WHERE id = ?").get(binding.projectId);
1061
1095
  if (found === undefined)
@@ -1234,6 +1268,71 @@ export class SqliteTaskStore {
1234
1268
  return this.#getPayload("integration_attempts", "task_id = ? AND integration_id = ?", [taskId, integrationId]);
1235
1269
  }
1236
1270
  // -- durable jobs -----------------------------------------------------------
1271
+ saveSchedule(schedule) {
1272
+ validateSchedule(schedule);
1273
+ this.#mutate(() => {
1274
+ this.#db.prepare(`INSERT INTO schedules VALUES(?,?,?,?,?)
1275
+ ON CONFLICT(scope_key,schedule_id) DO UPDATE SET payload=excluded.payload,state=excluded.state,next_due=excluded.next_due`)
1276
+ .run(jobScopeKey(schedule.scope), schedule.id, this.#recordJson("schedules", schedule), schedule.state, schedule.nextDue);
1277
+ });
1278
+ }
1279
+ getSchedule(scope, id) {
1280
+ return this.#getPayload("schedules", "scope_key=? AND schedule_id=?", [jobScopeKey(scope), id]);
1281
+ }
1282
+ listSchedules(scope) {
1283
+ return this.#listPayload("schedules", "scope_key=?", [jobScopeKey(scope)]);
1284
+ }
1285
+ listDueSchedules(now, limit) {
1286
+ const rows = this.#db.prepare(`SELECT payload FROM schedules
1287
+ WHERE state='enabled' AND next_due<=?
1288
+ ORDER BY COALESCE(json_extract(payload,'$.lastAttempt.at'),''),next_due,scope_key,schedule_id LIMIT ?`).all(now, limit);
1289
+ return rows.map(row => validateSchedule(JSON.parse(row.payload)));
1290
+ }
1291
+ saveJobDefinition(job) {
1292
+ validateJobDefinition(job);
1293
+ if (job.scope.kind === "task")
1294
+ this.#requireTask(job.scope.taskId);
1295
+ const previous = this.getJobDefinition(job.scope, job.id);
1296
+ if (previous !== null && (job.revision !== previous.revision + 1
1297
+ || job.createdAt !== previous.createdAt || Date.parse(job.updatedAt) < Date.parse(previous.updatedAt))) {
1298
+ throw new StorageRecordError("Job definition revision changed; reload before editing.");
1299
+ }
1300
+ this.#mutate(() => {
1301
+ this.#db.prepare(`INSERT INTO job_definitions VALUES(?,?,?)
1302
+ ON CONFLICT(scope_key,definition_id) DO UPDATE SET payload=excluded.payload`)
1303
+ .run(jobScopeKey(job.scope), job.id, this.#recordJson("job_definitions", job));
1304
+ });
1305
+ }
1306
+ getJobDefinition(scope, id) {
1307
+ return this.#getPayload("job_definitions", "scope_key=? AND definition_id=?", [jobScopeKey(scope), id]);
1308
+ }
1309
+ listJobDefinitions(scope) {
1310
+ return this.#listPayload("job_definitions", "scope_key=?", [jobScopeKey(scope)]);
1311
+ }
1312
+ saveGlobalJobRun(run) {
1313
+ validateDurableJob(run);
1314
+ if ("taskId" in run || run.scope !== "global")
1315
+ throw new StorageRecordError("Global Run cannot have a Task.");
1316
+ const previous = this.getGlobalJobRun(run.id);
1317
+ if (previous !== null && (!validDurableJobTransition(previous, run)
1318
+ || Date.parse(run.updatedAt) < Date.parse(previous.updatedAt))) {
1319
+ throw new StorageRecordError("Invalid global JobRun transition.");
1320
+ }
1321
+ this.#mutate(() => {
1322
+ this.#db.prepare(`INSERT INTO global_job_runs VALUES(?,?,?)
1323
+ ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload,status=excluded.status`)
1324
+ .run(run.id, this.#recordJson("global_job_runs", run), run.status);
1325
+ });
1326
+ }
1327
+ getGlobalJobRun(id) {
1328
+ return this.#getPayload("global_job_runs", "run_id=?", [id]);
1329
+ }
1330
+ listGlobalJobRuns() {
1331
+ return this.#listPayload("global_job_runs", "1=1", []);
1332
+ }
1333
+ listActiveGlobalJobRuns() {
1334
+ return this.#listPayload("global_job_runs", "status IN ('queued','running')", []);
1335
+ }
1237
1336
  nextDurableJobId(taskId) {
1238
1337
  return this.#nextTaskRecordId(taskId, "durableJob");
1239
1338
  }
@@ -1243,21 +1342,35 @@ export class SqliteTaskStore {
1243
1342
  }
1244
1343
  validateDurableJob(job);
1245
1344
  this.#requireTask(taskId);
1246
- const existing = this.getDurableJob(taskId, job.id);
1247
- if (existing !== null) {
1248
- if (Date.parse(job.updatedAt) < Date.parse(existing.updatedAt)) {
1249
- throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${job.id}`);
1250
- }
1251
- if (!validDurableJobTransition(existing, job)) {
1252
- throw new StorageRecordError(`DurableJob transition is invalid: ${job.id}`);
1253
- }
1254
- }
1255
1345
  this.#mutate(() => {
1346
+ const existing = this.getDurableJob(taskId, job.id);
1347
+ if (existing !== null) {
1348
+ if (Date.parse(job.updatedAt) < Date.parse(existing.updatedAt)) {
1349
+ throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${job.id}`);
1350
+ }
1351
+ if (!validDurableJobTransition(existing, job)) {
1352
+ throw new StorageRecordError(`DurableJob transition is invalid: ${job.id}`);
1353
+ }
1354
+ }
1256
1355
  this.#db.prepare(`INSERT INTO durable_jobs (job_id, task_id, idempotency_key, status, payload, created_at, updated_at)
1257
1356
  VALUES (?, ?, ?, ?, ?, ?, ?)
1258
1357
  ON CONFLICT(task_id, job_id) DO UPDATE SET idempotency_key = excluded.idempotency_key,
1259
1358
  status = excluded.status,
1260
1359
  payload = excluded.payload, updated_at = excluded.updated_at`).run(job.id, taskId, job.idempotencyKey ?? null, job.status, this.#recordJson("durable_jobs", job), job.createdAt, job.updatedAt);
1360
+ // All Task Job writers share this boundary (including cancellation and
1361
+ // unknown-result acknowledgement). Publish semantic changes atomically;
1362
+ // heartbeat/checkpoint/notification bookkeeping is deliberately excluded.
1363
+ if (existing === null || existing.status !== job.status
1364
+ || !isDeepStrictEqual(existing.result, job.result)
1365
+ || existing.cancelRequestedAt !== job.cancelRequestedAt
1366
+ || existing.acknowledgedAt !== job.acknowledgedAt) {
1367
+ this.saveEvent(taskId, createTaskEvent(this.nextEventId(taskId), taskId, "job.state-changed", {
1368
+ jobId: job.id, status: job.status,
1369
+ ...(existing === null ? {} : { previousStatus: existing.status }),
1370
+ ...(job.cancelRequestedAt === undefined ? {} : { cancelRequestedAt: job.cancelRequestedAt }),
1371
+ ...(job.acknowledgedAt === undefined ? {} : { acknowledgedAt: job.acknowledgedAt })
1372
+ }, new Date(job.updatedAt)));
1373
+ }
1261
1374
  });
1262
1375
  }
1263
1376
  listDurableJobs(taskId) {
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.8";
1
+ export const CURRENT_STORAGE_VERSION = "1.13";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -70,6 +70,11 @@ const READ_ONLY_STORE_METHODS = new Set([
70
70
  "listIntegrationAttempts",
71
71
  "getIntegrationAttempt",
72
72
  "listActiveDurableJobs",
73
+ "getJobDefinition",
74
+ "listJobDefinitions",
75
+ "getGlobalJobRun",
76
+ "listGlobalJobRuns",
77
+ "listActiveGlobalJobRuns",
73
78
  "listRoles",
74
79
  "getRole",
75
80
  "listManagedWorkspaces",
@@ -0,0 +1,22 @@
1
+ import { validateDurableJob } from "../../job/durableJob.js";
2
+ import { definitionFromTaskRun } from "../../job/jobDefinition.js";
3
+ export function migrateJobDefinitions(db) {
4
+ const rows = db.prepare("SELECT task_id, job_id, payload FROM durable_jobs").all();
5
+ for (const row of rows) {
6
+ const original = JSON.parse(row.payload);
7
+ if (original.taskId !== row.task_id || original.id !== row.job_id
8
+ || original.definitionId !== undefined || original.definitionRevision !== undefined || original.trigger !== undefined) {
9
+ throw new Error(`Invalid 1.8 Job identity: ${row.task_id}/${row.job_id}.`);
10
+ }
11
+ const run = validateDurableJob({
12
+ ...original, definitionId: original.id, definitionRevision: 1,
13
+ trigger: { source: "migration", key: original.operation.requestId }
14
+ });
15
+ db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
16
+ .run(9, "durable_jobs", `${row.task_id}/${row.job_id}`, row.payload);
17
+ db.prepare("INSERT INTO job_definitions VALUES(?,?,?)")
18
+ .run(row.task_id, run.definitionId, JSON.stringify(definitionFromTaskRun(run)));
19
+ db.prepare("UPDATE durable_jobs SET payload=? WHERE task_id=? AND job_id=?")
20
+ .run(JSON.stringify(run), row.task_id, row.job_id);
21
+ }
22
+ }
@@ -76,7 +76,7 @@ export function archiveRetainedResources(store, task) {
76
76
  return resources;
77
77
  }
78
78
  export function archiveDeliveryWarnings(delivery) {
79
- return delivery.projects.filter(p => p.codeDelivery !== "none" && (!p.merged || !p.verified))
79
+ return delivery.projects.filter(p => p.deliveryRequired && (!p.merged || !p.verified))
80
80
  .map(p => ({ resource: `delivery:${delivery.taskId}/${p.projectId}`,
81
81
  detail: `${p.coverage}: ${p.reason} Merged=${p.merged}; verified=${p.verified}.` }));
82
82
  }
@@ -65,7 +65,7 @@ export async function inspectTaskArchive(coordinator, request) {
65
65
  throw new Error(`Task not found: ${request.taskId}.`);
66
66
  const settlement = archiveSettlementChecks(store, task);
67
67
  const delivery = await readArchiveDelivery(store, preparer.git, task, request.force);
68
- const deliveryChecks = delivery.projects.filter(p => p.codeDelivery !== "none" && (!p.merged || !p.verified))
68
+ const deliveryChecks = delivery.projects.filter(p => p.deliveryRequired && (!p.merged || !p.verified))
69
69
  .map(p => ({ resource: `delivery:${task.id}/${p.projectId}`, reason: "delivery-coverage", status: "blocked",
70
70
  detail: p.reason, expected: { merged: true, verified: true, localCommit: p.expectedLocalCommit },
71
71
  observed: { coverage: p.coverage, merged: p.merged, verified: p.verified, localCommit: p.publication?.localCommit ?? null },
@@ -0,0 +1,20 @@
1
+ /** An append-only Task decision, not a Project permission or a merge fact. */
2
+ export const PROJECT_DELIVERY_DECIDED_EVENT = "task.project-delivery-decided";
3
+ export function projectDeliveryDecision(events, taskId, projectId) {
4
+ return events.filter(event => event.taskId === taskId
5
+ && event.type === PROJECT_DELIVERY_DECIDED_EVENT && event.payload.projectId === projectId)
6
+ // Task event IDs are monotonic durable sequence numbers; wall clocks can move backwards.
7
+ .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })).at(-1) ?? null;
8
+ }
9
+ export function projectDeliveryRequirement(decision) {
10
+ if (decision === null)
11
+ return "required";
12
+ const p = decision.payload;
13
+ if (!["required", "excluded"].includes(p.requirement ?? "")
14
+ || !p.reason?.trim() || !p.sourceMessageId?.trim()
15
+ || !/^[a-f0-9]{64}$/u.test(p.sourceDigest ?? "") || !p.purpose?.trim()
16
+ || !["user", "operator", "leader"].includes(p.by ?? "")) {
17
+ throw new Error(`Project delivery decision ${decision.id} has incomplete evidence; preserve it for diagnosis.`);
18
+ }
19
+ return p.requirement;
20
+ }
@@ -0,0 +1,126 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ const REQUIRED = [
3
+ "taskId", "publicationId", "projectId", "localCommit", "provider", "repository",
4
+ "externalKind", "externalId", "externalUrl", "targetBranch", "state",
5
+ "headCommit", "observedAt", "tool", "command", "output"
6
+ ];
7
+ const OPTIONAL = ["remoteCommit", "mergedAt"];
8
+ const SHA = /^[0-9a-f]{40}$/u;
9
+ const MAX_AGE_MS = 24 * 60 * 60 * 1_000;
10
+ export function parsePublicationToolObservation(text) {
11
+ if (Buffer.byteLength(text, "utf8") > 64 * 1024) {
12
+ throw usageError("Tool observation must not exceed 64 KiB; supply a sanitized relevant output excerpt.");
13
+ }
14
+ let value;
15
+ try {
16
+ value = JSON.parse(text);
17
+ }
18
+ catch {
19
+ throw usageError("--observation requires a JSON object.");
20
+ }
21
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
22
+ throw usageError("--observation requires a JSON object.");
23
+ }
24
+ const record = value;
25
+ const allowed = new Set([...REQUIRED, ...OPTIONAL]);
26
+ for (const key of Object.keys(record)) {
27
+ if (!allowed.has(key))
28
+ throw usageError(`Unknown tool observation field: ${key}.`);
29
+ }
30
+ for (const key of [...REQUIRED, ...OPTIONAL]) {
31
+ if (OPTIONAL.includes(key) && record[key] === undefined)
32
+ continue;
33
+ if (typeof record[key] !== "string" || record[key].trim().length === 0) {
34
+ throw usageError(`Tool observation requires nonempty ${key}.`);
35
+ }
36
+ }
37
+ for (const key of ["localCommit", "headCommit", "remoteCommit"]) {
38
+ if (record[key] !== undefined && !SHA.test(record[key])) {
39
+ throw usageError(`Tool observation ${key} requires an exact lowercase Git SHA.`);
40
+ }
41
+ }
42
+ if (!((record.provider === "github" && record.externalKind === "pull-request")
43
+ || (record.provider === "gitlab" && record.externalKind === "merge-request"))) {
44
+ throw usageError("Tool observation requires a matching GitHub PR or GitLab MR kind.");
45
+ }
46
+ if (!["open", "merged", "closed"].includes(record.state)
47
+ || (record.state === "merged" && record.remoteCommit === undefined)) {
48
+ throw usageError("Tool observation requires a known state and an integrated commit for a merge.");
49
+ }
50
+ for (const key of ["observedAt", "mergedAt"]) {
51
+ if (record[key] !== undefined
52
+ && (!/^\d{4}-\d\d-\d\dT/u.test(record[key])
53
+ || !Number.isFinite(Date.parse(record[key])))) {
54
+ throw usageError(`Tool observation ${key} requires an ISO timestamp.`);
55
+ }
56
+ }
57
+ // Canonical key ordering makes retries independent of JSON whitespace/order.
58
+ const evidence = JSON.stringify(Object.fromEntries(Object.keys(record).sort().map(key => [key, record[key]])));
59
+ return { ...record, evidence };
60
+ }
61
+ export function assertPublicationToolObservation(publication, observation, now, observationNotBefore) {
62
+ for (const field of ["taskId", "projectId", "provider", "repository",
63
+ "externalKind", "externalId", "localCommit", "targetBranch"]) {
64
+ if (observation[field] !== publication[field]) {
65
+ throw usageError(`Tool observation ${field} does not match the Publication candidate.`);
66
+ }
67
+ }
68
+ if (observation.publicationId !== publication.id) {
69
+ throw usageError("Tool observation publicationId does not match the selected Publication.");
70
+ }
71
+ if (publication.externalUrl === undefined
72
+ || canonicalPublicationUrl(observation.externalUrl, publication.provider)
73
+ !== canonicalPublicationUrl(publication.externalUrl, publication.provider)) {
74
+ throw usageError("Tool observation externalUrl must match the recorded Publication service and object.");
75
+ }
76
+ const url = new URL(canonicalPublicationUrl(observation.externalUrl, publication.provider));
77
+ // GitLab UI routes are deployment-specific. Structured identity above and
78
+ // the complete registered URL bind the object without inventing a web route.
79
+ if (publication.provider === "github"
80
+ && url.pathname !== `/${publication.repository.toLowerCase()}/pull/${publication.externalId}`) {
81
+ throw usageError("Tool observation URL does not match the repository and PR/MR identity.");
82
+ }
83
+ const observed = Date.parse(observation.observedAt);
84
+ if ((observationNotBefore !== undefined && observed < observationNotBefore)
85
+ || observed > now.getTime() || now.getTime() - observed > MAX_AGE_MS) {
86
+ throw usageError("Tool observation is stale or future-dated; observe the current Publication within 24 hours of recording.");
87
+ }
88
+ if (observation.mergedAt !== undefined && Date.parse(observation.mergedAt) > observed) {
89
+ throw usageError("Tool observation merge time cannot follow its observation time.");
90
+ }
91
+ }
92
+ /** Derive the latest material change from immutable history, not title edits or
93
+ * initial bookkeeping. A tool read may legitimately precede its first upsert.
94
+ * Changed-then-restored candidates still have a change boundary. */
95
+ export function publicationObservationNotBefore(publication, previous) {
96
+ const fields = [
97
+ "projectId", "provider", "repository", "externalKind", "externalId",
98
+ "externalUrl", "localCommit", "targetBranch", "state", "headCommit", "remoteCommit", "mergedAt"
99
+ ];
100
+ let current = publication;
101
+ while (current.supersedes !== undefined) {
102
+ const prior = previous(current.supersedes);
103
+ if (prior === null)
104
+ throw usageError("Publication history is incomplete; cannot validate observation freshness.");
105
+ if (fields.some(field => current[field] !== prior[field])) {
106
+ return Date.parse(current.createdAt);
107
+ }
108
+ current = prior;
109
+ }
110
+ return undefined;
111
+ }
112
+ export function canonicalPublicationUrl(value, provider) {
113
+ let url;
114
+ try {
115
+ url = new URL(value);
116
+ }
117
+ catch {
118
+ throw usageError("Publication URL must be an absolute HTTP(S) URL.");
119
+ }
120
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password
121
+ || url.search || url.hash) {
122
+ throw usageError("Publication URL must be HTTP(S), without credentials, query or fragment.");
123
+ }
124
+ const path = url.pathname.replace(/\/$/u, "");
125
+ return `${url.origin}${provider === "github" ? path.toLowerCase() : path}`;
126
+ }
@@ -1,4 +1,5 @@
1
1
  import { publicationAdoption } from "./publicationAdoption.js";
2
+ import { projectDeliveryDecision, projectDeliveryRequirement } from "./projectDeliveryScope.js";
2
3
  /**
3
4
  * One read-only delivery projection over existing Task, workspace, completion,
4
5
  * and Publication facts. It never writes a merged flag or queries a provider.
@@ -10,6 +11,8 @@ export function projectTaskRemoteDelivery(facts) {
10
11
  const currentPublications = currentPublicationReferences(facts.publications);
11
12
  const projects = facts.task.projectBindings.map((binding) => {
12
13
  const expectedLocalCommit = expected.commits.get(binding.projectId) ?? null;
14
+ const scopeDecision = projectDeliveryDecision(facts.events, facts.task.id, binding.projectId);
15
+ const deliveryRequirement = projectDeliveryRequirement(scopeDecision);
13
16
  const baseCommit = baseCommits.get(binding.projectId) ?? null;
14
17
  const codeDelivery = expectedLocalCommit === null
15
18
  ? "unknown"
@@ -44,6 +47,9 @@ export function projectTaskRemoteDelivery(facts) {
44
47
  deliveryLocalCommit: covers ? publication.localCommit ?? null : null,
45
48
  adoption,
46
49
  codeDelivery,
50
+ deliveryRequirement,
51
+ scopeDecision,
52
+ deliveryRequired: deliveryRequirement === "required" && codeDelivery !== "none",
47
53
  publication,
48
54
  state: publication?.state ?? null,
49
55
  verification: publication?.verification ?? null,
@@ -51,23 +57,26 @@ export function projectTaskRemoteDelivery(facts) {
51
57
  coverage,
52
58
  merged,
53
59
  verified,
54
- reason: projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) + (coverage === "stale" && adoptionEvidence?.reason
60
+ reason: (deliveryRequirement === "excluded"
61
+ ? `No delivery obligation: ${scopeDecision.payload.reason} (${scopeDecision.id}). Merge and cleanup facts remain separate. `
62
+ : "") + projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) + (coverage === "stale" && adoptionEvidence?.reason
55
63
  ? ` ${adoptionEvidence.reason}` : "")
56
64
  };
57
65
  });
58
66
  const codeProjects = projects.filter((project) => project.codeDelivery !== "none");
59
- const mergedProjectCount = codeProjects.filter((project) => project.merged).length;
60
- const verifiedProjectCount = codeProjects.filter((project) => project.verified).length;
61
- const allMerged = codeProjects.every((project) => project.merged);
62
- const allVerified = codeProjects.every((project) => project.verified);
63
- const status = codeProjects.length === 0
67
+ const requiredProjects = projects.filter(project => project.deliveryRequired);
68
+ const mergedProjectCount = requiredProjects.filter((project) => project.merged).length;
69
+ const verifiedProjectCount = requiredProjects.filter((project) => project.verified).length;
70
+ const allMerged = requiredProjects.every((project) => project.merged);
71
+ const allVerified = requiredProjects.every((project) => project.verified);
72
+ const status = requiredProjects.length === 0
64
73
  ? "none"
65
- : codeProjects.some((project) => project.coverage === "head-unavailable")
74
+ : requiredProjects.some((project) => project.coverage === "head-unavailable")
66
75
  ? "unavailable"
67
76
  : allMerged
68
77
  ? allVerified ? "merged" : "unverified"
69
78
  : mergedProjectCount > 0 ? "partial"
70
- : codeProjects.some(p => p.state === "merged") ? "uncovered" : "pending";
79
+ : requiredProjects.some(p => p.state === "merged") ? "uncovered" : "pending";
71
80
  return {
72
81
  taskId: facts.task.id,
73
82
  source: expected.source,
@@ -81,6 +90,8 @@ export function projectTaskRemoteDelivery(facts) {
81
90
  allVerified,
82
91
  totalProjectCount: projects.length,
83
92
  codeProjectCount: codeProjects.length,
93
+ requiredCodeProjectCount: requiredProjects.length,
94
+ excludedProjectCount: projects.filter(project => project.deliveryRequirement === "excluded").length,
84
95
  mergedProjectCount,
85
96
  verifiedProjectCount,
86
97
  integratedCoverageSatisfied: allMerged && allVerified,
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { usageError } from "../errors/cliError.js";
3
3
  import { projectTaskRemoteDelivery } from "../task/remoteDelivery.js";
4
4
  import { PUBLICATION_ADOPTED_EVENT } from "./publicationAdoption.js";
5
+ import { PROJECT_DELIVERY_DECIDED_EVENT } from "./projectDeliveryScope.js";
5
6
  export function projectTaskRemoteDeliveryFromStore(store, task, currentCandidate = null) {
6
7
  return createTaskRemoteDeliveryProof(store, task, currentCandidate).delivery;
7
8
  }
@@ -10,7 +11,7 @@ export function createTaskRemoteDeliveryProof(store, task, currentCandidate = nu
10
11
  const delivery = projectTaskRemoteDelivery({
11
12
  task,
12
13
  events: store.listEventsByType(task.id, [
13
- "task.completed", "task.archived", PUBLICATION_ADOPTED_EVENT
14
+ "task.completed", "task.archived", PUBLICATION_ADOPTED_EVENT, PROJECT_DELIVERY_DECIDED_EVENT
14
15
  ]),
15
16
  publications,
16
17
  managedWorkspaces: store.listManagedWorkspaces(task.id),
@@ -49,13 +50,13 @@ export function assertTaskRemoteDeliveryProof(store, task, proof) {
49
50
  }
50
51
  export function assertTaskRemoteDeliveryIntegrated(delivery) {
51
52
  if (!delivery.allMerged) {
52
- const headUnavailable = delivery.projects.filter((project) => (project.codeDelivery !== "none"
53
+ const headUnavailable = delivery.projects.filter((project) => (project.deliveryRequired
53
54
  && project.coverage === "head-unavailable"));
54
55
  if (headUnavailable.length > 0 && delivery.source === "task-completed") {
55
56
  throw usageError(`Task ${delivery.taskId} has no frozen completion heads for: ${headUnavailable.map(({ projectId }) => projectId).join(", ")}. Historical acceptance cannot be inferred from a merge or archive.`);
56
57
  }
57
58
  const uncovered = delivery.projects
58
- .filter((project) => project.codeDelivery !== "none" && !project.merged)
59
+ .filter((project) => project.deliveryRequired && !project.merged)
59
60
  .map((project) => (`${project.projectId}/${project.coverage}`
60
61
  + (project.publication === null ? "" : `/${project.publication.id}`)))
61
62
  .join(", ");
@@ -65,7 +66,7 @@ export function assertTaskRemoteDeliveryIntegrated(delivery) {
65
66
  if (delivery.allVerified)
66
67
  return;
67
68
  const unverified = delivery.projects
68
- .filter((project) => (project.codeDelivery !== "none"
69
+ .filter((project) => (project.deliveryRequired
69
70
  && project.merged
70
71
  && !project.verified))
71
72
  .map((project) => (`${project.projectId}/${project.coverage}`
@@ -3,6 +3,8 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
3
3
  import { createProjectResources } from "../resources/projectResourceService.js";
4
4
  import { admitTaskActivationRequest, adoptTaskActivationRequest, cancelTaskActivationRequest, canonicalEnvironmentPlan, createTaskActivationRequest, describeEnvironmentPlan, failTaskActivationRequest, settledActivationFromEvents, taskActivationInputDigest, TASK_ACTIVATION_EVENT } from "./taskActivation.js";
5
5
  import { setTaskActivationRequest, recordTaskActivationRequestEvidence } from "./task.js";
6
+ import { contextContentDigest } from "../context/contextSnapshot.js";
7
+ import { activationSourceEvent } from "../context/taskPhase.js";
6
8
  /**
7
9
  * The terminal outcome already recorded for a requestId, or `undefined` if it
8
10
  * was never cancelled or adopted.
@@ -63,6 +65,20 @@ export function recordTaskActivationRequestInTransaction(tx, input, now) {
63
65
  ? "immediate"
64
66
  : "after-planning-turn";
65
67
  const existing = task.activationRequest;
68
+ if (input.sourceMessageId !== undefined && input.sourceText !== undefined) {
69
+ throw new Error("Activation source requires either a Message or exact text, not both.");
70
+ }
71
+ if (input.sourceText !== undefined && (!input.sourceText.trim() || input.sourceText.includes("\0"))) {
72
+ throw new Error("Activation source text must be nonempty exact input.");
73
+ }
74
+ const sourceMessage = input.sourceMessageId === undefined ? undefined : tx.queryContextRecords(task.id, {
75
+ family: "task-message", ids: [input.sourceMessageId], limit: 1
76
+ }).records[0]?.value;
77
+ if (input.sourceMessageId !== undefined && (!sourceMessage
78
+ || !["user", "operator"].includes(sourceMessage.kind) || sourceMessage.recipient !== undefined
79
+ || sourceMessage.workItemId !== undefined)) {
80
+ throw new Error("Activation source must reference an existing shared user/Operator Message in this Task.");
81
+ }
66
82
  // A requestId whose outcome was already decided is never re-opened, whether
67
83
  // it still holds the slot or a later request displaced it. Cancelling A,
68
84
  // requesting B and then retrying A must not resurrect A: the withdrawal is
@@ -81,6 +97,11 @@ export function recordTaskActivationRequestInTransaction(tx, input, now) {
81
97
  + "Use a new requestId.");
82
98
  }
83
99
  if (existing?.operation.requestId === input.requestId) {
100
+ const source = activationSourceEvent(tx, task)?.payload;
101
+ if (source?.sourceMessageId !== input.sourceMessageId || source?.sourceText !== input.sourceText
102
+ || (sourceMessage !== undefined && source?.sourceBodyDigest !== contextContentDigest(sourceMessage.body))) {
103
+ throw new Error(`Activation requestId ${input.requestId} was already used with a different source.`);
104
+ }
84
105
  const digest = taskActivationInputDigest(task.id, {
85
106
  startMode,
86
107
  ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun }),
@@ -112,11 +133,25 @@ export function recordTaskActivationRequestInTransaction(tx, input, now) {
112
133
  environmentPlan: plan
113
134
  }, now);
114
135
  tx.saveTask(setTaskActivationRequest(task, request, now));
136
+ const priorMessageId = tx.queryContextRecords(task.id, {
137
+ family: "task-message", descending: true, limit: 1
138
+ }).records[0]?.id;
115
139
  tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, TASK_ACTIVATION_EVENT.requested, {
116
140
  requestId: request.operation.requestId,
117
141
  startMode: request.startMode,
118
142
  environmentPlan: describeEnvironmentPlan(request.environmentPlan),
119
143
  actor: request.operation.actorId,
144
+ ...(priorMessageId === undefined ? {} : { priorMessageId }),
145
+ ...(input.sourceMessageId === undefined ? {} : {
146
+ sourceMessageId: input.sourceMessageId,
147
+ sourceBodyDigest: contextContentDigest(sourceMessage.body),
148
+ sourceKind: "task-message"
149
+ }),
150
+ ...(input.sourceText === undefined ? {} : {
151
+ sourceText: input.sourceText,
152
+ sourceKind: input.actorId.startsWith("task:") ? "leader-attested-native-input" : "caller-provided-input",
153
+ ...(input.sourceNativeSessionId === undefined ? {} : { sourceNativeSessionId: input.sourceNativeSessionId })
154
+ }),
120
155
  ...(request.afterPlanningRun === undefined
121
156
  ? {}
122
157
  : { afterPlanningRun: request.afterPlanningRun })
@@ -8,6 +8,7 @@ import { createThemeController } from "/assets/js/lib/theme.js";
8
8
  import { clearPreference, readSessionAccessMode, writeSessionAccessMode } from "/assets/js/lib/prefs.js";
9
9
  import { i18n, createManageShell, pageSections, registerManagePage, navigateManage } from "/assets/js/layout/manageShell.js";
10
10
  import { mountSkills } from "/assets/js/views/skills.js";
11
+ import { mountJobs } from "/assets/js/views/jobs.js";
11
12
 
12
13
  const $ = selector => document.querySelector(selector);
13
14
  const t = i18n.t;
@@ -18,11 +19,13 @@ function readSection() {
18
19
  return SECTIONS.includes(section) ? section : SECTIONS[0];
19
20
  }
20
21
  let section = readSection();
22
+ let jobs;
21
23
  const shell = createManageShell({ page: "settings", t, onNavigate: navigate });
22
24
  function showSection(next) {
23
25
  section = next;
24
26
  document.querySelectorAll("#settings-root > .manage-section[data-section]").forEach(panel => { panel.hidden = panel.dataset.section !== next; });
25
27
  shell.set(next);
28
+ if (next === "jobs" && !jobs) jobs = mountJobs($("#global-jobs"), t);
26
29
  }
27
30
  function navigate(next) {
28
31
  navigateManage({ page: "settings", section: next });
@@ -34,6 +37,9 @@ function markDirty(name, value) {
34
37
  }
35
38
  registerManagePage("settings", { show: next => showSection(next.section) });
36
39
  i18n.subscribe(() => shell.render());
40
+ i18n.subscribe(() => {
41
+ if (jobs && !jobs.isDirty() && !jobs.isBusy()) jobs = mountJobs($("#global-jobs"), t);
42
+ });
37
43
  showSection(section);
38
44
  const skills = mountSkills($("#skill-browser"), t, { heading: false, onDirty: value => markDirty("skills", value) });
39
45
  i18n.subscribe(() => skills?.translate());
@@ -59,7 +65,7 @@ let searchTimer;
59
65
  const more = h("button.btn", { type: "button", hidden: true }, t("settings.more"));
60
66
  container.after(more);
61
67
  window.addEventListener("beforeunload", event => {
62
- if (!groups.some(g => g.dirty)) return;
68
+ if (!groups.some(g => g.dirty) && !jobs?.isDirty()) return;
63
69
  event.preventDefault(); event.returnValue = "";
64
70
  });
65
71