@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,137 @@
1
+ const SEVERITY_RANK = {
2
+ p1: 5,
3
+ critical: 4,
4
+ high: 3,
5
+ p2: 2,
6
+ medium: 1,
7
+ low: 0
8
+ };
9
+ const PATH_PATTERN = /(?:[\w.-]+\/)+[\w.-]+\.(?:ts|tsx|js|mjs|cjs|json|md|py|sh|yml|yaml|toml|css|html)(?::\d+)?/gu;
10
+ /**
11
+ * Extract open findings from a ReviewRound. Structured ExecutionFindings on
12
+ * the Review's ExecutionGroup lanes win; when none exist, fall back to
13
+ * parsing P1/P2 lines from the Review report text.
14
+ */
15
+ export function extractReviewFindings(round) {
16
+ const structured = (round.executionGroup?.lanes ?? [])
17
+ .flatMap((lane) => lane.result?.findings ?? [])
18
+ .filter((finding) => finding.status === "open");
19
+ if (structured.length > 0) {
20
+ return structured.map((finding) => ({
21
+ id: finding.id,
22
+ severity: finding.severity,
23
+ summary: finding.summary,
24
+ paths: extractPaths(finding.summary),
25
+ source: "structured"
26
+ }));
27
+ }
28
+ return parseReportFindings(round.report ?? "");
29
+ }
30
+ export function planRepairWave(reviewRoundId, findings) {
31
+ if (findings.length === 0) {
32
+ return { reviewRoundId, openFindingCount: 0, groups: [] };
33
+ }
34
+ const parent = new Array(findings.length).fill(0).map((_, index) => index);
35
+ const find = (index) => {
36
+ let current = index;
37
+ while (parent[current] !== current)
38
+ current = parent[current];
39
+ return current;
40
+ };
41
+ const union = (left, right) => {
42
+ const leftRoot = find(left);
43
+ const rightRoot = find(right);
44
+ if (leftRoot !== rightRoot)
45
+ parent[rightRoot] = leftRoot;
46
+ };
47
+ const unpathable = [];
48
+ for (let index = 0; index < findings.length; index += 1) {
49
+ if (findings[index].paths.length === 0)
50
+ unpathable.push(index);
51
+ }
52
+ // Unprovable findings merge together: the planner cannot prove they are
53
+ // disjoint from anything, so serializing them is the fail-closed choice.
54
+ for (let index = 1; index < unpathable.length; index += 1) {
55
+ union(unpathable[0], unpathable[index]);
56
+ }
57
+ for (let left = 0; left < findings.length; left += 1) {
58
+ for (let right = left + 1; right < findings.length; right += 1) {
59
+ if (sharePath(findings[left], findings[right]))
60
+ union(left, right);
61
+ }
62
+ }
63
+ const byRoot = new Map();
64
+ for (let index = 0; index < findings.length; index += 1) {
65
+ const root = find(index);
66
+ byRoot.set(root, [...(byRoot.get(root) ?? []), index]);
67
+ }
68
+ const groups = [...byRoot.values()]
69
+ .map((members, position) => {
70
+ const waveFindings = members.map((index) => findings[index]);
71
+ const paths = [...new Set(waveFindings.flatMap((finding) => finding.paths))]
72
+ .sort();
73
+ const reason = paths.length === 0
74
+ ? "No parseable paths; merged conservatively"
75
+ : waveFindings.length === 1
76
+ ? `Distinct path ${paths[0]}; parallel-safe`
77
+ : `Shared path(s) ${paths.join(", ")}; merged`;
78
+ return {
79
+ id: `repair-${position + 1}`,
80
+ findingIds: waveFindings.map((finding) => finding.id),
81
+ paths,
82
+ reason
83
+ };
84
+ })
85
+ .sort((left, right) => {
86
+ const leftRank = Math.max(...left.findingIds.map((id) => SEVERITY_RANK[findings.find((finding) => finding.id === id).severity]));
87
+ const rightRank = Math.max(...right.findingIds.map((id) => SEVERITY_RANK[findings.find((finding) => finding.id === id).severity]));
88
+ if (leftRank !== rightRank)
89
+ return rightRank - leftRank;
90
+ return left.id.localeCompare(right.id, undefined, { numeric: true });
91
+ })
92
+ .map((group, index) => ({ ...group, id: `repair-${index + 1}` }));
93
+ return {
94
+ reviewRoundId,
95
+ openFindingCount: findings.length,
96
+ groups
97
+ };
98
+ }
99
+ function sharePath(left, right) {
100
+ if (left.paths.length === 0 || right.paths.length === 0)
101
+ return false;
102
+ const rightPaths = new Set(right.paths.map(filePart));
103
+ return left.paths.some((path) => rightPaths.has(filePart(path)));
104
+ }
105
+ function filePart(path) {
106
+ return path.split(":")[0];
107
+ }
108
+ export function extractPaths(text) {
109
+ const matches = text.match(PATH_PATTERN) ?? [];
110
+ return [...new Set(matches)].sort();
111
+ }
112
+ /**
113
+ * Fallback finding extraction for Reviews without structured findings: parse
114
+ * P1/P2 lines from the report body. A line like "P1: src/foo.ts:12 ..." or
115
+ * "- P2 src/bar.ts ..." becomes one finding.
116
+ */
117
+ export function parseReportFindings(report) {
118
+ const findings = [];
119
+ const lines = report.split(/\r?\n/u);
120
+ let index = 0;
121
+ for (const line of lines) {
122
+ const match = /^\s*(?:[-*]\s*)?(P[12])\b[:\s-]+(.+)$/u.exec(line);
123
+ if (match === null)
124
+ continue;
125
+ index += 1;
126
+ const severity = match[1].toLowerCase();
127
+ const summary = match[2].trim();
128
+ findings.push({
129
+ id: `report-p${severity === "p1" ? 1 : 2}-${index}`,
130
+ severity,
131
+ summary,
132
+ paths: extractPaths(summary),
133
+ source: "report"
134
+ });
135
+ }
136
+ return findings;
137
+ }
@@ -3,13 +3,18 @@ export const TASK_RECORD_ID_PREFIXES = {
3
3
  workItem: "work-item",
4
4
  agentRun: "agent-run",
5
5
  reviewRound: "review-round",
6
+ reviewFinding: "review-finding",
6
7
  changeSet: "change-set",
7
8
  integrationAttempt: "integration",
9
+ integrationQueue: "integration-queue",
10
+ durableJob: "job",
8
11
  message: "message",
9
12
  inputRequest: "input",
10
13
  decision: "decision",
11
14
  milestone: "milestone",
12
- event: "event"
15
+ event: "event",
16
+ capabilityGrant: "capability-grant",
17
+ releaseWorkflow: "release-workflow"
13
18
  };
