@zq-silk/yui 0.6.0 → 0.6.2

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 (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,251 @@
1
+ import { DEFAULT_TERMINAL_KEEP } from "./telemetryConfig.js";
2
+ /**
3
+ * Issue 09 — historical compaction.
4
+ *
5
+ * Progress events that were appended to semantic Task event history (legacy
6
+ * behavior) are folded into the telemetry tables inside the Home's `yui.db`:
7
+ * the newest `keep` rows per Run/generation become the retained window, and
8
+ * every Run/generation gets an accurate aggregate
9
+ * (count/first/last/maxSequence/errorCount). The semantic progress events are
10
+ * then removed through the store's public transaction adapter. Semantic
11
+ * evidence (Run dispatch/delivery/terminal receipts, Session lifecycle,
12
+ * Review, Decision, Integration, ...) is never touched.
13
+ *
14
+ * Compaction runs on a staged copy of a Home. The caller promotes the staged
15
+ * copy only after this module validates the result; a validation failure
16
+ * throws and leaves the original history unchanged.
17
+ */
18
+ export const PROGRESS_EVENT_TYPE = "runtime.provider-turn-progress";
19
+ /**
20
+ * Scan a store and build the compaction plan. Read-only: nothing is modified.
21
+ */
22
+ export function planTelemetryCompaction(store, options = {}) {
23
+ const keep = options.terminalKeep ?? DEFAULT_TERMINAL_KEEP;
24
+ const tasks = [];
25
+ let progressEvents = 0;
26
+ let generations = 0;
27
+ let telemetryRows = 0;
28
+ for (const task of store.listTasks()) {
29
+ const taskPlan = planTaskCompaction(task.id, store.listEvents(task.id), keep);
30
+ if (taskPlan === null)
31
+ continue;
32
+ tasks.push(taskPlan);
33
+ progressEvents += taskPlan.progressEventIds.length;
34
+ generations += taskPlan.generations.length;
35
+ for (const generation of taskPlan.generations)
36
+ telemetryRows += generation.window.length;
37
+ }
38
+ return {
39
+ tasks,
40
+ totals: { tasks: tasks.length, progressEvents, generations, telemetryRows }
41
+ };
42
+ }
43
+ function planTaskCompaction(taskId, events, keep) {
44
+ const byGeneration = new Map();
45
+ const progressEventIds = [];
46
+ let malformedKept = 0;
47
+ for (const event of events) {
48
+ if (event.type !== PROGRESS_EVENT_TYPE)
49
+ continue;
50
+ const runId = event.payload.runId;
51
+ const progressId = event.payload.progressId;
52
+ if (typeof runId !== "string" || typeof progressId !== "string") {
53
+ // Fail closed: an anomalous progress row stays semantic and is reported.
54
+ malformedKept++;
55
+ continue;
56
+ }
57
+ const roleName = typeof event.payload.roleName === "string" ? event.payload.roleName : "unknown";
58
+ const generation = typeof event.payload.launchId === "string" && event.payload.launchId !== ""
59
+ ? event.payload.launchId
60
+ : typeof event.payload.nativeSessionId === "string" && event.payload.nativeSessionId !== ""
61
+ ? event.payload.nativeSessionId
62
+ : "unbound";
63
+ const key = `${roleName}\u0000${runId}\u0000${generation}`;
64
+ const group = byGeneration.get(key);
65
+ if (group === undefined)
66
+ byGeneration.set(key, [event]);
67
+ else
68
+ group.push(event);
69
+ progressEventIds.push(event.id);
70
+ }
71
+ if (progressEventIds.length === 0 && malformedKept === 0)
72
+ return null;
73
+ const generations = [];
74
+ for (const group of byGeneration.values()) {
75
+ generations.push(planGenerationCompaction(taskId, group, keep));
76
+ }
77
+ return { taskId, progressEventIds, malformedKept, generations };
78
+ }
79
+ function planGenerationCompaction(taskId, events, keep) {
80
+ const first = events[0];
81
+ const roleName = typeof first.payload.roleName === "string" ? first.payload.roleName : "unknown";
82
+ const runId = first.payload.runId;
83
+ const generation = typeof first.payload.launchId === "string" && first.payload.launchId !== ""
84
+ ? first.payload.launchId
85
+ : typeof first.payload.nativeSessionId === "string" && first.payload.nativeSessionId !== ""
86
+ ? first.payload.nativeSessionId
87
+ : "unbound";
88
+ const ordered = [...events].sort(compareProgressEvents);
89
+ let firstAt = progressAt(ordered[0]);
90
+ let lastAt = firstAt;
91
+ let maxSequence = null;
92
+ let errorCount = 0;
93
+ for (const event of ordered) {
94
+ const at = progressAt(event);
95
+ if (at < firstAt)
96
+ firstAt = at;
97
+ if (at > lastAt)
98
+ lastAt = at;
99
+ const sequence = event.payload.sequence;
100
+ if (typeof sequence === "string") {
101
+ const value = Number(sequence);
102
+ if (Number.isSafeInteger(value) && (maxSequence === null || value > maxSequence)) {
103
+ maxSequence = value;
104
+ }
105
+ }
106
+ if ((typeof event.payload.error === "string" && event.payload.error !== "")
107
+ || (typeof event.payload.errorKind === "string" && event.payload.errorKind !== "")) {
108
+ errorCount++;
109
+ }
110
+ }
111
+ const window = ordered.slice(-keep).map((event) => ({
112
+ taskId,
113
+ roleName,
114
+ runId,
115
+ generation,
116
+ progressId: event.payload.progressId,
117
+ ...(typeof event.payload.sequence === "string"
118
+ && Number.isSafeInteger(Number(event.payload.sequence))
119
+ ? { sequence: Number(event.payload.sequence) }
120
+ : {}),
121
+ payload: { ...event.payload },
122
+ receivedAt: progressAt(event)
123
+ }));
124
+ return {
125
+ roleName,
126
+ runId,
127
+ generation,
128
+ aggregate: {
129
+ taskId,
130
+ roleName,
131
+ runId,
132
+ generation,
133
+ firstAt,
134
+ lastAt,
135
+ count: ordered.length,
136
+ maxSequence,
137
+ errorCount
138
+ },
139
+ window
140
+ };
141
+ }
142
+ function compareProgressEvents(a, b) {
143
+ const seqA = numericSequence(a);
144
+ const seqB = numericSequence(b);
145
+ if (seqA !== null && seqB !== null && seqA !== seqB)
146
+ return seqA - seqB;
147
+ const atA = Date.parse(progressAt(a));
148
+ const atB = Date.parse(progressAt(b));
149
+ if (atA !== atB)
150
+ return atA - atB;
151
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
152
+ }
153
+ function numericSequence(event) {
154
+ const raw = event.payload.sequence;
155
+ if (typeof raw !== "string")
156
+ return null;
157
+ const value = Number(raw);
158
+ return Number.isSafeInteger(value) ? value : null;
159
+ }
160
+ function progressAt(event) {
161
+ const raw = event.payload.progressAt;
162
+ if (typeof raw === "string" && Number.isFinite(Date.parse(raw)))
163
+ return raw;
164
+ return event.createdAt;
165
+ }
166
+ /**
167
+ * Apply a plan to a staged Home. The telemetry windows/aggregates are imported
168
+ * first and validated, then the semantic progress events are removed inside
169
+ * one store transaction and validated again. Throws on any validation
170
+ * failure; the caller must discard the staged Home in that case. A crash
171
+ * between the two phases leaves the staged Home in the equivalent of
172
+ * dual-write state (telemetry imported, semantic progress still present),
173
+ * which is harmless and re-runnable.
174
+ */
175
+ export function applyTelemetryCompaction(store, telemetry, plan, options) {
176
+ const now = options.now ?? new Date();
177
+ const keep = options.terminalKeep ?? DEFAULT_TERMINAL_KEEP;
178
+ if (!options.dryRun) {
179
+ // Import first and validate the sidecar before touching semantic history:
180
+ // a validation failure leaves the staged Home's semantic events intact.
181
+ for (const task of plan.tasks) {
182
+ for (const generation of task.generations) {
183
+ telemetry.importGeneration(generation.window, generation.aggregate);
184
+ }
185
+ }
186
+ validateAggregates(telemetry, plan);
187
+ store.transaction(() => {
188
+ for (const task of plan.tasks) {
189
+ if (task.progressEventIds.length === 0)
190
+ continue;
191
+ store.removeEvents(task.taskId, task.progressEventIds);
192
+ }
193
+ });
194
+ validateSemanticClean(store, plan);
195
+ }
196
+ return {
197
+ schemaVersion: 1,
198
+ createdAt: now.toISOString(),
199
+ dryRun: options.dryRun,
200
+ source: options.source,
201
+ terminalKeep: keep,
202
+ totals: plan.totals,
203
+ tasks: plan.tasks.map((task) => ({
204
+ taskId: task.taskId,
205
+ removedProgressEvents: options.dryRun ? 0 : task.progressEventIds.length,
206
+ generations: task.generations.map((generation) => generation.aggregate)
207
+ })),
208
+ validation: "passed"
209
+ };
210
+ }
211
+ /**
212
+ * Verify every planned Run/generation aggregate in the sidecar matches the
213
+ * plan (count, first/last, max sequence) before semantic history is touched.
214
+ */
215
+ function validateAggregates(telemetry, plan) {
216
+ for (const task of plan.tasks) {
217
+ for (const generation of task.generations) {
218
+ const stored = telemetry.aggregateGeneration(generation.aggregate.taskId, generation.aggregate.roleName, generation.aggregate.runId, generation.aggregate.generation);
219
+ if (stored === null) {
220
+ throw new Error(`Compaction validation failed: missing aggregate for Run ${generation.aggregate.runId}.`);
221
+ }
222
+ const expected = generation.aggregate;
223
+ if (stored.count !== expected.count
224
+ || stored.firstAt !== expected.firstAt
225
+ || stored.lastAt !== expected.lastAt
226
+ || stored.maxSequence !== expected.maxSequence
227
+ || stored.errorCount !== expected.errorCount) {
228
+ throw new Error(`Compaction validation failed: aggregate mismatch for Run ${expected.runId} `
229
+ + `(expected count=${expected.count} first=${expected.firstAt} last=${expected.lastAt} `
230
+ + `maxSequence=${String(expected.maxSequence)} errors=${expected.errorCount}; `
231
+ + `got count=${stored.count} first=${stored.firstAt} last=${stored.lastAt} `
232
+ + `maxSequence=${String(stored.maxSequence)} errors=${stored.errorCount}).`);
233
+ }
234
+ }
235
+ }
236
+ }
237
+ /** Verify no planned progress event remains in semantic history. */
238
+ function validateSemanticClean(store, plan) {
239
+ for (const task of plan.tasks) {
240
+ if (task.progressEventIds.length === 0)
241
+ continue;
242
+ const remaining = new Set(store.listEvents(task.taskId)
243
+ .filter((event) => event.type === PROGRESS_EVENT_TYPE)
244
+ .map((event) => event.id));
245
+ for (const id of task.progressEventIds) {
246
+ if (remaining.has(id)) {
247
+ throw new Error(`Compaction validation failed: progress event ${task.taskId}/${id} still present.`);
248
+ }
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Issue 09 — telemetry retention & compaction configuration.
3
+ *
4
+ * Telemetry lives in the Home's authoritative `yui.db` (database-only
5
+ * direction). Its activation is an explicit per-Home switch so the feature
6
+ * can be rolled out (legacy → dual → bounded) and rolled back independently
7
+ * of every other Issue. Retention defaults are the schema's own constants
8
+ * (§4.4); the environment only overrides them.
9
+ */
10
+ import { TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "../storage/sqliteSchema.js";
11
+ export const DEFAULT_TELEMETRY_MODE = "legacy";
12
+ /** Terminal Run/generation progress rows retained after prune. */
13
+ export const DEFAULT_TERMINAL_KEEP = TELEMETRY_KEEP_PER_GENERATION;
14
+ /** Hard cap of progress rows per Run while it is still active. */
15
+ export const DEFAULT_RUN_CAP = TELEMETRY_RUN_CAP;
16
+ /**
17
+ * Absolute ceiling for the configurable Run cap. Retention can be tuned but
18
+ * never disabled: every Home keeps a computable upper bound on telemetry
19
+ * rows (Tasks × Runs × cap).
20
+ */
21
+ export const MAX_RUN_CAP = 10_000_000;
22
+ const TELEMETRY_MODES = ["legacy", "dual", "bounded"];
23
+ /**
24
+ * Resolve `YUI_TELEMETRY_MODE` (default `legacy`). Only the three exact
25
+ * values (case-insensitive) are accepted; anything else fails closed at
26
+ * startup instead of silently changing progress routing.
27
+ */
28
+ export function resolveTelemetryMode(env = process.env) {
29
+ const raw = env.YUI_TELEMETRY_MODE?.trim().toLowerCase();
30
+ if (raw === undefined || raw === "")
31
+ return DEFAULT_TELEMETRY_MODE;
32
+ if (!TELEMETRY_MODES.includes(raw)) {
33
+ throw new TypeError(`YUI_TELEMETRY_MODE must be one of ${TELEMETRY_MODES.join(", ")}; got ${JSON.stringify(raw)}.`);
34
+ }
35
+ return raw;
36
+ }
37
+ /**
38
+ * Resolve the terminal-Run retention window (`YUI_TELEMETRY_TERMINAL_KEEP`,
39
+ * default 200). Must be a positive integer.
40
+ */
41
+ export function resolveTerminalKeep(env = process.env) {
42
+ return resolvePositiveInteger(env.YUI_TELEMETRY_TERMINAL_KEEP, DEFAULT_TERMINAL_KEEP, "YUI_TELEMETRY_TERMINAL_KEEP");
43
+ }
44
+ /**
45
+ * Resolve the active-Run hard cap (`YUI_TELEMETRY_RUN_CAP`, default 50,000).
46
+ * Must be a positive integer not exceeding {@link MAX_RUN_CAP}; the cap
47
+ * cannot be disabled.
48
+ */
49
+ export function resolveRunCap(env = process.env) {
50
+ const cap = resolvePositiveInteger(env.YUI_TELEMETRY_RUN_CAP, DEFAULT_RUN_CAP, "YUI_TELEMETRY_RUN_CAP");
51
+ if (cap > MAX_RUN_CAP) {
52
+ throw new TypeError(`YUI_TELEMETRY_RUN_CAP must not exceed ${MAX_RUN_CAP.toLocaleString("en-US")} (retention cannot be disabled).`);
53
+ }
54
+ return cap;
55
+ }
56
+ function resolvePositiveInteger(raw, fallback, label) {
57
+ if (raw === undefined || raw.trim() === "")
58
+ return fallback;
59
+ const value = Number(raw);
60
+ if (!Number.isSafeInteger(value) || value < 1) {
61
+ throw new TypeError(`${label} must be a positive integer; got ${JSON.stringify(raw)}.`);
62
+ }
63
+ return value;
64
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Route one provider progress observation according to `telemetry.mode`:
3
+ *
4
+ * - `legacy` — semantic event only (master behavior).
5
+ * - `dual` — semantic event plus best-effort sidecar upsert.
6
+ * - `bounded` — sidecar upsert only; semantic events stop carrying progress.
7
+ *
8
+ * The sidecar write is best-effort and isolated: a sink failure must never
9
+ * block or roll back the semantic lane.
10
+ */
11
+ export function routeProviderProgress(input) {
12
+ const { mode, entry, semanticExists, writeSemantic, sink } = input;
13
+ if (mode === "bounded") {
14
+ safeObserve(sink, entry);
15
+ return "applied";
16
+ }
17
+ if (semanticExists)
18
+ return "applied";
19
+ writeSemantic();
20
+ if (mode === "dual")
21
+ safeObserve(sink, entry);
22
+ return "applied";
23
+ }
24
+ function safeObserve(sink, entry) {
25
+ try {
26
+ sink.observe(entry);
27
+ }
28
+ catch {
29
+ // Telemetry is best-effort by contract; sinks must not throw, but a
30
+ // broken sink must never take down the semantic lane.
31
+ }
32
+ }
@@ -0,0 +1,19 @@
1
+ /** No-op sink for `legacy` mode and for callers without a sidecar. */
2
+ export class NullTelemetrySink {
3
+ mode;
4
+ constructor(mode = "legacy") {
5
+ this.mode = mode;
6
+ }
7
+ observe(_entry) { }
8
+ health() {
9
+ return {
10
+ mode: this.mode,
11
+ available: false,
12
+ dropped: 0,
13
+ coalesced: 0,
14
+ lastError: null,
15
+ rows: 0
16
+ };
17
+ }
18
+ async close() { }
19
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveRunCap, resolveTelemetryMode, resolveTerminalKeep } from "./telemetryConfig.js";
4
+ import { SqliteTelemetryStore } from "./sqliteTelemetryStore.js";
5
+ import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigration.js";
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.
10
+ *
11
+ * Telemetry lives in the Home's authoritative `yui.db`. A Home without a
12
+ * database has not reached SQLite storage yet, so dual/bounded mode fails
13
+ * closed at startup with a bounded diagnosis instead of silently creating an
14
+ * empty database (which would corrupt Home classification). Once opened, the
15
+ * store fails isolated: a broken sidecar only increments its dropped counter
16
+ * and never blocks the semantic lane.
17
+ */
18
+ export function openSchedulerTelemetry(home, env = process.env) {
19
+ const mode = resolveTelemetryMode(env);
20
+ if (mode === "legacy")
21
+ return null;
22
+ const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
23
+ if (!existsSync(dbPath)) {
24
+ throw new Error(`YUI_TELEMETRY_MODE=${mode} requires SQLite storage, but ${dbPath} does not exist. `
25
+ + "Migrate this Home to the database backend first (yui upgrade).");
26
+ }
27
+ const store = new SqliteTelemetryStore(home, {
28
+ mode,
29
+ terminalKeep: resolveTerminalKeep(env),
30
+ runCap: resolveRunCap(env)
31
+ });
32
+ return { mode, sink: store, reader: store };
33
+ }
@@ -723,6 +723,25 @@ export class TmuxManager {
723
723
  const snapshot = await this.sessionWindowNamesAsync(taskId);
724
724
  return snapshot.names.includes(roleName) ? "running" : "exited";
725
725
  }
726
+ /** Exact pane process state for one Role, used by owner-identity recording. */
727
+ inspectRolePane(taskId, roleName) {
728
+ const pane = this.inspectPane(taskId, roleName);
729
+ return {
730
+ ...(pane.pid === undefined ? {} : { pid: pane.pid }),
731
+ target: pane.target,
732
+ dead: pane.dead,
733
+ currentCommand: pane.currentCommand
734
+ };
735
+ }
736
+ async inspectRolePaneAsync(taskId, roleName) {
737
+ const pane = await this.inspectPaneAsync(taskId, roleName);
738
+ return {
739
+ ...(pane.pid === undefined ? {} : { pid: pane.pid }),
740
+ target: pane.target,
741
+ dead: pane.dead,
742
+ currentCommand: pane.currentCommand
743
+ };
744
+ }
726
745
  stopTask(taskId) {
727
746
  const taskSession = this.sessionName(taskId);
728
747
  const sessions = this.taskSessionGroupNames(taskId);
@@ -1037,7 +1056,7 @@ function launchCommand(launch) {
1037
1056
  function isExplicitlyAbsentTmuxSession(error) {
1038
1057
  if (!(error instanceof CommandExecutionError))
1039
1058
  return false;
1040
- return /can't find (?:session|window|pane)|no server running|error connecting to .+ \(No such file or directory\)/i
1059
+ return /can't find (?:session|window|pane)|no server running|no current target|session not found|error connecting to .+ \(No such file or directory\)/i
1041
1060
  .test(error.stderr);
1042
1061
  }
1043
1062
  function isUnavailableTmuxStatus(error) {
@@ -0,0 +1,20 @@
1
+ import { join, resolve } from "node:path";
2
+ /**
3
+ * Exact same-user directory in which `tmux -L` publishes a named server.
4
+ * TMUX_TMPDIR is tmux's only configurable root; TMPDIR/TMP/TEMP do not apply.
5
+ */
6
+ export function tmuxSocketDirectory(environment = process.env) {
7
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
8
+ return join(resolve(nonEmptyTmuxTmpdir(environment) ?? "/tmp"), `tmux-${uid}`);
9
+ }
10
+ /** Pins an invoked tmux process to the same root selected above. */
11
+ export function tmuxSocketEnvironment(environment = process.env) {
12
+ return {
13
+ ...environment,
14
+ TMUX_TMPDIR: nonEmptyTmuxTmpdir(environment)
15
+ };
16
+ }
17
+ function nonEmptyTmuxTmpdir(environment) {
18
+ const configured = environment.TMUX_TMPDIR;
19
+ return configured !== undefined && configured.length > 0 ? configured : undefined;
20
+ }
@@ -0,0 +1,216 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+ import { requireIdentity, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
4
+ /**
5
+ * Issue 08: the reusable exact-SHA gate evidence record.
6
+ *
7
+ * A GateArtifact binds one gate run to its full identity tuple: Project,
8
+ * candidate commit, VerificationPlan digest, toolchain digest, and (for L2)
9
+ * the source/base/target boundary. The same tuple reuses the same successful
10
+ * artifact; any tuple change invalidates it. Artifacts are stored in the
11
+ * SQLite `gate_artifacts` / `gate_artifact_logs` tables, backend-neutral
12
+ * through the GateArtifactStorePort.
13
+ */
14
+ export const GATE_ARTIFACT_SCHEMA_VERSION = 1;
15
+ /** The evidence-reference scheme carried in manifests and queue entries. */
16
+ export const GATE_ARTIFACT_REF_PREFIX = "gate-artifact:";
17
+ export function gateArtifactRef(key) {
18
+ return `${GATE_ARTIFACT_REF_PREFIX}${key}`;
19
+ }
20
+ export function parseGateArtifactRef(ref) {
21
+ return ref.startsWith(GATE_ARTIFACT_REF_PREFIX)
22
+ ? ref.slice(GATE_ARTIFACT_REF_PREFIX.length)
23
+ : undefined;
24
+ }
25
+ /** Content-addressed key: the same identity tuple always maps to one key. */
26
+ export function gateArtifactKey(identity) {
27
+ return createHash("sha256")
28
+ .update(canonicalJson(identity))
29
+ .digest("hex");
30
+ }
31
+ export function createGateArtifact(identity, metadata, now) {
32
+ const timestamp = now.toISOString();
33
+ return validateGateArtifact({
34
+ schemaVersion: GATE_ARTIFACT_SCHEMA_VERSION,
35
+ key: gateArtifactKey(identity),
36
+ projectId: identity.projectId,
37
+ level: identity.level,
38
+ commit: identity.commit,
39
+ planId: requireIdentity(metadata.planId, "GateArtifact planId"),
40
+ planVersion: requireText(metadata.planVersion, "GateArtifact planVersion"),
41
+ planDigest: identity.planDigest,
42
+ toolchainDigest: identity.toolchainDigest,
43
+ ...(identity.boundary === undefined ? {} : { boundary: identity.boundary }),
44
+ steps: Object.freeze([]),
45
+ generator: requireText(metadata.generator, "GateArtifact generator"),
46
+ status: "incomplete",
47
+ outcome: "unknown",
48
+ potentialReuseCount: 0,
49
+ reuseCount: 0,
50
+ createdAt: timestamp,
51
+ lastUsedAt: timestamp
52
+ });
53
+ }
54
+ /** Terminalize an artifact with its step results. */
55
+ export function completeGateArtifact(artifact, steps, outcome, now) {
56
+ validateGateArtifact(artifact);
57
+ if (artifact.status !== "incomplete") {
58
+ throw new Error(`GateArtifact is already complete: ${artifact.key}.`);
59
+ }
60
+ const timestamp = now.toISOString();
61
+ return validateGateArtifact({
62
+ ...artifact,
63
+ steps,
64
+ status: "complete",
65
+ outcome,
66
+ completedAt: timestamp,
67
+ lastUsedAt: timestamp
68
+ });
69
+ }
70
+ /** Record a shadow potential-reuse observation (record mode). */
71
+ export function recordGateArtifactPotentialReuse(artifact, now) {
72
+ validateGateArtifact(artifact);
73
+ return validateGateArtifact({
74
+ ...artifact,
75
+ potentialReuseCount: artifact.potentialReuseCount + 1,
76
+ lastUsedAt: now.toISOString()
77
+ });
78
+ }
79
+ /** Record an actual reuse (reuse/enforce mode). */
80
+ export function recordGateArtifactReuse(artifact, now) {
81
+ validateGateArtifact(artifact);
82
+ if (artifact.status !== "complete" || artifact.outcome !== "succeeded") {
83
+ throw new Error(`Only a complete successful GateArtifact can be reused: ${artifact.key}.`);
84
+ }
85
+ return validateGateArtifact({
86
+ ...artifact,
87
+ reuseCount: artifact.reuseCount + 1,
88
+ lastUsedAt: now.toISOString()
89
+ });
90
+ }
91
+ export function validateGateArtifact(artifact) {
92
+ if (artifact.schemaVersion !== GATE_ARTIFACT_SCHEMA_VERSION) {
93
+ throw new Error(`GateArtifact must use schemaVersion ${GATE_ARTIFACT_SCHEMA_VERSION}.`);
94
+ }
95
+ requireIdentity(artifact.projectId, "GateArtifact projectId");
96
+ if (artifact.level !== "L1" && artifact.level !== "L2") {
97
+ throw new Error(`GateArtifact level is invalid: ${String(artifact.level)}.`);
98
+ }
99
+ requireCommit(artifact.commit, "GateArtifact commit");
100
+ requireDigest(artifact.planDigest, "GateArtifact planDigest");
101
+ requireDigest(artifact.toolchainDigest, "GateArtifact toolchainDigest");
102
+ requireDigest(artifact.key, "GateArtifact key");
103
+ if (artifact.key !== gateArtifactKey({
104
+ projectId: artifact.projectId,
105
+ level: artifact.level,
106
+ commit: artifact.commit,
107
+ planDigest: artifact.planDigest,
108
+ toolchainDigest: artifact.toolchainDigest,
109
+ ...(artifact.boundary === undefined ? {} : { boundary: artifact.boundary })
110
+ })) {
111
+ throw new Error("GateArtifact key does not match its identity tuple.");
112
+ }
113
+ if (artifact.level === "L2" && artifact.boundary === undefined) {
114
+ throw new Error("An L2 GateArtifact requires a target boundary.");
115
+ }
116
+ if (artifact.boundary !== undefined) {
117
+ requireText(artifact.boundary.targetRef, "GateArtifact boundary targetRef");
118
+ requireCommit(artifact.boundary.baseHead, "GateArtifact boundary baseHead");
119
+ }
120
+ if (!["incomplete", "complete"].includes(artifact.status)) {
121
+ throw new Error(`GateArtifact status is invalid: ${String(artifact.status)}.`);
122
+ }
123
+ if (!["unknown", "succeeded", "failed"].includes(artifact.outcome)) {
124
+ throw new Error(`GateArtifact outcome is invalid: ${String(artifact.outcome)}.`);
125
+ }
126
+ if (artifact.status === "incomplete" && artifact.outcome !== "unknown") {
127
+ throw new Error("An incomplete GateArtifact must have outcome unknown.");
128
+ }
129
+ if (artifact.status === "complete" && artifact.outcome === "unknown") {
130
+ throw new Error("A complete GateArtifact must have a terminal outcome.");
131
+ }
132
+ const names = new Set();
133
+ for (const step of artifact.steps) {
134
+ requireIdentity(step.name, "GateArtifact step name");
135
+ if (names.has(step.name)) {
136
+ throw new Error(`GateArtifact step names must be unique: ${step.name}.`);
137
+ }
138
+ names.add(step.name);
139
+ requireText(step.command, `GateArtifact step ${step.name} command`);
140
+ if (!["passed", "failed", "skipped"].includes(step.outcome)) {
141
+ throw new Error(`GateArtifact step ${step.name} outcome is invalid.`);
142
+ }
143
+ requirePositiveInteger(step.durationMs + 1, `GateArtifact step ${step.name} durationMs`);
144
+ requireText(step.logPath, `GateArtifact step ${step.name} logPath`);
145
+ if (isAbsolute(step.logPath) || step.logPath.includes("..")) {
146
+ throw new Error(`GateArtifact step ${step.name} logPath must be relative.`);
147
+ }
148
+ requireDigest(step.logDigest, `GateArtifact step ${step.name} logDigest`);
149
+ if (!Number.isSafeInteger(step.logBytes) || step.logBytes < 0) {
150
+ throw new Error(`GateArtifact step ${step.name} logBytes is invalid.`);
151
+ }
152
+ }
153
+ requirePositiveInteger(artifact.potentialReuseCount + 1, "GateArtifact potentialReuseCount");
154
+ requirePositiveInteger(artifact.reuseCount + 1, "GateArtifact reuseCount");
155
+ requireTimestamp(artifact.createdAt, "GateArtifact createdAt");
156
+ requireTimestamp(artifact.lastUsedAt, "GateArtifact lastUsedAt");
157
+ if (artifact.completedAt !== undefined) {
158
+ requireTimestamp(artifact.completedAt, "GateArtifact completedAt");
159
+ }
160
+ return artifact;
161
+ }
162
+ /**
163
+ * Verify that every step log is present in the log map and matches its
164
+ * recorded digest. A lost or corrupted log means the artifact cannot serve
165
+ * as complete L2 evidence: consumers must treat it as a gap.
166
+ */
167
+ export function verifyGateArtifactLogs(artifact, logs) {
168
+ const missing = [];
169
+ const corrupted = [];
170
+ for (const step of artifact.steps) {
171
+ const content = logs.get(step.name);
172
+ if (content === undefined) {
173
+ missing.push(step.name);
174
+ continue;
175
+ }
176
+ const digest = createHash("sha256").update(content).digest("hex");
177
+ if (digest !== step.logDigest || content.length !== step.logBytes) {
178
+ corrupted.push(step.name);
179
+ }
180
+ }
181
+ return Object.freeze({
182
+ ok: missing.length === 0 && corrupted.length === 0,
183
+ missing: Object.freeze(missing),
184
+ corrupted: Object.freeze(corrupted)
185
+ });
186
+ }
187
+ export function isReusableGateArtifact(artifact) {
188
+ return artifact.status === "complete" && artifact.outcome === "succeeded";
189
+ }
190
+ function requireCommit(value, label) {
191
+ const normalized = requireText(value, label).toLowerCase();
192
+ if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(normalized)) {
193
+ throw new Error(`${label} is invalid.`);
194
+ }
195
+ return normalized;
196
+ }
197
+ function requireDigest(value, label) {
198
+ const normalized = requireText(value, label).toLowerCase();
199
+ if (!/^[a-f0-9]{64}$/u.test(normalized)) {
200
+ throw new Error(`${label} is invalid.`);
201
+ }
202
+ return normalized;
203
+ }
204
+ function canonicalJson(value) {
205
+ return JSON.stringify(canonicalize(value));
206
+ }
207
+ function canonicalize(value) {
208
+ if (Array.isArray(value))
209
+ return value.map(canonicalize);
210
+ if (typeof value === "object" && value !== null) {
211
+ const entries = Object.entries(value)
212
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
213
+ return Object.fromEntries(entries.map(([key, entry]) => [key, canonicalize(entry)]));
214
+ }
215
+ return value;
216
+ }