@zq-silk/yui 0.6.12 → 0.6.14

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 (93) hide show
  1. package/README.md +34 -5
  2. package/dist/cli/commandCatalog.js +173 -57
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli.js +99 -11
  5. package/dist/commands/configCommands.js +521 -171
  6. package/dist/commands/deliveryGuardPreflight.js +2 -2
  7. package/dist/commands/executionAuditCommands.js +56 -3
  8. package/dist/commands/projectCommands.js +504 -2
  9. package/dist/commands/releaseCommands.js +0 -1
  10. package/dist/commands/taskActor.js +17 -0
  11. package/dist/commands/taskBaseCommands.js +29 -0
  12. package/dist/commands/taskCommands.js +577 -126
  13. package/dist/commands/taskContextCommand.js +36 -2
  14. package/dist/commands/taskNextActionCommand.js +48 -3
  15. package/dist/commands/taskPublicationCommands.js +319 -0
  16. package/dist/commands/taskRoleRuntimeStatus.js +83 -59
  17. package/dist/commands/telemetryCommands.js +14 -13
  18. package/dist/config/yuiConfig.js +161 -8
  19. package/dist/context/sessionContextBudget.js +68 -0
  20. package/dist/context/wakeNotification.js +65 -0
  21. package/dist/controller/clientRuntime.js +2 -1
  22. package/dist/controller/ephemeralResourceReaper.js +2 -1
  23. package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
  24. package/dist/controller/jobSupervisor.js +5 -4
  25. package/dist/controller/resourceCleanupLinux.js +6 -6
  26. package/dist/controller/resourceInventoryLinux.js +3 -3
  27. package/dist/controller/runtime.js +88 -10
  28. package/dist/controller/updateReconciliation.js +4 -3
  29. package/dist/doctor/doctor.js +26 -7
  30. package/dist/executor/agentConfigurationCatalog.js +18 -0
  31. package/dist/executor/fileRoleLaunchPlanner.js +3 -3
  32. package/dist/lifecycle/contextBudgetRollover.js +81 -0
  33. package/dist/lifecycle/exactRunTerminalization.js +11 -1
  34. package/dist/lifecycle/providerErrorClass.js +33 -12
  35. package/dist/observability/executionAudit.js +214 -6
  36. package/dist/output/table.js +18 -0
  37. package/dist/repository/gitWorkspace.js +92 -0
  38. package/dist/repository/project.js +218 -4
  39. package/dist/repository/taskBaseFreshness.js +318 -0
  40. package/dist/repository/taskWorkspacePreparer.js +16 -2
  41. package/dist/review/deltaRecheck.js +232 -0
  42. package/dist/review/reviewConfig.js +31 -0
  43. package/dist/review/reviewFindingLedger.js +5 -1
  44. package/dist/review/reviewRound.js +156 -1
  45. package/dist/run/providerRetry.js +21 -3
  46. package/dist/run/providerRetryConfig.js +13 -60
  47. package/dist/run/recoveryProjection.js +199 -0
  48. package/dist/runtime/builtinAgentDrivers.js +3 -0
  49. package/dist/runtime/builtinTranscriptUsage.js +76 -32
  50. package/dist/runtime/continuationManager.js +17 -0
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/launchDiagnostics.js +154 -0
  53. package/dist/runtime/lifecycleReservation.js +13 -0
  54. package/dist/runtime/providerContinuation.js +38 -0
  55. package/dist/runtime/providerContinuationReconciliationService.js +1 -0
  56. package/dist/runtime/providerErrorCodes.js +278 -0
  57. package/dist/runtime/runtimeHealthPolicy.js +20 -0
  58. package/dist/runtime/runtimeObservation.js +1 -0
  59. package/dist/runtime/runtimeProjection.js +115 -23
  60. package/dist/runtime/tmuxAdapters.js +242 -48
  61. package/dist/scheduler/activeRoleRunDelivery.js +139 -3
  62. package/dist/scheduler/activeTaskProgress.js +4 -3
  63. package/dist/scheduler/leaderWakeupProcessor.js +105 -15
  64. package/dist/scheduler/roleRunLiveness.js +2 -1
  65. package/dist/scheduler/roleRunStall.js +3 -2
  66. package/dist/scheduler/taskWake.js +72 -0
  67. package/dist/scheduler/wakeReason.js +64 -0
  68. package/dist/scheduler/wakeupQueue.js +2 -1
  69. package/dist/setup/setupCommand.js +1 -1
  70. package/dist/storage/migration/productionRegistry.js +325 -1
  71. package/dist/storage/sqliteSchema.js +61 -2
  72. package/dist/storage/sqliteStore.js +129 -2
  73. package/dist/storage/storeRpc.js +1 -0
  74. package/dist/storage/taskStore.js +262 -5
  75. package/dist/storage/upgrade/recordVersions.js +6 -1
  76. package/dist/storage/upgrade/sqliteFileSet.js +50 -0
  77. package/dist/storage/upgrade/sqliteMigrationTarget.js +6 -4
  78. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +6 -4
  79. package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
  80. package/dist/task/completionReadiness.js +282 -0
  81. package/dist/task/publicationReference.js +123 -0
  82. package/dist/task/taskRecordReference.js +3 -1
  83. package/dist/telemetry/telemetryConfig.js +23 -18
  84. package/dist/telemetry/telemetryWiring.js +8 -8
  85. package/dist/tmux/tmuxManager.js +50 -9
  86. package/dist/web/assets/client/i18n.js +4 -0
  87. package/dist/web/assets/client/view.js +18 -0
  88. package/dist/web/webSnapshot.js +100 -10
  89. package/i18n/README.zh-CN.md +5 -5
  90. package/package.json +1 -1
  91. package/skills/yui-leader/SKILL.md +49 -10
  92. package/skills/yui-operator/SKILL.md +13 -3
  93. package/skills/yui-worker/SKILL.md +8 -0
