@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
@@ -11,6 +11,9 @@ export const DURABLE_JOB_TERMINAL_STATUSES = [
11
11
  "cancelled",
12
12
  "unknown-needs-attention"
13
13
  ];
14
+ export function executionNamespace(run) {
15
+ return "taskId" in run ? run.taskId : "global";
16
+ }
14
17
  export function isDurableJobTerminal(status) {
15
18
  return DURABLE_JOB_TERMINAL_STATUSES.includes(status);
16
19
  }
@@ -22,6 +25,9 @@ export function createDurableJob(input, now) {
22
25
  return validateDurableJob({
23
26
  schemaVersion: CURRENT_DURABLE_JOB_SCHEMA_VERSION,
24
27
  id: input.id,
28
+ definitionId: input.definitionId ?? input.id,
29
+ definitionRevision: input.definitionRevision ?? 1,
30
+ trigger: input.trigger ?? { source: "manual", key: input.operation.requestId },
25
31
  taskId: input.taskId,
26
32
  owner: input.owner,
27
33
  projectId: input.projectId,
@@ -233,50 +239,26 @@ export function validateDurableJob(job) {
233
239
  throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
234
240
  }
235
241
  validateOperationFacts(job.operation);
236
- validateTaskRecordReference({
237
- taskId: job.taskId,
238
- localId: job.id
239
- }, "durableJob");
240
- validateDurableJobOwner(job.owner, job.taskId);
241
- requireIdentity(job.projectId, "DurableJob project id");
242
- requireCommit(job.head, "DurableJob head");
243
- requireText(job.workspace, "DurableJob workspace");
244
- if (!job.workspace.startsWith("/")) {
245
- throw new Error("DurableJob workspace must be an absolute path.");
246
- }
247
- validateEnvMap(job.env);
248
- if (!Array.isArray(job.steps) || job.steps.length === 0) {
249
- throw new Error("DurableJob requires at least one step.");
250
- }
251
- const stepNames = new Set();
252
- for (const step of job.steps) {
253
- const name = requireText(step.name, "DurableJob step name");
254
- if (stepNames.has(name)) {
255
- throw new Error(`DurableJob step names must be unique: ${name}.`);
256
- }
257
- stepNames.add(name);
258
- requireText(step.command, `DurableJob step command (${name})`);
259
- if (step.argv !== undefined) {
260
- if (!Array.isArray(step.argv) || step.argv.length === 0) {
261
- throw new Error(`DurableJob step argv must be a non-empty array (${name}).`);
262
- }
263
- for (const value of step.argv) {
264
- requireText(value, `DurableJob step argv (${name})`);
265
- }
266
- }
267
- if (step.cwd !== undefined) {
268
- requireText(step.cwd, `DurableJob step cwd (${name})`);
269
- if (!step.cwd.startsWith("/")) {
270
- throw new Error(`DurableJob step cwd must be absolute (${name}).`);
271
- }
272
- }
273
- if (step.env !== undefined) {
274
- validateEnvMap(step.env);
275
- }
276
- if (step.timeoutMs !== undefined) {
277
- requirePositiveInteger(step.timeoutMs, `DurableJob step timeout (${name})`);
278
- }
279
- }
242
+ requireIdentity(job.definitionId, "Job definition id");
243
+ requirePositiveInteger(job.definitionRevision, "Job definition revision");
244
+ requireText(job.trigger?.key, "Job trigger key");
245
+ if (!["manual", "retry", "schedule", "integration", "migration"].includes(job.trigger.source)) {
246
+ throw new Error("Invalid JobRun trigger source.");
247
+ }
248
+ if ("taskId" in job) {
249
+ validateTaskRecordReference({ taskId: job.taskId, localId: job.id }, "durableJob");
250
+ validateDurableJobOwner(job.owner, job.taskId);
251
+ requireIdentity(job.projectId, "DurableJob project id");
252
+ }
253
+ else {
254
+ if (job.scope !== "global")
255
+ throw new Error("Invalid global JobRun scope.");
256
+ requireIdentity(job.id, "JobRun id");
257
+ requireText(job.action, "JobRun builtin action");
258
+ if (job.projectId !== undefined)
259
+ requireIdentity(job.projectId, "JobRun Project id");
260
+ }
261
+ validateJobExecutionConfiguration(job);
280
262
  requireText(job.idempotencyKey, "DurableJob idempotency key");
281
263
  if (!/^[a-f0-9]{64}$/u.test(job.idempotencyKey)) {
282
264
  throw new Error("DurableJob idempotency key is invalid.");
@@ -321,7 +303,10 @@ export function validateDurableJob(job) {
321
303
  normalizeDurableJobResult(job.result);
322
304
  }
323
305
  if (job.retryOf !== undefined) {
324
- validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
306
+ if ("taskId" in job)
307
+ validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
308
+ else
309
+ requireIdentity(job.retryOf, "JobRun retryOf");
325
310
  }
326
311
  if (job.cancelRequestedAt !== undefined) {
327
312
  requireTimestamp(job.cancelRequestedAt, "DurableJob cancelRequestedAt");
@@ -345,14 +330,18 @@ export function validateDurableJob(job) {
345
330
  }
346
331
  export function validDurableJobTransition(before, after) {
347
332
  if (before.id !== after.id
348
- || before.taskId !== after.taskId
333
+ || before.definitionId !== after.definitionId
334
+ || before.definitionRevision !== after.definitionRevision
335
+ || !isDeepStrictEqual(before.trigger, after.trigger)
336
+ || before.retryOf !== after.retryOf
337
+ || executionNamespace(before) !== executionNamespace(after)
349
338
  || before.projectId !== after.projectId
350
339
  || before.head !== after.head
351
340
  || before.workspace !== after.workspace
352
341
  || before.idempotencyKey !== after.idempotencyKey
353
342
  || before.artifactsLocator !== after.artifactsLocator
354
343
  || before.createdAt !== after.createdAt
355
- || !isDeepStrictEqual(before.owner, after.owner)
344
+ || !isDeepStrictEqual("owner" in before ? before.owner : [before.action, before.actionInput], "owner" in after ? after.owner : [after.action, after.actionInput])
356
345
  || !isDeepStrictEqual(before.env, after.env)
357
346
  || !isDeepStrictEqual(before.steps, after.steps)
358
347
  || before.operation.requestId !== after.operation.requestId
@@ -374,7 +363,10 @@ export function validDurableJobTransition(before, after) {
374
363
  const wakeupFlip = before.wakeupNotified !== true && after.wakeupNotified === true;
375
364
  const acknowledgeFlip = before.acknowledgedAt === undefined
376
365
  && after.acknowledgedAt !== undefined;
377
- return before.status === after.status
366
+ const { wakeupNotified: _beforeWake, acknowledgedAt: _beforeAck, updatedAt: _beforeTime, ...beforeFacts } = before;
367
+ const { wakeupNotified: _afterWake, acknowledgedAt: _afterAck, updatedAt: _afterTime, ...afterFacts } = after;
368
+ return isDeepStrictEqual(beforeFacts, afterFacts)
369
+ && before.status === after.status
378
370
  && isDeepStrictEqual(before.result, after.result)
379
371
  && isDeepStrictEqual(before.operation, after.operation)
380
372
  && before.terminalAt === after.terminalAt
@@ -398,7 +390,7 @@ export function validDurableJobTransition(before, after) {
398
390
  };
399
391
  return (allowed[before.status] ?? []).includes(after.status);
400
392
  }
401
- function validateDurableJobOwner(owner, taskId) {
393
+ export function validateDurableJobOwner(owner, taskId) {
402
394
  if (owner.kind === "integration-attempt") {
403
395
  validateTaskRecordReference({ taskId, localId: owner.integrationAttemptId }, "integrationAttempt");
404
396
  }
@@ -409,6 +401,37 @@ function validateDurableJobOwner(owner, taskId) {
409
401
  throw new Error(`DurableJob owner kind is invalid: ${String(owner.kind)}.`);
410
402
  }
411
403
  }
404
+ export function validateJobExecutionConfiguration(job) {
405
+ requireCommit(job.head, "DurableJob head");
406
+ requireText(job.workspace, "DurableJob workspace");
407
+ if (!job.workspace.startsWith("/"))
408
+ throw new Error("DurableJob workspace must be an absolute path.");
409
+ validateEnvMap(job.env);
410
+ if (!Array.isArray(job.steps) || job.steps.length === 0) {
411
+ throw new Error("DurableJob requires at least one step.");
412
+ }
413
+ const names = new Set();
414
+ for (const step of job.steps) {
415
+ const name = requireText(step.name, "DurableJob step name");
416
+ if (names.has(name))
417
+ throw new Error(`DurableJob step names must be unique: ${name}.`);
418
+ names.add(name);
419
+ requireText(step.command, `DurableJob step command (${name})`);
420
+ if (step.argv !== undefined) {
421
+ if (!Array.isArray(step.argv) || step.argv.length === 0)
422
+ throw new Error("Step argv must be nonempty.");
423
+ for (const value of step.argv)
424
+ requireText(value, `DurableJob step argv (${name})`);
425
+ }
426
+ if (step.cwd !== undefined && !requireText(step.cwd, "Step cwd").startsWith("/")) {
427
+ throw new Error("DurableJob step cwd must be absolute.");
428
+ }
429
+ if (step.env !== undefined)
430
+ validateEnvMap(step.env);
431
+ if (step.timeoutMs !== undefined)
432
+ requirePositiveInteger(step.timeoutMs, "Step timeout");
433
+ }
434
+ }
412
435
  function validateEnvMap(env) {
413
436
  if (typeof env !== "object" || env === null || Array.isArray(env)) {
414
437
  throw new Error("DurableJob env must be a map.");
@@ -0,0 +1,68 @@
1
+ import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
2
+ import { validateJobExecutionConfiguration, validateDurableJobOwner } from "./durableJob.js";
3
+ export function jobScopeKey(scope) {
4
+ if (scope.kind === "global")
5
+ return "global";
6
+ requireIdentity(scope.taskId, "Job Task id");
7
+ if (!/^task-\d+$/u.test(scope.taskId))
8
+ throw new Error("Invalid Job Task id.");
9
+ return scope.taskId;
10
+ }
11
+ export function validateJobDefinition(job) {
12
+ requireKnownFields(job, ["schemaVersion", "id", "scope", "title", "revision", "configuration", "createdAt", "updatedAt"], "Job");
13
+ if (job.schemaVersion !== 1)
14
+ throw new Error("Job schemaVersion must be 1.");
15
+ requireIdentity(job.id, "Job id");
16
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(job.id))
17
+ throw new Error("Invalid Job definition id.");
18
+ requireText(job.title, "Job title");
19
+ requirePositiveInteger(job.revision, "Job revision");
20
+ requireTimestamp(job.createdAt, "Job createdAt");
21
+ requireTimestamp(job.updatedAt, "Job updatedAt");
22
+ if (job.scope.kind !== "task" && job.scope.kind !== "global")
23
+ throw new Error("Invalid Job scope.");
24
+ requireKnownFields(job.scope, job.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Job scope");
25
+ jobScopeKey(job.scope);
26
+ const config = job.configuration;
27
+ if (job.scope.kind === "global") {
28
+ if (config.kind !== "builtin")
29
+ throw new Error("Global Jobs require a controlled builtin action.");
30
+ requireKnownFields(config, ["kind", "action", "projectId", "input"], "Builtin Job");
31
+ requireText(config.action, "Builtin action");
32
+ if (config.projectId !== undefined)
33
+ requireIdentity(config.projectId, "Job Project id");
34
+ if (config.input === undefined)
35
+ throw new Error("Builtin Job input is required.");
36
+ }
37
+ else {
38
+ if (config.kind !== "shell")
39
+ throw new Error("Task Jobs require a managed shell configuration.");
40
+ requireKnownFields(config, ["kind", "owner", "projectId", "head", "workspace", "env", "steps"], "Task Job");
41
+ requireIdentity(config.projectId, "Job Project id");
42
+ validateDurableJobOwner(config.owner, job.scope.taskId);
43
+ validateJobExecutionConfiguration(config);
44
+ }
45
+ return job;
46
+ }
47
+ export function createJobDefinition(input, now) {
48
+ return validateJobDefinition({
49
+ schemaVersion: 1, ...structuredClone(input), revision: 1,
50
+ createdAt: now.toISOString(), updatedAt: now.toISOString()
51
+ });
52
+ }
53
+ export function reviseJobDefinition(job, input, now) {
54
+ return validateJobDefinition({
55
+ ...job, title: input.title, configuration: structuredClone(input.configuration),
56
+ revision: job.revision + 1, updatedAt: now.toISOString()
57
+ });
58
+ }
59
+ /** Old execution IDs are preserved one-to-one; command equality never merges history. */
60
+ export function definitionFromTaskRun(run) {
61
+ return createJobDefinition({
62
+ id: run.definitionId, scope: { kind: "task", taskId: run.taskId }, title: run.steps[0].name,
63
+ configuration: {
64
+ kind: "shell", owner: run.owner, projectId: run.projectId, head: run.head,
65
+ workspace: run.workspace, env: run.env, steps: run.steps
66
+ }
67
+ }, new Date(run.createdAt));
68
+ }
@@ -267,7 +267,8 @@ function readSpec(specPath) {
267
267
  if (typeof spec.jobId !== "string" || spec.jobId.length === 0) {
268
268
  throw new Error("DurableJob spec is missing jobId.");
269
269
  }
270
- if (typeof spec.taskId !== "string" || spec.taskId.length === 0) {
270
+ if (spec.scope === "global" ? spec.taskId !== undefined
271
+ : typeof spec.taskId !== "string" || spec.taskId.length === 0) {
271
272
  throw new Error("DurableJob spec is missing taskId.");
272
273
  }
273
274
  if (typeof spec.workspace !== "string" || !spec.workspace.startsWith("/")) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Controlled Project domain action invoked by the artifact-only Job runner.
3
+ * Its catalog connection is read-only; only the shared domain service writes Git.
4
+ */
5
+ import { resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { SqliteTaskStore } from "../storage/sqliteStore.js";
8
+ import { authorizeGlobalJobStart } from "../controller/jobDefinitionControl.js";
9
+ import { refreshRegisteredProject } from "../repository/projectRefresh.js";
10
+ import { projectRefreshDigest } from "./builtinJob.js";
11
+ import { CliError } from "../errors/cliError.js";
12
+ export async function runProjectRefresh(home, runId) {
13
+ const store = new SqliteTaskStore(home, { readonly: true });
14
+ try {
15
+ const run = store.getGlobalJobRun(runId);
16
+ if (!run || run.action !== "project.refresh" || !["queued", "running"].includes(run.status)
17
+ || run.operation.effect !== "possible" || run.cancelRequestedAt) {
18
+ throw new Error("No active admitted project.refresh Run.");
19
+ }
20
+ authorizeGlobalJobStart(store, run);
21
+ const result = await refreshRegisteredProject(store, run.projectId, {
22
+ automatic: true,
23
+ expectedDigest: projectRefreshDigest({
24
+ kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
25
+ }),
26
+ beforeRefresh: () => {
27
+ const current = store.getGlobalJobRun(runId);
28
+ if (!current || !["queued", "running"].includes(current.status) || current.cancelRequestedAt) {
29
+ throw new Error("Project refresh Run stopped while waiting for maintenance.");
30
+ }
31
+ authorizeGlobalJobStart(store, current);
32
+ }
33
+ });
34
+ process.stdout.write(JSON.stringify({ action: run.action, projectId: run.projectId,
35
+ trigger: run.trigger, outcome: "succeeded", observedAt: new Date().toISOString(),
36
+ remoteCheck: { status: "verified", commit: result.toCommit }, refresh: result }) + "\n");
37
+ }
38
+ finally {
39
+ store.close();
40
+ }
41
+ }
42
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
43
+ runProjectRefresh(process.argv[2], process.argv[3]).catch(error => {
44
+ process.stderr.write(JSON.stringify({ outcome: "failed", reason: error instanceof Error ? error.message : String(error),
45
+ ...(error instanceof CliError ? { details: error.details } : {}) }) + "\n");
46
+ process.exitCode = 1;
47
+ });
48
+ }
@@ -0,0 +1,44 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createGlobalRoleMessage } from "./message.js";
3
+ import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
4
+ import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
5
+ import { INBOX_CHECK_PREFIX } from "./inboxTurn.js";
6
+ const requestId = (agentId, nativeSessionId) => `${INBOX_CHECK_PREFIX}recovery:${createHash("sha256").update(JSON.stringify([agentId, nativeSessionId])).digest("hex")}`;
7
+ /** Derive a single initialization notice from existing retained claims. New
8
+ * Sessions are created only by the existing authorized lifecycle path. A
9
+ * restart can finish notice creation, but cannot select or replace a Session. */
10
+ export function globalInboxRecovery(store, roleName, now) {
11
+ return store.transaction(tx => {
12
+ const sessions = tx.getGlobalRoleSessionSet(roleName);
13
+ const session = sessions?.sessions[sessions.activeAgentId];
14
+ const binding = sessions?.providerBinding;
15
+ if (!session || session.status !== "active" || !session.nativeSessionId || !binding
16
+ || binding.authority.owner !== "controller"
17
+ || currentProviderConversation(binding).conversationId !== session.nativeSessionId
18
+ || binding.run && ["submitting", "accepted", "delivery-unknown"].includes(binding.run.status)
19
+ || hasRuntimeCleanupObligation(tx.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName }))))
20
+ return;
21
+ if (tx.queryGlobalRoleMessages(roleName, { handling: true, handlingExceptSession: {
22
+ agentId: session.agentId, nativeSessionId: session.nativeSessionId
23
+ }, limit: 0 }).count === 0)
24
+ return;
25
+ const id = requestId(session.agentId, session.nativeSessionId);
26
+ const existing = tx.queryGlobalRoleMessages(roleName, { requestId: id, limit: 1 }).messages[0];
27
+ if (existing)
28
+ return existing;
29
+ const notice = {
30
+ ...createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), roleName, [
31
+ "Yui-Summary: This successor has retained inbox handling obligations; inspect original evidence before settlement.",
32
+ "Read current Session Context and yui inbox list --view history --json. Read the exact original, original claim and prior effects.",
33
+ "This is initialization only: do not replay old input, retarget its delivery, or infer business completion.",
34
+ "After handling under current authority, explicitly acknowledge the original entry without replacing its claim.",
35
+ "Uncertain external effects require verification or a real user decision. New queued input remains separate."
36
+ ].join("\n"), "system", { type: "system" }, now, {
37
+ inputControl: { action: "queue", requestId: id, expectedSessionId: session.nativeSessionId }
38
+ }),
39
+ deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId }
40
+ };
41
+ tx.saveGlobalRoleMessage(notice);
42
+ return notice;
43
+ });
44
+ }
@@ -11,7 +11,7 @@ export function recordGlobalSteerResult(store, roleName, messageId, receipt) {
11
11
  const now = new Date();
12
12
  const outcome = receipt.state === "steered" ? "accepted"
13
13
  : receipt.state === "steer-unknown" ? "delivery-unknown" : "rejected";
14
- const updated = { ...message, control: {
14
+ const updated = { ...message, control: { ...message.control,
15
15
  requestId: source.requestId, receiptId: `steer:${roleName}/${messageId}`,
16
16
  outcome, observedAt: now.toISOString()
17
17
  } };
@@ -3,7 +3,8 @@ import { isDeepStrictEqual } from "node:util";
3
3
  /** Semantic handling and transport delivery are independent facts. Projections
4
4
  * must not infer handling from delivery, a native terminal, or an empty queue. */
5
5
  export function inboxHandlingState(receipt) {
6
- return receipt?.ack !== undefined ? "handled" : receipt !== undefined ? "claimed-unacknowledged" : "unclaimed";
6
+ return receipt?.retirement !== undefined ? "retired" : receipt?.ack !== undefined ? "handled"
7
+ : receipt !== undefined ? "claimed-unacknowledged" : "unclaimed";
7
8
  }
8
9
  export function isGlobalInboxMessage(message) {
9
10
  return message.inputControl !== undefined || message.interruptThen !== undefined;
@@ -11,7 +12,8 @@ export function isGlobalInboxMessage(message) {
11
12
  /** Unhandled own-role input, including accepted and unknown delivery. This is
12
13
  * NOT the scheduler's deliverable queue; selecting it never authorizes replay. */
13
14
  export function pendingGlobalInboxMessages(messages) {
14
- return messages.filter(message => isGlobalInboxMessage(message) && inboxHandlingState(message.inbox) !== "handled");
15
+ return messages.filter(message => isGlobalInboxMessage(message)
16
+ && !["handled", "retired"].includes(inboxHandlingState(message.inbox)));
15
17
  }
16
18
  /** Original message evidence only. A missing receipt is not proof of
17
19
  * non-submission; admission additionally inspects the exact native attempt. */
@@ -22,20 +24,22 @@ export function globalInboxDeliveryState(message) {
22
24
  export function validateInboxReceipt(receipt) {
23
25
  if (receipt === undefined)
24
26
  return;
25
- requireKnownFields(receipt, ["claim", "ack"], "Inbox receipt");
27
+ requireKnownFields(receipt, ["claim", "ack", "retirement"], "Inbox receipt");
28
+ if (receipt.ack && receipt.retirement)
29
+ throw new Error("Inbox disposition cannot be both acknowledgement and retirement.");
26
30
  for (const [kind, entry] of Object.entries(receipt)) {
27
31
  requireKnownFields(entry, kind === "claim" ? ["requestId", "actor", "at"]
28
32
  : ["requestId", "actor", "at", "reason"], "Inbox action");
29
33
  requireText(entry.requestId, "Inbox request id");
30
34
  requireTimestamp(entry.at, "Inbox timestamp");
31
35
  const actor = entry.actor;
32
- requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId"], "Inbox actor");
36
+ requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId", "attemptId"], "Inbox actor");
33
37
  if (!["task", "global"].includes(actor.scope)
34
38
  || (actor.scope === "task") !== (actor.taskId !== undefined))
35
39
  throw new Error("Inbox actor scope is invalid.");
36
40
  for (const value of Object.values(actor))
37
41
  requireText(value, "Inbox actor identity");
38
- if (kind === "ack")
42
+ if (kind !== "claim")
39
43
  requireText(entry.reason, "Inbox disposition");
40
44
  }
41
45
  if (!receipt.claim)
@@ -45,7 +49,8 @@ export function validateInboxReceipt(receipt) {
45
49
  * Host admission. The original claim and any ack are immutable audit facts. */
46
50
  export function assertInboxReceiptPreserved(previous, next) {
47
51
  if (previous !== undefined && (!isDeepStrictEqual(previous.claim, next?.claim)
48
- || previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack))) {
52
+ || previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack)
53
+ || previous.retirement !== undefined && !isDeepStrictEqual(previous.retirement, next?.retirement))) {
49
54
  throw new Error("Inbox handling ownership changed; reread the original instead of overwriting its receipt.");
50
55
  }
51
56
  }
@@ -55,6 +60,9 @@ export function assertInboxReceiptPreserved(previous, next) {
55
60
  */
56
61
  export function assertInboxOriginalPreserved(previous, next) {
57
62
  assertInboxReceiptPreserved(previous?.inbox, next.inbox);
63
+ if (previous?.control?.target !== undefined && !isDeepStrictEqual(previous.control.target, next.control?.target)) {
64
+ throw new Error("The exact admitted control target cannot be replaced or erased.");
65
+ }
58
66
  if (previous?.inbox !== undefined && (previous.body !== next.body || previous.kind !== next.kind
59
67
  || !isDeepStrictEqual(previous.author, next.author) || !isDeepStrictEqual(previous.inputControl, next.inputControl))) {
60
68
  throw new Error("Inbox receipt binds the exact original Message; submit changed content as a new Message.");
@@ -0,0 +1,86 @@
1
+ import { globalInboxDeliveryState } from "./inboxReceipt.js";
2
+ export const INBOX_CHECK_PREFIX = "inbox-check:";
3
+ export const INBOX_CHECK_REASON = "inbox-handling-missing";
4
+ export const isInboxWake = (entry) => "fromCursor" in entry;
5
+ export const isHandlingReminder = (entry) => isInboxWake(entry)
6
+ ? entry.reasons.every(reason => reason === INBOX_CHECK_REASON)
7
+ : entry.kind === "system" && entry.inputControl?.requestId.startsWith(INBOX_CHECK_PREFIX) === true;
8
+ /** Correlation uses existing input identities, not timestamps, output text or
9
+ * Task state. Active consumption adds only the exact attempt to its claim. */
10
+ export function belongsToInboxTurn(entry, actor, turn) {
11
+ if (!turn)
12
+ return false;
13
+ const claim = entry.inbox?.claim.actor;
14
+ if (claim?.attemptId === turn.attemptId && claim.scope === actor.scope && claim.taskId === actor.taskId
15
+ && claim.roleName === actor.roleName && claim.agentId === actor.agentId
16
+ && claim.nativeSessionId === actor.nativeSessionId)
17
+ return true;
18
+ if (isInboxWake(entry))
19
+ return actor.scope === "task" && actor.roleName === "leader"
20
+ && entry.taskId === actor.taskId && (turn.input?.kind === "wake" && turn.input.wakeId === entry.id
21
+ && turn.input.taskId === entry.taskId
22
+ || turn.attemptId.startsWith(`notification:${entry.taskId}/${entry.id}/`));
23
+ if ("roleName" in entry) {
24
+ if (actor.scope !== "global" || entry.roleName !== actor.roleName)
25
+ return false;
26
+ if (entry.deliveryTarget && (entry.deliveryTarget.agentId !== actor.agentId
27
+ || entry.deliveryTarget.nativeSessionId !== actor.nativeSessionId))
28
+ return false;
29
+ if (turn.input?.kind === "message" && turn.input.roleName === actor.roleName
30
+ && turn.input.messageId === entry.id || turn.attemptId === `global-input:${actor.roleName}/${entry.id}`)
31
+ return true;
32
+ }
33
+ else {
34
+ if (actor.scope !== "task" || entry.taskId !== actor.taskId)
35
+ return false;
36
+ if (turn.runId !== undefined && entry.recipient?.roleName === actor.roleName
37
+ && entry.continuation?.runId === turn.runId)
38
+ return true;
39
+ }
40
+ const target = entry.control?.target;
41
+ return entry.control?.outcome === "accepted" && target?.agentId === actor.agentId
42
+ && target.nativeSessionId === actor.nativeSessionId && target.attemptId === turn.attemptId;
43
+ }
44
+ /** Historical absence of ACK is not fresh work. Current unsubmitted input
45
+ * remains discoverable, but never steals an unsettled transport owner. */
46
+ export function inboxEntryCategory(entry, actor, turn) {
47
+ if (belongsToInboxTurn(entry, actor, turn))
48
+ return "current";
49
+ if (entry.inbox !== undefined)
50
+ return "history";
51
+ if (isInboxWake(entry))
52
+ return "history";
53
+ if ("roleName" in entry) {
54
+ if (entry.delivery !== undefined || entry.notDelivered !== undefined
55
+ || entry.deliveryTarget && (entry.deliveryTarget.agentId !== actor.agentId
56
+ || entry.deliveryTarget.nativeSessionId !== actor.nativeSessionId))
57
+ return "history";
58
+ }
59
+ else if (entry.continuation?.runId !== undefined)
60
+ return "history";
61
+ if (entry.inputControl?.expectedSessionId !== undefined
62
+ && entry.inputControl.expectedSessionId !== actor.nativeSessionId)
63
+ return "history";
64
+ if (entry.control?.outcome === "accepted")
65
+ return "history";
66
+ return "available";
67
+ }
68
+ export function inboxSettled(receipt) {
69
+ return receipt?.ack !== undefined || receipt?.retirement !== undefined;
70
+ }
71
+ export function missingTurnHandling(entries, actor, turn) {
72
+ return entries.filter(entry => !inboxSettled(entry.inbox) && !isHandlingReminder(entry)
73
+ && belongsToInboxTurn(entry, actor, turn));
74
+ }
75
+ /** Accepted historical input and explicit nondelivery do not impose an input
76
+ * order on independent new messages. Unknown delivery still does. */
77
+ export function blocksInboxOrder(entry, actor, turn) {
78
+ if (isInboxWake(entry) || inboxSettled(entry.inbox))
79
+ return false;
80
+ if ("roleName" in entry && (globalInboxDeliveryState(entry) === "accepted" || entry.notDelivered !== undefined))
81
+ return false;
82
+ if (entry.control?.outcome === "accepted")
83
+ return false;
84
+ return inboxEntryCategory(entry, actor, turn) !== "history"
85
+ || entry.control !== undefined && ["pending", "delivery-unknown"].includes(entry.control.outcome);
86
+ }
@@ -147,6 +147,7 @@ export function recordTaskMessageControlOutcome(message, record) {
147
147
  requestId: requireSafeIdentity(record.requestId, "Message control requestId"),
148
148
  receiptId: requireText(record.receiptId, "Message control receiptId"),
149
149
  outcome: record.outcome,
150
+ ...(existing?.target ?? record.target ? { target: existing?.target ?? record.target } : {}),
150
151
  observedAt: record.observedAt.toISOString()
151
152
  }
152
153
  };
@@ -282,6 +283,7 @@ export function validateTaskMessage(message) {
282
283
  }
283
284
  }
284
285
  if (message.control !== undefined) {
286
+ validateControlTarget(message.control);
285
287
  requireSafeIdentity(message.control.requestId, "Message control requestId");
286
288
  requireText(message.control.receiptId, "Message control receiptId");
287
289
  if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)) {
@@ -408,6 +410,7 @@ export function validateGlobalRoleMessage(message) {
408
410
  requireText(message.deliveryTarget.nativeSessionId, "Global input Session");
409
411
  }
410
412
  if (message.control !== undefined) {
413
+ validateControlTarget(message.control);
411
414
  requireSafeIdentity(message.control.requestId, "Global input requestId");
412
415
  requireText(message.control.receiptId, "Global input receiptId");
413
416
  if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)
@@ -479,6 +482,14 @@ export function validateGlobalRoleMessage(message) {
479
482
  throw new Error("Message createdAt is invalid.");
480
483
  }
481
484
  }
485
+ function validateControlTarget(control) {
486
+ if (control.target === undefined)
487
+ return;
488
+ requireKnownFields(control.target, ["agentId", "nativeSessionId", "attemptId"], "Control target");
489
+ requireText(control.target.agentId, "Control target Agent");
490
+ requireText(control.target.nativeSessionId, "Control target Session");
491
+ requireText(control.target.attemptId, "Control target attempt");
492
+ }
482
493
  /** A Global message local id is `global-message-<n>`; it carries no Task id. */
483
494
  export function validateGlobalRoleMessageId(localId) {
484
495
  const normalized = requireSafeIdentity(localId, "Global message id");
@@ -26,10 +26,15 @@ export function isLeaderSteer(message) {
26
26
  export function taskMessageInboxReceipt(store, message) {
27
27
  return message.inbox ?? messageRevisionWakes(store, message).find(wake => wake.inbox !== undefined)?.inbox;
28
28
  }
29
- /** Delivery suppresses repeat notification of that revision, but remains
30
- * distinct from handling. The original fixed window stays in the own inbox. */
31
- export function deliveredMessageWake(store, message) {
32
- return messageRevisionWakes(store, message).find(wake => wake.status === "consumed");
29
+ /** Acceptance or explicit no-replay release suppresses repeat notification,
30
+ * never handling. The original fixed window stays in the own inbox. */
31
+ export function nonReplayableMessageWake(store, message) {
32
+ return messageRevisionWakes(store, message).find(wake => wake.status === "consumed" || releasedNotificationWake(store, message.taskId, wake.id));
33
+ }
34
+ /** Operational release is not acceptance. Keep its originals out of automatic
35
+ * delivery while permitting explicit inspection and settlement in the inbox. */
36
+ export function releasedNotificationWake(store, taskId, wakeId) {
37
+ return store.listEventsByType(taskId, ["notification.resolved"]).some(event => event.payload.wakeId === wakeId && event.payload.outcome === "released-without-replay");
33
38
  }
34
39
  function messageRevisionWakes(store, message) {
35
40
  const revision = latestMessageUpdate(store, message);
@@ -37,7 +42,16 @@ function messageRevisionWakes(store, message) {
37
42
  && wake.refs?.some(ref => ref.type === "message" && ref.taskId === message.taskId && ref.id === message.id));
38
43
  }
39
44
  export function unfinishedLeaderInbox(store, taskId) {
40
- return store.listTaskWakes(taskId).some(wake => wake.inbox !== undefined && wake.inbox.ack === undefined)
45
+ const sessions = store.getTaskRoleSessionSet(taskId, "leader");
46
+ const binding = sessions?.providerBinding;
47
+ const turn = binding?.run;
48
+ const nativeSessionId = binding?.conversations.find(c => c.epoch === binding.currentConversationEpoch)?.conversationId;
49
+ const active = (receipt) => receipt !== undefined && receipt.ack === undefined
50
+ && (receipt.claim.actor.attemptId === undefined || turn?.attemptId === receipt.claim.actor.attemptId
51
+ && receipt.claim.actor.agentId === sessions?.activeAgentId
52
+ && receipt.claim.actor.nativeSessionId === nativeSessionId
53
+ && ["submitting", "accepted", "delivery-unknown"].includes(turn.status));
54
+ return store.listTaskWakes(taskId).some(wake => active(wake.inbox))
41
55
  || operationalTaskRecords(store.listMessages(taskId), store.listEvents(taskId), "message").some(message => isLeaderSteer(message)
42
- && message.inbox !== undefined && message.inbox.ack === undefined);
56
+ && active(message.inbox));
43
57
  }