14
19
  export function formatTaskRecordReference(taskId, localId, kind) {
15
20
  const reference = validateTaskRecordReference({ taskId, localId }, kind);
@@ -0,0 +1,387 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { DEFAULT_RUN_CAP, DEFAULT_TERMINAL_KEEP } from "./telemetryConfig.js";
5
+ import { migrateSqliteSchema } from "../storage/sqliteSchema.js";
6
+ import { COMMITTED_DATABASE_FILENAME } from "../storage/upgrade/sqliteStateMigration.js";
7
+ /**
8
+ * Default sidecar implementation: the telemetry tables live inside the Home's
9
+ * authoritative `yui.db` (schema migrations 1 and 5), so a database-only Home
10
+ * has one file to manage, back up, and migrate. The store opens its own
11
+ * connection to that file: the write path is a single serialized writer (the
12
+ * "WAL worker") and stays isolated from the business store's connection.
13
+ *
14
+ * Writes are best-effort and never block the Controller event loop: `observe`
15
+ * only merges into a bounded in-memory queue; a background flush drains it in
16
+ * batches. Queue overflow and database failures increment `dropped` and record
17
+ * a health warning — the semantic lane is never affected.
18
+ *
19
+ * The telemetry tables are maintained by the centralized schema migrations:
20
+ * `telemetry` (migration 1, §4.4) holds the bounded latest-per-key window and
21
+ * `telemetry_aggregate` (migration 5) holds the authoritative per-Run/generation
22
+ * summary, maintained by triggers so it survives window pruning.
23
+ */
24
+ const MAX_PAGE_LIMIT = 500;
25
+ export class SqliteTelemetryStore {
26
+ mode;
27
+ #path;
28
+ #terminalKeep;
29
+ #runCap;
30
+ #maxPending;
31
+ #db = null;
32
+ #failed = false;
33
+ #lastError = null;
34
+ #dropped = 0;
35
+ #coalesced = 0;
36
+ #applied = 0;
37
+ #pending = new Map();
38
+ #flushScheduled = false;
39
+ #closed = false;
40
+ constructor(home, options = {}) {
41
+ this.mode = options.mode ?? "dual";
42
+ this.#path = join(home, COMMITTED_DATABASE_FILENAME);
43
+ this.#terminalKeep = options.terminalKeep ?? DEFAULT_TERMINAL_KEEP;
44
+ this.#runCap = options.runCap ?? DEFAULT_RUN_CAP;
45
+ this.#maxPending = options.maxPending ?? 10_000;
46
+ }
47
+ // -- TelemetrySink -------------------------------------------------------------
48
+ observe(entry) {
49
+ if (this.#closed)
50
+ return;
51
+ const db = this.#ensureDb();
52
+ if (db === null) {
53
+ this.#dropped++;
54
+ return;
55
+ }
56
+ const key = pendingKey(entry);
57
+ const existing = this.#pending.get(key);
58
+ if (existing !== undefined && !isNewer(entry, existing)) {
59
+ // Same hook replayed with an older/equal sequence: fold onto the row
60
+ // already queued. Replays never add rows.
61
+ this.#coalesced++;
62
+ return;
63
+ }
64
+ if (existing !== undefined)
65
+ this.#coalesced++;
66
+ if (this.#pending.size >= this.#maxPending && existing === undefined) {
67
+ this.#dropped++;
68
+ return;
69
+ }
70
+ this.#pending.set(key, entry);
71
+ this.#scheduleFlush();
72
+ }
73
+ health() {
74
+ return {
75
+ mode: this.mode,
76
+ available: !this.#failed && !this.#closed,
77
+ dropped: this.#dropped,
78
+ coalesced: this.#coalesced,
79
+ lastError: this.#lastError,
80
+ rows: this.#failed ? 0 : this.count()
81
+ };
82
+ }
83
+ async close() {
84
+ if (this.#closed)
85
+ return;
86
+ this.#closed = true;
87
+ await this.#flushPending();
88
+ this.#db?.close();
89
+ this.#db = null;
90
+ }
91
+ // -- TelemetryReader -----------------------------------------------------------
92
+ count(taskId, runId) {
93
+ const db = this.#ensureDb();
94
+ if (db === null)
95
+ return 0;
96
+ if (taskId === undefined) {
97
+ return db.prepare("SELECT COUNT(*) AS n FROM telemetry").get().n;
98
+ }
99
+ if (runId === undefined) {
100
+ return db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ?").get(taskId).n;
101
+ }
102
+ return db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ? AND run_id = ?").get(taskId, runId).n;
103
+ }
104
+ list(taskId, runId, page = { limit: 100, offset: 0 }) {
105
+ const db = this.#ensureDb();
106
+ if (db === null)
107
+ return { items: [], nextOffset: null };
108
+ const limit = Math.min(Math.max(1, Math.trunc(page.limit)), MAX_PAGE_LIMIT);
109
+ const offset = Math.max(0, Math.trunc(page.offset));
110
+ const rows = runId === undefined
111
+ ? db.prepare("SELECT task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at FROM telemetry WHERE task_id = ? ORDER BY received_at, progress_id LIMIT ? OFFSET ?").all(taskId, limit, offset)
112
+ : db.prepare("SELECT task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at FROM telemetry WHERE task_id = ? AND run_id = ? ORDER BY received_at, progress_id LIMIT ? OFFSET ?").all(taskId, runId, limit, offset);
113
+ const items = rows.map(rowToEntry);
114
+ const total = this.count(taskId, runId);
115
+ const nextOffset = offset + items.length < total ? offset + items.length : null;
116
+ return { items, nextOffset };
117
+ }
118
+ aggregate(taskId, runId) {
119
+ const db = this.#ensureDb();
120
+ if (db === null)
121
+ return null;
122
+ const rows = db.prepare("SELECT task_id, role_name, run_id, generation, first_at, last_at, count, max_sequence, error_count FROM telemetry_aggregate WHERE task_id = ? AND run_id = ?").all(taskId, runId);
123
+ if (rows.length === 0)
124
+ return null;
125
+ return mergeAggregates(rows);
126
+ }
127
+ aggregateGeneration(taskId, roleName, runId, generation) {
128
+ const db = this.#ensureDb();
129
+ if (db === null)
130
+ return null;
131
+ const row = db.prepare("SELECT task_id, role_name, run_id, generation, first_at, last_at, count, max_sequence, error_count FROM telemetry_aggregate WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?").get(taskId, roleName, runId, generation);
132
+ if (row === undefined)
133
+ return null;
134
+ return {
135
+ taskId: row.task_id,
136
+ roleName: row.role_name,
137
+ runId: row.run_id,
138
+ generation: row.generation,
139
+ firstAt: row.first_at,
140
+ lastAt: row.last_at,
141
+ count: row.count,
142
+ maxSequence: row.max_sequence,
143
+ errorCount: row.error_count
144
+ };
145
+ }
146
+ listRunAggregates(taskId) {
147
+ const db = this.#ensureDb();
148
+ if (db === null)
149
+ return [];
150
+ const rows = db.prepare("SELECT task_id, role_name, run_id, generation, first_at, last_at, count, max_sequence, error_count FROM telemetry_aggregate WHERE task_id = ? ORDER BY run_id, generation").all(taskId);
151
+ return rows.map((row) => ({
152
+ taskId: row.task_id,
153
+ roleName: row.role_name,
154
+ runId: row.run_id,
155
+ generation: row.generation,
156
+ firstAt: row.first_at,
157
+ lastAt: row.last_at,
158
+ count: row.count,
159
+ maxSequence: row.max_sequence,
160
+ errorCount: row.error_count
161
+ }));
162
+ }
163
+ revision() {
164
+ return this.#applied;
165
+ }
166
+ latestProgressEvents(taskId) {
167
+ const db = this.#ensureDb();
168
+ if (db === null)
169
+ return [];
170
+ const rows = db.prepare(`SELECT task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at FROM (
171
+ SELECT *, ROW_NUMBER() OVER (
172
+ PARTITION BY run_id
173
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
174
+ ) AS rn FROM telemetry WHERE task_id = ?
175
+ ) WHERE rn = 1`).all(taskId);
176
+ return rows.map((row) => {
177
+ const entry = rowToEntry(row);
178
+ const payload = { ...entry.payload };
179
+ payload.runId = entry.runId;
180
+ payload.progressId = entry.progressId;
181
+ // The semantic progress event carries progressAt = observation time;
182
+ // mirror that so liveness folds see the same timestamp.
183
+ payload.progressAt = entry.receivedAt;
184
+ return {
185
+ schemaVersion: 2,
186
+ id: `telemetry-progress-${entry.runId}-${entry.progressId}`,
187
+ taskId: entry.taskId,
188
+ type: "runtime.provider-turn-progress",
189
+ payload,
190
+ createdAt: entry.receivedAt
191
+ };
192
+ });
193
+ }
194
+ // -- retention -----------------------------------------------------------------
195
+ pruneGeneration(taskId, roleName, runId, generation, keep = this.#terminalKeep) {
196
+ const db = this.#ensureDb();
197
+ if (db === null)
198
+ return 0;
199
+ const result = db.prepare(`DELETE FROM telemetry
200
+ WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
201
+ AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
202
+ SELECT task_id, role_name, run_id, generation, progress_id FROM telemetry
203
+ WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
204
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
205
+ LIMIT ?
206
+ )`).run(taskId, roleName, runId, generation, taskId, roleName, runId, generation, keep);
207
+ return result.changes;
208
+ }
209
+ capRun(taskId, runId, cap = this.#runCap) {
210
+ const db = this.#ensureDb();
211
+ if (db === null)
212
+ return 0;
213
+ const result = db.prepare(`DELETE FROM telemetry
214
+ WHERE task_id = ? AND run_id = ?
215
+ AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
216
+ SELECT task_id, role_name, run_id, generation, progress_id FROM telemetry
217
+ WHERE task_id = ? AND run_id = ?
218
+ ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
219
+ LIMIT ?
220
+ )`).run(taskId, runId, taskId, runId, cap);
221
+ return result.changes;
222
+ }
223
+ importGeneration(entries, aggregate) {
224
+ const db = this.#ensureDb();
225
+ if (db === null) {
226
+ this.#dropped += entries.length;
227
+ return;
228
+ }
229
+ const upsert = db.prepare(`INSERT INTO telemetry (task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at)
230
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
231
+ ON CONFLICT(task_id, role_name, run_id, generation, progress_id) DO UPDATE SET
232
+ sequence = excluded.sequence,
233
+ payload = excluded.payload,
234
+ received_at = excluded.received_at
235
+ WHERE COALESCE(excluded.sequence, -1) > COALESCE(telemetry.sequence, -1)
236
+ OR (excluded.sequence IS telemetry.sequence AND excluded.received_at >= telemetry.received_at)`);
237
+ const upsertAggregate = db.prepare(`INSERT INTO telemetry_aggregate
238
+ (task_id, role_name, run_id, generation, first_at, last_at, count, max_sequence, error_count, updated_at)
239
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
240
+ ON CONFLICT(task_id, role_name, run_id, generation) DO UPDATE SET
241
+ first_at = excluded.first_at,
242
+ last_at = excluded.last_at,
243
+ count = excluded.count,
244
+ max_sequence = excluded.max_sequence,
245
+ error_count = excluded.error_count,
246
+ updated_at = excluded.updated_at`);
247
+ db.transaction(() => {
248
+ for (const entry of entries) {
249
+ upsert.run(entry.taskId, entry.roleName, entry.runId, entry.generation, entry.progressId, entry.sequence ?? null, JSON.stringify(entry.payload), entry.receivedAt);
250
+ }
251
+ upsertAggregate.run(aggregate.taskId, aggregate.roleName, aggregate.runId, aggregate.generation, aggregate.firstAt, aggregate.lastAt, aggregate.count, aggregate.maxSequence, aggregate.errorCount, aggregate.lastAt);
252
+ })();
253
+ this.#applied += entries.length;
254
+ }
255
+ async flush() {
256
+ await this.#flushPending();
257
+ }
258
+ // -- internals -----------------------------------------------------------------
259
+ #ensureDb() {
260
+ if (this.#db !== null)
261
+ return this.#db;
262
+ if (this.#failed || this.#closed)
263
+ return null;
264
+ try {
265
+ // The telemetry tables live in the Home's authoritative database; the
266
+ // store never creates `yui.db` itself. Wiring fails closed on Homes
267
+ // without a database instead of silently materializing an empty one.
268
+ if (!existsSync(this.#path)) {
269
+ throw new Error(`Telemetry database not found: ${this.#path}`);
270
+ }
271
+ const db = new Database(this.#path);
272
+ db.pragma("journal_mode = WAL");
273
+ db.pragma("synchronous = FULL");
274
+ db.pragma("foreign_keys = ON");
275
+ db.pragma("busy_timeout = 5000");
276
+ db.pragma("wal_autocheckpoint = 1000");
277
+ migrateSqliteSchema(db);
278
+ this.#db = db;
279
+ return db;
280
+ }
281
+ catch (error) {
282
+ this.#failed = true;
283
+ this.#lastError = error instanceof Error ? error.message : String(error);
284
+ return null;
285
+ }
286
+ }
287
+ #scheduleFlush() {
288
+ if (this.#flushScheduled)
289
+ return;
290
+ this.#flushScheduled = true;
291
+ const handle = setImmediate(() => {
292
+ this.#flushScheduled = false;
293
+ void this.#flushPending();
294
+ });
295
+ handle.unref?.();
296
+ }
297
+ async #flushPending() {
298
+ if (this.#pending.size === 0)
299
+ return;
300
+ const db = this.#ensureDb();
301
+ if (db === null) {
302
+ this.#dropped += this.#pending.size;
303
+ this.#pending.clear();
304
+ return;
305
+ }
306
+ const batch = new Map(this.#pending);
307
+ this.#pending.clear();
308
+ const upsert = db.prepare(`INSERT INTO telemetry (task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at)
309
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
310
+ ON CONFLICT(task_id, role_name, run_id, generation, progress_id) DO UPDATE SET
311
+ sequence = excluded.sequence,
312
+ payload = excluded.payload,
313
+ received_at = excluded.received_at
314
+ WHERE COALESCE(excluded.sequence, -1) > COALESCE(telemetry.sequence, -1)
315
+ OR (excluded.sequence IS telemetry.sequence AND excluded.received_at >= telemetry.received_at)`);
316
+ try {
317
+ db.transaction(() => {
318
+ for (const entry of batch.values()) {
319
+ upsert.run(entry.taskId, entry.roleName, entry.runId, entry.generation, entry.progressId, entry.sequence ?? null, JSON.stringify(entry.payload), entry.receivedAt);
320
+ }
321
+ })();
322
+ this.#applied += batch.size;
323
+ }
324
+ catch (error) {
325
+ this.#lastError = error instanceof Error ? error.message : String(error);
326
+ this.#dropped += batch.size;
327
+ }
328
+ }
329
+ }
330
+ function pendingKey(entry) {
331
+ return `${entry.taskId}\u0000${entry.roleName}\u0000${entry.runId}\u0000${entry.generation}\u0000${entry.progressId}`;
332
+ }
333
+ /** True when `candidate` should replace `current` (sequence/receivedAt only move forward). */
334
+ function isNewer(candidate, current) {
335
+ if (candidate.sequence !== undefined && current.sequence !== undefined) {
336
+ return candidate.sequence > current.sequence;
337
+ }
338
+ if (candidate.sequence !== undefined)
339
+ return true;
340
+ if (current.sequence !== undefined)
341
+ return false;
342
+ return Date.parse(candidate.receivedAt) >= Date.parse(current.receivedAt);
343
+ }
344
+ function rowToEntry(row) {
345
+ return {
346
+ taskId: row.task_id,
347
+ roleName: row.role_name,
348
+ runId: row.run_id,
349
+ generation: row.generation,
350
+ progressId: row.progress_id,
351
+ ...(row.sequence === null ? {} : { sequence: row.sequence }),
352
+ payload: JSON.parse(row.payload),
353
+ receivedAt: row.received_at
354
+ };
355
+ }
356
+ function mergeAggregates(rows) {
357
+ let firstAt = rows[0].first_at;
358
+ let lastAt = rows[0].last_at;
359
+ let count = 0;
360
+ let errorCount = 0;
361
+ let maxSequence = null;
362
+ let roleName = rows[0].role_name;
363
+ for (const row of rows) {
364
+ if (row.first_at < firstAt)
365
+ firstAt = row.first_at;
366
+ if (row.last_at > lastAt) {
367
+ lastAt = row.last_at;
368
+ roleName = row.role_name;
369
+ }
370
+ count += row.count;
371
+ errorCount += row.error_count;
372
+ if (row.max_sequence !== null && (maxSequence === null || row.max_sequence > maxSequence)) {
373
+ maxSequence = row.max_sequence;
374
+ }
375
+ }
376
+ return {
377
+ taskId: rows[0].task_id,
378
+ roleName,
379
+ runId: rows[0].run_id,
380
+ generation: "*",
381
+ firstAt,
382
+ lastAt,
383
+ count,
384
+ maxSequence,
385
+ errorCount
386
+ };
387
+ }