@@ -189,11 +189,13 @@ function asStoredTask(value) {
189
189
  decisions: asObjectMap(record.decisions),
190
190
  milestones: asObjectMap(record.milestones),
191
191
  events: asObjectMap(record.events),
192
+ wakes: asObjectMap(record.wakes),
192
193
  leaderFailure: asNullableObject(record.leaderFailure),
193
194
  operatorNotification: asNullableObject(record.operatorNotification),
194
195
  idHighWaterMarks: asObject(record.idHighWaterMarks),
195
196
  capabilityGrants: asObjectMap(record.capabilityGrants),
196
- releaseWorkflows: asObjectMap(record.releaseWorkflows)
197
+ releaseWorkflows: asObjectMap(record.releaseWorkflows),
198
+ publicationReferences: asObjectMap(record.publicationReferences)
197
199
  };
198
200
  }
199
201
  function tasksOf(state) {
@@ -335,6 +337,9 @@ export function populateSqliteFromState(home, state, databaseFilename) {
335
337
  for (const event of Object.values(stored.events)) {
336
338
  store.saveEvent(taskId, event);
337
339
  }
340
+ for (const wake of Object.values(stored.wakes)) {
341
+ store.saveTaskWake(taskId, wake);
342
+ }
338
343
  if (stored.leaderFailure !== null) {
339
344
  store.saveLeaderFailure(stored.leaderFailure);
340
345
  }
@@ -354,6 +359,9 @@ export function populateSqliteFromState(home, state, databaseFilename) {
354
359
  for (const workflow of Object.values(stored.releaseWorkflows)) {
355
360
  store.saveReleaseWorkflow(taskId, workflow);
356
361
  }
362
+ for (const reference of Object.values(stored.publicationReferences)) {
363
+ store.savePublicationReference(taskId, reference);
364
+ }
357
365
  }
358
366
  // Work mailboxes.
359
367
  for (const mailbox of Object.values(asObjectMap(state.mailboxes))) {
@@ -456,10 +464,12 @@ export function computeStateFamilyChecksums(state) {
456
464
  const decisions = [];
457
465
  const milestones = [];
458
466
  const events = [];
467
+ const wakes = [];
459
468
  const leaderFailures = [];
460
469
  const operatorNotifications = [];
461
470
  const capabilityGrants = [];
462
471
  const releaseWorkflows = [];
472
+ const publicationReferences = [];
463
473
  for (const stored of Object.values(tasks)) {
464
474
  taskRecords.push(stored.task);
465
475
  if (stored.brief !== null)
@@ -492,12 +502,14 @@ export function computeStateFamilyChecksums(state) {
492
502
  decisions.push(...Object.values(stored.decisions));
493
503
  milestones.push(...Object.values(stored.milestones));
494
504
  events.push(...Object.values(stored.events));
505
+ wakes.push(...Object.values(stored.wakes));
495
506
  if (stored.leaderFailure !== null)
496
507
  leaderFailures.push(stored.leaderFailure);
497
508
  if (stored.operatorNotification !== null)
498
509
  operatorNotifications.push(stored.operatorNotification);
499
510
  capabilityGrants.push(...Object.values(stored.capabilityGrants));
500
511
  releaseWorkflows.push(...Object.values(stored.releaseWorkflows));
512
+ publicationReferences.push(...Object.values(stored.publicationReferences));
501
513
  }
502
514
  checksums.task = hashRecords(taskRecords);
503
515
  checksums.taskBrief = hashRecords(briefs);
@@ -518,10 +530,12 @@ export function computeStateFamilyChecksums(state) {
518
530
  checksums.decision = hashRecords(decisions);
519
531
  checksums.milestone = hashRecords(milestones);
520
532
  checksums.event = hashRecords(events);
533
+ checksums.taskWake = hashRecords(wakes);
521
534
  checksums.leaderFailure = hashRecords(leaderFailures);
522
535
  checksums.operatorNotification = hashRecords(operatorNotifications);
523
536
  checksums.capabilityGrant = hashRecords(capabilityGrants);
524
537
  checksums.releaseWorkflow = hashRecords(releaseWorkflows);
538
+ checksums.publicationReference = hashRecords(publicationReferences);
525
539
  checksums.workMailbox = hashRecords(Object.values(asObjectMap(state.mailboxes)));
526
540
  return checksums;
527
541
  }
@@ -609,10 +623,12 @@ export function computeDbFamilyChecksums(home, databaseFilename) {
609
623
  checksums.decision = hashPayloadTable(db, "SELECT payload FROM decisions");
610
624
  checksums.milestone = hashPayloadTable(db, "SELECT payload FROM milestones");
611
625
  checksums.event = hashPayloadTable(db, "SELECT payload FROM events");
626
+ checksums.taskWake = hashPayloadTable(db, "SELECT payload FROM task_wakes");
612
627
  checksums.leaderFailure = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'leader-failure' AND payload IS NOT NULL");
613
628
  checksums.operatorNotification = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'operator-notification' AND payload IS NOT NULL");
614
629
  checksums.capabilityGrant = hashPayloadTable(db, "SELECT payload FROM capability_grants");
615
630
  checksums.releaseWorkflow = hashPayloadTable(db, "SELECT payload FROM release_workflows");
631
+ checksums.publicationReference = hashPayloadTable(db, "SELECT payload FROM publication_references");
616
632
  // Mailboxes are reconstructed from typed columns (no payload column).
617
633
  const mailboxRows = readWorkMailboxRows(db);
618
634
  checksums.workMailbox = hashRecords(mailboxRows.map(rowToMailbox));
@@ -730,11 +746,13 @@ export function readStateFromSqlite(home) {
730
746
  decisions: {},
731
747
  milestones: {},
732
748
  events: {},
749
+ wakes: {},
733
750
  leaderFailure: null,
734
751
  operatorNotification: null,
735
752
  idHighWaterMarks: {},
736
753
  capabilityGrants: {},
737
- releaseWorkflows: {}
754
+ releaseWorkflows: {},
755
+ publicationReferences: {}
738
756
  };
739
757
  }
740
758
  // Per-task record families, keyed by their state.json map keys.
@@ -753,8 +771,10 @@ export function readStateFromSqlite(home) {
753
771
  loadTaskPayloadMap(db, "decisions", tasks, "decisions", (record) => record.id);
754
772
  loadTaskPayloadMap(db, "milestones", tasks, "milestones", (record) => record.id);
755
773
  loadTaskPayloadMap(db, "events", tasks, "events", (record) => record.id);
774
+ loadTaskPayloadMap(db, "task_wakes", tasks, "wakes", (record) => record.id);
756
775
  loadTaskPayloadMap(db, "capability_grants", tasks, "capabilityGrants", (record) => record.id);
757
776
  loadTaskPayloadMap(db, "release_workflows", tasks, "releaseWorkflows", (record) => record.id);
777
+ loadTaskPayloadMap(db, "publication_references", tasks, "publicationReferences", (record) => record.id);
758
778
  // Job caller key hashes: keyed by `${roleName}\0${agentId}`.
759
779
  const callerHashRows = db.prepare("SELECT task_id, role_name, agent_id, hash FROM job_caller_key_hashes").all();
760
780
  for (const row of callerHashRows) {
@@ -0,0 +1,282 @@
1
+ import { isReviewFindingBlocking } from "../review/reviewFinding.js";
2
+ import { deltaRecheckBlocksAcceptance } from "../review/reviewRound.js";
3
+ import { reviewFindingLedgerWriteFailedFromEvents } from "../review/reviewFindingLedger.js";
4
+ import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
5
+ const ACTIVE_JOB_STATUSES = new Set([
6
+ "queued",
7
+ "running",
8
+ "unknown-needs-attention"
9
+ ]);
10
+ const UNRESOLVED_INTEGRATION_STATUSES = new Set([
11
+ "running",
12
+ "blocked",
13
+ "validating"
14
+ ]);
15
+ const TERMINAL_REVIEW_STATUSES = new Set(["completed", "failed"]);
16
+ const TERMINAL_LANE_STATUSES = new Set(["completed", "failed", "yielded"]);
17
+ export function projectCompletionReadiness(facts, options = {}) {
18
+ const blockers = [];
19
+ const { task } = facts;
20
+ const findingsGate = options.findingsGate ?? true;
21
+ // A pending/running Task-final Review must be resumed or blocked first.
22
+ for (const round of facts.reviewRounds) {
23
+ if ((round.scope ?? "work-item") !== "task")
24
+ continue;
25
+ if (round.status !== "pending" && round.status !== "running")
26
+ continue;
27
+ blockers.push({
28
+ code: "active-task-review",
29
+ ref: ref("review-round", round.id),
30
+ reason: `Task-final ReviewRound ${round.id} is ${round.status}.`,
31
+ fix: round.status === "pending"
32
+ ? `yui task review retry ${task.id}/${round.id}`
33
+ : `wait for Reviewer Run on ${round.id} to finish`
34
+ });
35
+ }
36
+ // Issue 07: a completed delta-recheck that did not accept the head keeps the
37
+ // completion gate closed. `requires-full-review` escalates to a full Review;
38
+ // `finding` stays a blocker for the Leader to repair.
39
+ for (const round of facts.reviewRounds) {
40
+ if (!deltaRecheckBlocksAcceptance(round))
41
+ continue;
42
+ const disposition = round.deltaRecheck.disposition;
43
+ blockers.push({
44
+ code: "delta-recheck-not-accepted",
45
+ ref: ref("review-round", round.id),
46
+ reason: `Delta-recheck ${round.id} disposition is ${disposition}; the head is not accepted.`,
47
+ fix: disposition === "requires-full-review"
48
+ ? `yui task review request ${task.id} --role <global-role>`
49
+ : `repair the finding and request a new Task-final Review`
50
+ });
51
+ }
52
+ // Open Input requests block protocol convergence.
53
+ for (const request of facts.openInputRequests) {
54
+ blockers.push({
55
+ code: "open-input-request",
56
+ ref: ref("input-request", request.id),
57
+ reason: `Input request ${request.id} is open.`,
58
+ fix: `yui task input answer ${task.id}/${request.id}`
59
+ });
60
+ }
61
+ // Every Work Item must be terminal (completed or retired).
62
+ for (const item of facts.workItems) {
63
+ if (item.status === "completed" || item.status === "retired")
64
+ continue;
65
+ blockers.push({
66
+ code: "incomplete-work-item",
67
+ ref: ref("work-item", item.id),
68
+ reason: `Work Item ${item.id} is ${item.status}.`,
69
+ fix: item.status === "failed"
70
+ ? `yui task work update ${task.id}/${item.id} running`
71
+ : `complete or retire Work Item ${item.id}`
72
+ });
73
+ }
74
+ // Active DurableJobs (integration checks, etc.) must settle.
75
+ for (const job of facts.durableJobs) {
76
+ if (!ACTIVE_JOB_STATUSES.has(job.status))
77
+ continue;
78
+ if (job.status === "unknown-needs-attention" && job.acknowledgedAt !== undefined)
79
+ continue;
80
+ blockers.push({
81
+ code: "active-durable-job",
82
+ ref: ref("durable-job", job.id),
83
+ reason: `DurableJob ${job.id} is ${job.status}.`,
84
+ fix: `wait for DurableJob ${job.id} to finish, or cancel it`
85
+ });
86
+ }
87
+ // requireIntegration Tasks need the full evidence chain.
88
+ if (task.requireIntegration === true) {
89
+ if (facts.workItems.length === 0) {
90
+ blockers.push({
91
+ code: "integration-evidence-missing",
92
+ ref: ref("task", task.id),
93
+ reason: `Task ${task.id} requires at least one WorkItem before completion.`,
94
+ fix: `yui task work create ${task.id} "<objective>"`
95
+ });
96
+ }
97
+ if (facts.changeSets.length === 0) {
98
+ blockers.push({
99
+ code: "integration-evidence-missing",
100
+ ref: ref("task", task.id),
101
+ reason: `Task ${task.id} requires at least one ChangeSet before completion.`,
102
+ fix: `yui task work capture ${task.id}/<work-item>`
103
+ });
104
+ }
105
+ if (!facts.integrations.some(({ status }) => status === "committed")) {
106
+ blockers.push({
107
+ code: "integration-evidence-missing",
108
+ ref: ref("task", task.id),
109
+ reason: `Task ${task.id} requires a committed Integration Attempt before completion.`,
110
+ fix: `yui task integration start ${task.id} --project <project> --change-set <change-set>`
111
+ });
112
+ }
113
+ }
114
+ // Unresolved Integration Attempts must settle.
115
+ for (const integration of facts.integrations) {
116
+ if (!UNRESOLVED_INTEGRATION_STATUSES.has(integration.status))
117
+ continue;
118
+ blockers.push({
119
+ code: "unresolved-integration",
120
+ ref: ref("integration-attempt", integration.id),
121
+ reason: `Integration Attempt ${integration.id} is ${integration.status}.`,
122
+ fix: `yui task integration continue ${task.id}/${integration.id}`
123
+ });
124
+ }
125
+ // Unsettled integration queue entries must commit or be superseded.
126
+ for (const entry of facts.integrationQueueEntries) {
127
+ if (entry.status === "committed" || entry.status === "superseded")
128
+ continue;
129
+ blockers.push({
130
+ code: "unsettled-integration-queue-entry",
131
+ ref: ref("integration-queue-entry", entry.id),
132
+ reason: `Integration queue entry ${entry.id} is ${entry.status}.`,
133
+ fix: `settle integration queue entry ${entry.id} (continue or supersede)`
134
+ });
135
+ }
136
+ // Provider continuations that may still write the Workspace.
137
+ for (const continuation of blockingProviderContinuations(facts.events)) {
138
+ const identity = continuation.identity;
139
+ blockers.push({
140
+ code: "blocking-provider-continuation",
141
+ ref: ref("provider-continuation", identity.continuationId),
142
+ reason: `Provider continuation ${identity.continuationId} (run ${continuation.runId}) `
143
+ + "may still write the Workspace or has an identity conflict.",
144
+ fix: `wait for or recover the Provider turn on run ${continuation.runId}`
145
+ });
146
+ }
147
+ // Undisposed managed workspaces. The Task-owned main workspace is excluded
148
+ // (it is cleaned at archive, not at completion).
149
+ for (const workspace of facts.managedWorkspaces) {
150
+ const blocker = workspaceBlocker(facts, task.id, workspace);
151
+ if (blocker !== null)
152
+ blockers.push(blocker);
153
+ }
154
+ // Active non-Leader Runs must finish. The Leader's own Run is terminalized
155
+ // by the completion transaction itself, so it is not a readiness blocker.
156
+ for (const run of facts.activeRuns) {
157
+ if (run.roleName === "leader")
158
+ continue;
159
+ blockers.push({
160
+ code: "active-run",
161
+ ref: ref("agent-run", run.id),
162
+ reason: `Role ${run.roleName} has an active Run ${run.id}.`,
163
+ fix: `wait for Run ${run.id} to yield or fail`
164
+ });
165
+ }
166
+ // Review finding ledger gate (enforce mode only).
167
+ // The transactional completion path runs this gate after final-review
168
+ // preparation: a pending/running Task-final Review is expected to resolve
169
+ // `fixed-pending-review` findings, so the gate only blocks when no Review
170
+ // is active. Mirror that timing here so the projection never blocks a
171
+ // completion attempt that is about to request the resolving Review.
172
+ const activeTaskReview = facts.reviewRounds.some((round) => ((round.scope ?? "work-item") === "task"
173
+ && (round.status === "pending" || round.status === "running")));
174
+ if (findingsGate && facts.reviewFindingLedgerMode === "enforce" && !activeTaskReview) {
175
+ if (reviewFindingLedgerWriteFailedFromEvents(facts.events)) {
176
+ blockers.push({
177
+ code: "finding-ledger-unavailable",
178
+ ref: ref("task", task.id),
179
+ reason: "The Review finding ledger was unavailable while reconciling a semantic Review.",
180
+ fix: "recover the ledger and reconcile the Round before completing the Task"
181
+ });
182
+ }
183
+ for (const finding of facts.reviewFindings) {
184
+ if (!isReviewFindingBlocking(finding))
185
+ continue;
186
+ blockers.push({
187
+ code: "open-review-finding",
188
+ ref: ref("review-finding", finding.id),
189
+ reason: `Open ${finding.severity.toUpperCase()} finding ${finding.id} is undispositioned.`,
190
+ fix: `yui task review finding dispose ${task.id}/${finding.id}`
191
+ });
192
+ }
193
+ }
194
+ const sorted = [...blockers].sort((left, right) => {
195
+ const codeOrder = left.code.localeCompare(right.code);
196
+ if (codeOrder !== 0)
197
+ return codeOrder;
198
+ const kindOrder = left.ref.kind.localeCompare(right.ref.kind);
199
+ if (kindOrder !== 0)
200
+ return kindOrder;
201
+ return left.ref.id.localeCompare(right.ref.id, undefined, { numeric: true });
202
+ });
203
+ return { taskId: task.id, ready: sorted.length === 0, blockers: sorted };
204
+ }
205
+ function workspaceBlocker(facts, taskId, workspace) {
206
+ const owner = workspace.owner;
207
+ switch (owner.type) {
208
+ case "task":
209
+ // The Task main workspace is cleaned at archive, not completion.
210
+ return null;
211
+ case "work-item": {
212
+ // The preflight has always blocked on WorkItem workspaces. Project the
213
+ // blocker regardless of the Work Item status: if the item is still open
214
+ // the incomplete-work-item blocker fires too, and once it is terminal
215
+ // the workspace is the exact cleanup target.
216
+ return {
217
+ code: "work-item-workspace-undisposed",
218
+ ref: ref("work-item", owner.workItemId),
219
+ reason: `Work Item ${owner.workItemId} has an isolated workspace that is not disposed.`,
220
+ fix: `yui task work cleanup ${taskId}/${owner.workItemId} --integrated|--abandon`
221
+ };
222
+ }
223
+ case "review-round": {
224
+ const round = facts.reviewRounds.find((entry) => entry.id === owner.reviewRoundId);
225
+ // A workspace for a still-active Review is covered by the
226
+ // active-task-review / wait-for-owned-execution path; only project the
227
+ // cleanup blocker once the Round is terminal.
228
+ if (round !== undefined && !TERMINAL_REVIEW_STATUSES.has(round.status))
229
+ return null;
230
+ return {
231
+ code: "review-workspace-undisposed",
232
+ ref: ref("review-round", owner.reviewRoundId),
233
+ reason: `ReviewRound ${owner.reviewRoundId} is terminal but its workspace is not cleaned up.`,
234
+ fix: `yui task work review cleanup ${taskId}/${owner.reviewRoundId}`
235
+ };
236
+ }
237
+ case "integration-attempt": {
238
+ const integration = facts.integrations.find((entry) => entry.id === owner.integrationAttemptId);
239
+ if (integration !== undefined && UNRESOLVED_INTEGRATION_STATUSES.has(integration.status)) {
240
+ return null;
241
+ }
242
+ return {
243
+ code: "integration-workspace-undisposed",
244
+ ref: ref("integration-attempt", owner.integrationAttemptId),
245
+ reason: `Integration Attempt ${owner.integrationAttemptId} is terminal but its workspace is not cleaned up.`,
246
+ fix: `retry or continue Integration ${owner.integrationAttemptId} so its workspace is reclaimed`
247
+ };
248
+ }
249
+ case "execution-lane": {
250
+ const lane = findExecutionLane(facts, owner.executionGroupId, owner.executionLaneId);
251
+ if (lane !== undefined && !TERMINAL_LANE_STATUSES.has(lane.status))
252
+ return null;
253
+ return {
254
+ code: "execution-lane-workspace-undisposed",
255
+ ref: ref("execution-lane", `${owner.executionGroupId}/${owner.executionLaneId}`),
256
+ reason: `Execution Lane ${owner.executionGroupId}/${owner.executionLaneId} `
257
+ + "is terminal but its workspace is not cleaned up.",
258
+ fix: `clean up Execution Lane ${owner.executionGroupId}/${owner.executionLaneId}`
259
+ };
260
+ }
261
+ }
262
+ }
263
+ function findExecutionLane(facts, groupId, laneId) {
264
+ for (const item of facts.workItems) {
265
+ const group = item.executionGroups?.find((entry) => entry.id === groupId);
266
+ const lane = group?.lanes.find((entry) => entry.id === laneId);
267
+ if (lane !== undefined)
268
+ return lane;
269
+ }
270
+ for (const round of facts.reviewRounds) {
271
+ const group = round.executionGroup;
272
+ if (group?.id !== groupId)
273
+ continue;
274
+ const lane = group.lanes.find((entry) => entry.id === laneId);
275
+ if (lane !== undefined)
276
+ return lane;
277
+ }
278
+ return undefined;
279
+ }
280
+ function ref(kind, id) {
281
+ return { kind, id };
282
+ }
@@ -0,0 +1,123 @@
1
+ import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
2
+ import { validateTaskRecordReference } from "./taskRecordReference.js";
3
+ export const PUBLICATION_REFERENCE_SCHEMA_VERSION = 1;
4
+ const PROVIDERS = new Set(["github", "gitlab"]);
5
+ const EXTERNAL_KINDS = new Set(["pull-request", "merge-request"]);
6
+ const STATES = new Set(["open", "merged", "closed"]);
7
+ const VERIFICATIONS = new Set(["reported", "verified"]);
8
+ const RECORDED_BY = new Set(["user", "operator", "leader"]);
9
+ const SOURCES = new Set(["manual", "release-workflow", "integration"]);
10
+ const GIT_SHA = /^[0-9a-f]{40}$/iu;
11
+ export function createPublicationReference(id, taskId, input, now) {
12
+ const reference = validateTaskRecordReference({ taskId, localId: id }, "publicationReference");
13
+ return validatePublicationReference({
14
+ schemaVersion: PUBLICATION_REFERENCE_SCHEMA_VERSION,
15
+ id: reference.localId,
16
+ taskId: reference.taskId,
17
+ projectId: requireIdentity(input.projectId, "Publication Project id"),
18
+ provider: requireEnum(input.provider, PROVIDERS, "Publication provider"),
19
+ repository: requireText(input.repository, "Publication repository"),
20
+ externalKind: requireEnum(input.externalKind, EXTERNAL_KINDS, "Publication external kind"),
21
+ externalId: requireText(input.externalId, "Publication external id"),
22
+ ...(input.externalUrl === undefined ? {} : {
23
+ externalUrl: requireText(input.externalUrl, "Publication external URL")
24
+ }),
25
+ ...(input.title === undefined ? {} : {
26
+ title: requireText(input.title, "Publication title")
27
+ }),
28
+ ...(input.sourceBranch === undefined ? {} : {
29
+ sourceBranch: requireText(input.sourceBranch, "Publication source branch")
30
+ }),
31
+ ...(input.targetBranch === undefined ? {} : {
32
+ targetBranch: requireText(input.targetBranch, "Publication target branch")
33
+ }),
34
+ ...(input.localCommit === undefined ? {} : {
35
+ localCommit: requireCommit(input.localCommit, "Publication local commit")
36
+ }),
37
+ ...(input.remoteCommit === undefined ? {} : {
38
+ remoteCommit: requireCommit(input.remoteCommit, "Publication remote commit")
39
+ }),
40
+ state: requireEnum(input.state ?? "open", STATES, "Publication state"),
41
+ verification: requireEnum(input.verification ?? "reported", VERIFICATIONS, "Publication verification"),
42
+ ...(input.evidence === undefined ? {} : {
43
+ evidence: requireText(input.evidence, "Publication evidence")
44
+ }),
45
+ ...(input.supersedes === undefined ? {} : {
46
+ supersedes: requireText(input.supersedes, "Publication supersedes id")
47
+ }),
48
+ recordedBy: requireEnum(input.recordedBy ?? "user", RECORDED_BY, "Publication recordedBy"),
49
+ source: requireEnum(input.source ?? "manual", SOURCES, "Publication source"),
50
+ ...(input.mergedAt === undefined ? {} : {
51
+ mergedAt: requireTimestamp(input.mergedAt, "Publication mergedAt")
52
+ }),
53
+ createdAt: now.toISOString()
54
+ });
55
+ }
56
+ export function publicationExternalKey(reference) {
57
+ return `${reference.provider}/${reference.repository}/${reference.externalId}`;
58
+ }
59
+ export function validatePublicationReference(reference) {
60
+ if (reference.schemaVersion !== PUBLICATION_REFERENCE_SCHEMA_VERSION) {
61
+ throw new Error("Publication reference must use schemaVersion 1.");
62
+ }
63
+ validateTaskRecordReference({ taskId: reference.taskId, localId: reference.id }, "publicationReference");
64
+ requireIdentity(reference.projectId, "Publication Project id");
65
+ requireEnum(reference.provider, PROVIDERS, "Publication provider");
66
+ requireText(reference.repository, "Publication repository");
67
+ requireEnum(reference.externalKind, EXTERNAL_KINDS, "Publication external kind");
68
+ requireText(reference.externalId, "Publication external id");
69
+ if (reference.externalUrl !== undefined) {
70
+ requireText(reference.externalUrl, "Publication external URL");
71
+ }
72
+ if (reference.title !== undefined) {
73
+ requireText(reference.title, "Publication title");
74
+ }
75
+ if (reference.sourceBranch !== undefined) {
76
+ requireText(reference.sourceBranch, "Publication source branch");
77
+ }
78
+ if (reference.targetBranch !== undefined) {
79
+ requireText(reference.targetBranch, "Publication target branch");
80
+ }
81
+ if (reference.localCommit !== undefined) {
82
+ requireCommit(reference.localCommit, "Publication local commit");
83
+ }
84
+ if (reference.remoteCommit !== undefined) {
85
+ requireCommit(reference.remoteCommit, "Publication remote commit");
86
+ }
87
+ requireEnum(reference.state, STATES, "Publication state");
88
+ requireEnum(reference.verification, VERIFICATIONS, "Publication verification");
89
+ if (reference.verification === "verified"
90
+ && (reference.state !== "merged" || reference.remoteCommit === undefined)) {
91
+ throw new Error("Verified publication requires state merged and a remote commit.");
92
+ }
93
+ if (reference.mergedAt !== undefined && reference.state !== "merged") {
94
+ throw new Error("Publication mergedAt requires state merged.");
95
+ }
96
+ if (reference.evidence !== undefined) {
97
+ requireText(reference.evidence, "Publication evidence");
98
+ }
99
+ if (reference.supersedes !== undefined) {
100
+ requireText(reference.supersedes, "Publication supersedes id");
101
+ if (reference.supersedes === reference.id) {
102
+ throw new Error("Publication reference cannot supersede itself.");
103
+ }
104
+ }
105
+ requireEnum(reference.recordedBy, RECORDED_BY, "Publication recordedBy");
106
+ requireEnum(reference.source, SOURCES, "Publication source");
107
+ requireTimestamp(reference.createdAt, "Publication createdAt");
108
+ return reference;
109
+ }
110
+ function requireEnum(value, allowed, label) {
111
+ const normalized = requireText(value, label);
112
+ if (!allowed.has(normalized)) {
113
+ throw new Error(`${label} is invalid: ${normalized}.`);
114
+ }
115
+ return normalized;
116
+ }
117
+ function requireCommit(value, label) {
118
+ const commit = requireText(value, label);
119
+ if (!GIT_SHA.test(commit)) {
120
+ throw new Error(`${label} must be a full 40-character Git SHA.`);
121
+ }
122
+ return commit.toLowerCase();
123
+ }
@@ -14,7 +14,9 @@ export const TASK_RECORD_ID_PREFIXES = {
14
14
  milestone: "milestone",
15
15
  event: "event",
16
16
  capabilityGrant: "capability-grant",
17
- releaseWorkflow: "release-workflow"
17
+ releaseWorkflow: "release-workflow",
18
+ taskWake: "wake",
19
+ publicationReference: "publication"
18
20
  };
19
21
  export function formatTaskRecordReference(taskId, localId, kind) {
20
22
  const reference = validateTaskRecordReference({ taskId, localId }, kind);
@@ -20,42 +20,47 @@ export const DEFAULT_RUN_CAP = TELEMETRY_RUN_CAP;
20
20
  export const MAX_RUN_CAP = 10_000_000;
21
21
  const TELEMETRY_MODES = ["legacy", "dual", "bounded"];
22
22
  /**
23
- * Resolve `YUI_TELEMETRY_MODE` (default `legacy`). Only the three exact
24
- * values (case-insensitive) are accepted; anything else fails closed at
25
- * startup instead of silently changing diagnostic retention.
23
+ * Resolve the telemetry mode from the durable config value (default `legacy`).
24
+ * Only the three exact values (case-insensitive) are accepted; anything else
25
+ * fails closed at startup instead of silently changing diagnostic retention.
26
26
  */
27
- export function resolveTelemetryMode(env = process.env) {
28
- const raw = env.YUI_TELEMETRY_MODE?.trim().toLowerCase();
27
+ export function resolveTelemetryMode(value) {
28
+ if (typeof value !== "string")
29
+ return DEFAULT_TELEMETRY_MODE;
30
+ const raw = value.trim().toLowerCase();
29
31
  if (raw === undefined || raw === "")
30
32
  return DEFAULT_TELEMETRY_MODE;
31
33
  if (!TELEMETRY_MODES.includes(raw)) {
32
- throw new TypeError(`YUI_TELEMETRY_MODE must be one of ${TELEMETRY_MODES.join(", ")}; got ${JSON.stringify(raw)}.`);
34
+ throw new TypeError(`telemetryMode must be one of ${TELEMETRY_MODES.join(", ")}; got ${JSON.stringify(raw)}.`);
33
35
  }
34
36
  return raw;
35
37
  }
36
38
  /**
37
- * Resolve the terminal-Run retention window (`YUI_TELEMETRY_TERMINAL_KEEP`,
38
- * default 200). Must be a positive integer.
39
+ * Resolve the terminal-Run retention window from the durable config value
40
+ * (default 200). Must be a positive integer.
39
41
  */
40
- export function resolveTerminalKeep(env = process.env) {
41
- return resolvePositiveInteger(env.YUI_TELEMETRY_TERMINAL_KEEP, DEFAULT_TERMINAL_KEEP, "YUI_TELEMETRY_TERMINAL_KEEP");
42
+ export function resolveTerminalKeep(value) {
43
+ return resolvePositiveInteger(value, DEFAULT_TERMINAL_KEEP, "telemetryTerminalKeep");
42
44
  }
43
45
  /**
44
- * Resolve the active-Run hard cap (`YUI_TELEMETRY_RUN_CAP`, default 50,000).
45
- * Must be a positive integer not exceeding {@link MAX_RUN_CAP}; the cap
46
- * cannot be disabled.
46
+ * Resolve the active-Run hard cap from the durable config value (default
47
+ * 50,000). Must be a positive integer not exceeding {@link MAX_RUN_CAP};
48
+ * the cap cannot be disabled.
47
49
  */
48
- export function resolveRunCap(env = process.env) {
49
- const cap = resolvePositiveInteger(env.YUI_TELEMETRY_RUN_CAP, DEFAULT_RUN_CAP, "YUI_TELEMETRY_RUN_CAP");
50
+ export function resolveRunCap(value) {
51
+ const cap = resolvePositiveInteger(value, DEFAULT_RUN_CAP, "telemetryRunCap");
50
52
  if (cap > MAX_RUN_CAP) {
51
- throw new TypeError(`YUI_TELEMETRY_RUN_CAP must not exceed ${MAX_RUN_CAP.toLocaleString("en-US")} (retention cannot be disabled).`);
53
+ throw new TypeError(`telemetryRunCap must not exceed ${MAX_RUN_CAP.toLocaleString("en-US")} (retention cannot be disabled).`);
52
54
  }
53
55
  return cap;
54
56
  }
55
57
  function resolvePositiveInteger(raw, fallback, label) {
56
- if (raw === undefined || raw.trim() === "")
58
+ if (raw === undefined || raw === null)
57
59
  return fallback;
58
- const value = Number(raw);
60
+ if (typeof raw !== "string" && typeof raw !== "number") {
61
+ throw new TypeError(`${label} must be a positive integer; got ${JSON.stringify(raw)}.`);
62
+ }
63
+ const value = typeof raw === "string" ? Number(raw) : raw;
59
64
  if (!Number.isSafeInteger(value) || value < 1) {
60
65
  throw new TypeError(`${label} must be a positive integer; got ${JSON.stringify(raw)}.`);
61
66
  }
@@ -4,9 +4,9 @@ import { resolveRunCap, resolveTelemetryMode, resolveTerminalKeep } from "./tele
4
4
  import { SqliteTelemetryStore } from "./sqliteTelemetryStore.js";
5
5
  import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigration.js";
6
6
  /**
7
- * Open the telemetry sidecar for a Home, resolved from the environment
8
- * (`YUI_TELEMETRY_MODE`, default `legacy`). Returns null in legacy mode so
9
- * callers keep the exact master behavior and never touch the database.
7
+ * Open the telemetry sidecar for a Home, resolved from the durable Yui
8
+ * config (default `legacy`). Returns null in legacy mode so callers keep the
9
+ * exact master behavior and never touch the database.
10
10
  *
11
11
  * Telemetry lives in the Home's authoritative `yui.db`. A Home without a
12
12
  * database has not reached SQLite storage yet, so dual/bounded mode fails
@@ -15,19 +15,19 @@ import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigra
15
15
  * store fails isolated: a broken sidecar only increments its dropped counter
16
16
  * and never blocks the semantic lane.
17
17
  */
18
- export function openSchedulerTelemetry(home, env = process.env) {
19
- const mode = resolveTelemetryMode(env);
18
+ export function openSchedulerTelemetry(home, config) {
19
+ const mode = resolveTelemetryMode(config.telemetryMode);
20
20
  if (mode === "legacy")
21
21
  return null;
22
22
  const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
23
23
  if (!existsSync(dbPath)) {
24
- throw new Error(`YUI_TELEMETRY_MODE=${mode} requires SQLite storage, but ${dbPath} does not exist. `
24
+ throw new Error(`telemetryMode=${mode} requires SQLite storage, but ${dbPath} does not exist. `
25
25
  + "Migrate this Home to the database backend first (yui upgrade).");
26
26
  }
27
27
  const store = new SqliteTelemetryStore(home, {
28
28
  mode,
29
- terminalKeep: resolveTerminalKeep(env),
30
- runCap: resolveRunCap(env)
29
+ terminalKeep: resolveTerminalKeep(config.telemetryTerminalKeep),
30
+ runCap: resolveRunCap(config.telemetryRunCap)
31
31
  });
32
32
  return { mode, sink: store, reader: store };
33
33
  }