@zq-silk/yui 0.5.3 → 0.6.1
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.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +35 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +610 -201
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +24 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +85 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +60 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +413 -61
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +238 -22
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +549 -42
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/boundedRpc.js +475 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/setup/setupCommand.js +27 -4
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +769 -1
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +705 -0
- package/dist/storage/sqliteStore.js +1695 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +298 -0
- package/dist/storage/taskStore.js +982 -21
- package/dist/storage/upgrade/homeClassification.js +157 -12
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +343 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +2 -1
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite WAL Store (task-21, work-item-3).
|
|
3
|
+
*
|
|
4
|
+
* A `TaskStore` implementation backed by a single SQLite WAL database
|
|
5
|
+
* (`yui.db`), replacing the aggregate `state.json` read/parse/validate/write
|
|
6
|
+
* cycle. It implements the replaceable-Store seam from the design (§6) and the
|
|
7
|
+
* semantic-preservation checklist (§9):
|
|
8
|
+
*
|
|
9
|
+
* - Process write lock .......... single writer connection + BEGIN IMMEDIATE;
|
|
10
|
+
* busy_timeout absorbs CLI contention.
|
|
11
|
+
* - Revision CAS ................ home_meta.revision checked/incremented in
|
|
12
|
+
* the write transaction; conflict ->
|
|
13
|
+
* StorageConflictError (transactionWithRevisionCas).
|
|
14
|
+
* - Atomic durable write ........ WAL + synchronous=FULL; COMMIT == fsync.
|
|
15
|
+
* - Mailbox per-target ordering . mailboxes.next_sequence + mailbox_signals
|
|
16
|
+
* (mailbox_id, sequence) primary key.
|
|
17
|
+
* - Exactly-once terminal state . conditional updates + UNIQUE(request_id)
|
|
18
|
+
* on outbox / mailbox_signals.
|
|
19
|
+
* - Crash recovery .............. WAL rollback of uncommitted transactions;
|
|
20
|
+
* outbox replay of committed-but-unacked effects.
|
|
21
|
+
* - Record family versioning .... full record (incl. schemaVersion) in payload.
|
|
22
|
+
* - Upgrade fence ............... assertHomeWritable at the write boundary.
|
|
23
|
+
* - Evidence retention .......... events/review_rounds/change_sets/
|
|
24
|
+
* integration_attempts are never pruned.
|
|
25
|
+
*
|
|
26
|
+
* Records are stored as full versioned JSON in `payload` columns, with typed
|
|
27
|
+
* columns for the fields that are queried/filtered/used-for-CAS (§4). A
|
|
28
|
+
* high-frequency `runtime.provider-turn-progress` event is a single-row
|
|
29
|
+
* upsert into `telemetry` scoped by its primary key — it never rewrites global
|
|
30
|
+
* state and never touches another Task's rows (§4.4).
|
|
31
|
+
*
|
|
32
|
+
* The in-process store is phase 1 of §6. It does not re-run the heavy record
|
|
33
|
+
* validators on write (the domain layer that constructs records already does,
|
|
34
|
+
* and the design places record validation in the persistence worker, phase 2);
|
|
35
|
+
* it performs the same cheap structural checks the file store relies on
|
|
36
|
+
* (identity presence, taskId matching, referential lookups).
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { isDeepStrictEqual } from "node:util";
|
|
41
|
+
import Database from "better-sqlite3";
|
|
42
|
+
import { mailboxTargetKey } from "../coordination/workMailbox.js";
|
|
43
|
+
import { validateReviewFinding } from "../review/reviewFinding.js";
|
|
44
|
+
import { generateHomeIdentity } from "../repository/homeIdentity.js";
|
|
45
|
+
import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
|
|
46
|
+
import { validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
|
|
47
|
+
import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
|
|
48
|
+
import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
|
|
49
|
+
import { assertHomeWritable } from "./upgradeFence.js";
|
|
50
|
+
import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, executionLaneActiveRunKey, executionLaneActiveRunKeyParts, StorageConflictError, StorageCancelledError, StorageRecordError, FileTaskStore, storedCapabilityGrant, storedReleaseWorkflow, isValidCapabilityGrantTransition, isValidReleaseWorkflowTransition, validateYuiConfig } from "./taskStore.js";
|
|
51
|
+
import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
|
|
52
|
+
import { migrateSqliteSchema, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
|
|
53
|
+
import { inspectStorageSchema } from "./storageSchema.js";
|
|
54
|
+
const DEFAULT_CONFIG = { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION };
|
|
55
|
+
function numericCompare(left, right) {
|
|
56
|
+
return left.localeCompare(right, undefined, { numeric: true });
|
|
57
|
+
}
|
|
58
|
+
/** True when the better-sqlite3 error is a UNIQUE/PRIMARY KEY constraint failure. */
|
|
59
|
+
function isUniqueConstraint(error) {
|
|
60
|
+
return error instanceof Error
|
|
61
|
+
&& "code" in error
|
|
62
|
+
&& error.code === "SQLITE_CONSTRAINT_PRIMARYKEY"
|
|
63
|
+
|| error?.code === "SQLITE_CONSTRAINT_UNIQUE";
|
|
64
|
+
}
|
|
65
|
+
/** Project a leader-role work mailbox's pending batch to a PendingWakeup (mirrors taskStore.ts). */
|
|
66
|
+
function pendingWakeupProjection(mailbox) {
|
|
67
|
+
if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
|
|
68
|
+
|| mailbox.pending === null) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: CURRENT_PENDING_WAKEUP_SCHEMA_VERSION,
|
|
73
|
+
taskId: mailbox.target.taskId,
|
|
74
|
+
reasons: [...mailbox.pending.reasons],
|
|
75
|
+
requestCount: mailbox.pending.requestCount,
|
|
76
|
+
firstRequestedAt: mailbox.pending.firstQueuedAt,
|
|
77
|
+
lastRequestedAt: mailbox.pending.lastQueuedAt
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export class SqliteTaskStore {
|
|
81
|
+
#db;
|
|
82
|
+
#rootDir;
|
|
83
|
+
#migration;
|
|
84
|
+
#inTransaction = false;
|
|
85
|
+
#dirty = false;
|
|
86
|
+
constructor(rootDir, _options = {}) {
|
|
87
|
+
this.#rootDir = rootDir;
|
|
88
|
+
this.#migration = _options.migration ?? false;
|
|
89
|
+
mkdirSync(rootDir, { recursive: true, mode: 0o700 });
|
|
90
|
+
const filename = _options.databaseFilename ?? "yui.db";
|
|
91
|
+
this.#db = new Database(join(rootDir, filename));
|
|
92
|
+
// §4.1 / §9: WAL, no fsync weakening, FKs on, busy timeout for CLI contention.
|
|
93
|
+
this.#db.pragma("journal_mode = WAL");
|
|
94
|
+
this.#db.pragma("synchronous = FULL");
|
|
95
|
+
this.#db.pragma("foreign_keys = ON");
|
|
96
|
+
this.#db.pragma("busy_timeout = 5000");
|
|
97
|
+
this.#db.pragma("wal_autocheckpoint = 1000");
|
|
98
|
+
migrateSqliteSchema(this.#db);
|
|
99
|
+
this.#seedHomeMeta();
|
|
100
|
+
this.#seedConfig();
|
|
101
|
+
}
|
|
102
|
+
rootDirectory() { return this.#rootDir; }
|
|
103
|
+
/** Close the underlying database connection. */
|
|
104
|
+
close() { this.#db.close(); }
|
|
105
|
+
/**
|
|
106
|
+
* The underlying database connection, for read-only diagnostics
|
|
107
|
+
* (`PRAGMA journal_mode`, `PRAGMA quick_check`). Callers must not mutate
|
|
108
|
+
* through this handle.
|
|
109
|
+
*/
|
|
110
|
+
databaseHandle() { return this.#db; }
|
|
111
|
+
// -- transaction primitives -------------------------------------------------
|
|
112
|
+
#seedHomeMeta() {
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const identity = generateHomeIdentity(new Date());
|
|
115
|
+
this.#db.prepare(`INSERT OR IGNORE INTO home_meta (id, home_identity, revision, layout_version, aggregate_version, created_at, updated_at)
|
|
116
|
+
VALUES (1, ?, 0, ?, ?, ?, ?)`).run(JSON.stringify(identity), SQLITE_LAYOUT_VERSION, SQLITE_AGGREGATE_VERSION, now, now);
|
|
117
|
+
}
|
|
118
|
+
#seedConfig() {
|
|
119
|
+
this.#db.prepare(`INSERT OR IGNORE INTO config (id, payload, updated_at) VALUES (1, ?, ?)`).run(JSON.stringify(DEFAULT_CONFIG), new Date().toISOString());
|
|
120
|
+
}
|
|
121
|
+
#now() { return new Date().toISOString(); }
|
|
122
|
+
#json(value) { return JSON.stringify(value); }
|
|
123
|
+
#parse(text) { return JSON.parse(text); }
|
|
124
|
+
#begin() {
|
|
125
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
126
|
+
this.#inTransaction = true;
|
|
127
|
+
this.#dirty = false;
|
|
128
|
+
}
|
|
129
|
+
#commit() {
|
|
130
|
+
this.#db.exec("COMMIT");
|
|
131
|
+
this.#inTransaction = false;
|
|
132
|
+
this.#dirty = false;
|
|
133
|
+
}
|
|
134
|
+
#rollback() {
|
|
135
|
+
try {
|
|
136
|
+
this.#db.exec("ROLLBACK");
|
|
137
|
+
}
|
|
138
|
+
catch { /* already closed */ }
|
|
139
|
+
this.#inTransaction = false;
|
|
140
|
+
this.#dirty = false;
|
|
141
|
+
}
|
|
142
|
+
/** The upgrade-admission fence, honored at the single write moment (§9). */
|
|
143
|
+
#prepareWrite() {
|
|
144
|
+
// The staged migration populates the sidecar database while the upgrade
|
|
145
|
+
// fence is active (the migration IS the upgrade), so it bypasses the
|
|
146
|
+
// per-write admission check. Production stores never set migration mode.
|
|
147
|
+
if (this.#migration)
|
|
148
|
+
return;
|
|
149
|
+
assertHomeWritable(this.#rootDir);
|
|
150
|
+
}
|
|
151
|
+
#bumpRevision() {
|
|
152
|
+
this.#db.prepare("UPDATE home_meta SET revision = revision + 1, updated_at = ? WHERE id = 1").run(this.#now());
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Run a mutating closure. Inside an outer {@link transaction} it joins that
|
|
156
|
+
* transaction (single revision bump at the outer commit); standalone it takes
|
|
157
|
+
* the write lock, checks the fence, bumps the revision, and commits.
|
|
158
|
+
*/
|
|
159
|
+
#mutate(fn) {
|
|
160
|
+
if (this.#inTransaction) {
|
|
161
|
+
const result = fn();
|
|
162
|
+
this.#dirty = true;
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
this.#prepareWrite();
|
|
166
|
+
this.#begin();
|
|
167
|
+
try {
|
|
168
|
+
const result = fn();
|
|
169
|
+
if (!this.#migration) {
|
|
170
|
+
this.#bumpRevision();
|
|
171
|
+
}
|
|
172
|
+
this.#commit();
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
this.#rollback();
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
transaction(execute, options) {
|
|
181
|
+
const run = execute;
|
|
182
|
+
if (this.#inTransaction)
|
|
183
|
+
return run(this);
|
|
184
|
+
this.#begin();
|
|
185
|
+
try {
|
|
186
|
+
const result = run(this);
|
|
187
|
+
if (this.#dirty) {
|
|
188
|
+
this.#prepareWrite();
|
|
189
|
+
if (options?.requestId !== undefined) {
|
|
190
|
+
this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
|
|
191
|
+
}
|
|
192
|
+
// The staged migration sets the revision explicitly via
|
|
193
|
+
// migrationSetHomeMeta; the commit must not bump it.
|
|
194
|
+
if (!this.#migration) {
|
|
195
|
+
this.#bumpRevision();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.#commit();
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
this.#rollback();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Async transaction seam used by queue operations that must inspect Git
|
|
207
|
+
* before committing the durable queue state. The SQLite write transaction
|
|
208
|
+
* remains open across the awaited callback, matching FileTaskStore's
|
|
209
|
+
* transactionAsync semantics and preserving the single-writer boundary. */
|
|
210
|
+
async transactionAsync(execute) {
|
|
211
|
+
if (this.#inTransaction)
|
|
212
|
+
return execute(this);
|
|
213
|
+
this.#prepareWrite();
|
|
214
|
+
this.#begin();
|
|
215
|
+
try {
|
|
216
|
+
const result = await execute(this);
|
|
217
|
+
if (this.#dirty)
|
|
218
|
+
this.#bumpRevision();
|
|
219
|
+
this.#commit();
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.#rollback();
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Bounded runtime-event folds share the same SQLite write transaction. */
|
|
228
|
+
withRuntimeEventTransaction(execute) {
|
|
229
|
+
return this.transaction(() => execute());
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Revision CAS (§5.3): run `execute` only when the global revision is still
|
|
233
|
+
* `expectedRevision`; otherwise throw {@link StorageConflictError}. The check
|
|
234
|
+
* and the increment happen in the same write transaction.
|
|
235
|
+
*/
|
|
236
|
+
transactionWithRevisionCas(expectedRevision, execute, options) {
|
|
237
|
+
if (this.#inTransaction)
|
|
238
|
+
return execute(this);
|
|
239
|
+
this.#begin();
|
|
240
|
+
try {
|
|
241
|
+
const current = this.getRevision();
|
|
242
|
+
if (current !== expectedRevision) {
|
|
243
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`);
|
|
244
|
+
}
|
|
245
|
+
const result = execute(this);
|
|
246
|
+
if (this.#dirty) {
|
|
247
|
+
this.#prepareWrite();
|
|
248
|
+
if (options?.requestId !== undefined) {
|
|
249
|
+
this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
|
|
250
|
+
}
|
|
251
|
+
// The staged migration sets the revision explicitly via
|
|
252
|
+
// migrationSetHomeMeta; the commit must not bump it.
|
|
253
|
+
if (!this.#migration) {
|
|
254
|
+
this.#bumpRevision();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.#commit();
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.#rollback();
|
|
262
|
+
if (error instanceof StorageConflictError)
|
|
263
|
+
throw error;
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** The current global revision (the cross-writer CAS token). */
|
|
268
|
+
getRevision() {
|
|
269
|
+
const row = this.#db.prepare("SELECT revision FROM home_meta WHERE id = 1").get();
|
|
270
|
+
return row.revision;
|
|
271
|
+
}
|
|
272
|
+
getStateRevision() { return this.getRevision(); }
|
|
273
|
+
/**
|
|
274
|
+
* Run an ordered command batch inside one `BEGIN IMMEDIATE … COMMIT`, yielding
|
|
275
|
+
* to the event loop between commands so a cancellation signal can interrupt
|
|
276
|
+
* the batch (§3.1). The persistence worker uses this for `transactionAsync`:
|
|
277
|
+
* a cancelled batch rolls back (the db is unchanged); already-committed
|
|
278
|
+
* batches are not undone (their effects are idempotent and caller-owned).
|
|
279
|
+
*
|
|
280
|
+
* Each command is `{op, args}` where `op` is a `TaskStore` method name. The
|
|
281
|
+
* batch runs on the single writer connection, so writes are serialized exactly
|
|
282
|
+
* as the synchronous {@link transaction} (§3.2). The revision is bumped once
|
|
283
|
+
* at commit when the batch wrote; an optional `requestId` records the effect
|
|
284
|
+
* in the durable outbox for exactly-once replay (§5.4).
|
|
285
|
+
*/
|
|
286
|
+
async transactionAsyncBatch(commands, options = {}) {
|
|
287
|
+
if (this.#inTransaction) {
|
|
288
|
+
// Nested inside a synchronous transaction: run without yielding (the
|
|
289
|
+
// caller already holds the write lock).
|
|
290
|
+
return commands.map((command) => this.#executeCommand(command.op, command.args));
|
|
291
|
+
}
|
|
292
|
+
this.#begin();
|
|
293
|
+
try {
|
|
294
|
+
// Revision CAS (§5.3): the check and the increment happen in the same
|
|
295
|
+
// write transaction, exactly as transactionWithRevisionCas.
|
|
296
|
+
if (options.expectedRevision !== undefined) {
|
|
297
|
+
const current = this.getRevision();
|
|
298
|
+
if (current !== options.expectedRevision) {
|
|
299
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current}).`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const results = [];
|
|
303
|
+
for (const command of commands) {
|
|
304
|
+
if (options.shouldCancel?.() === true) {
|
|
305
|
+
throw new StorageCancelledError(`Storage command batch cancelled before op '${command.op}'.`);
|
|
306
|
+
}
|
|
307
|
+
results.push(this.#executeCommand(command.op, command.args));
|
|
308
|
+
// Yield so the worker's message loop can observe a cancel signal
|
|
309
|
+
// between statements (§3.1). A single-command batch still yields once
|
|
310
|
+
// so a cancel that raced the batch start is honoured before commit.
|
|
311
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
312
|
+
}
|
|
313
|
+
if (options.shouldCancel?.() === true) {
|
|
314
|
+
throw new StorageCancelledError("Storage command batch cancelled before commit.");
|
|
315
|
+
}
|
|
316
|
+
if (this.#dirty) {
|
|
317
|
+
this.#prepareWrite();
|
|
318
|
+
if (options.requestId !== undefined) {
|
|
319
|
+
this.#insertOutbox(options.requestId, options.outboxCommand ?? commands);
|
|
320
|
+
}
|
|
321
|
+
if (!this.#migration) {
|
|
322
|
+
this.#bumpRevision();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
this.#commit();
|
|
326
|
+
return results;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
this.#rollback();
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Invoke a TaskStore method by name (used by the worker's command batches). */
|
|
334
|
+
#executeCommand(op, args) {
|
|
335
|
+
const method = this[op];
|
|
336
|
+
if (typeof method !== "function") {
|
|
337
|
+
throw new StorageRecordError(`Unknown store command: ${op}`);
|
|
338
|
+
}
|
|
339
|
+
return method.apply(this, args);
|
|
340
|
+
}
|
|
341
|
+
// -- outbox (§5.4) ----------------------------------------------------------
|
|
342
|
+
#insertOutbox(requestId, command) {
|
|
343
|
+
try {
|
|
344
|
+
this.#db.prepare(`INSERT INTO outbox (request_id, command, state, created_at) VALUES (?, ?, 'pending', ?)`).run(requestId, this.#json(command), this.#now());
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (isUniqueConstraint(error)) {
|
|
348
|
+
throw new StorageConflictError(`Outbox request already applied: ${requestId}`);
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Enqueue an outbox row idempotently. Returns true when a new row was
|
|
355
|
+
* inserted, false when `requestId` was already present (exactly-once).
|
|
356
|
+
*/
|
|
357
|
+
enqueueOutbox(requestId, command) {
|
|
358
|
+
return this.#mutate(() => {
|
|
359
|
+
const result = this.#db.prepare(`INSERT OR IGNORE INTO outbox (request_id, command, state, created_at) VALUES (?, ?, 'pending', ?)`).run(requestId, this.#json(command), this.#now());
|
|
360
|
+
return result.changes > 0;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/** Outbox rows still awaiting acknowledgement (the replay source after a crash). */
|
|
364
|
+
listPendingOutbox() {
|
|
365
|
+
const rows = this.#db.prepare("SELECT request_id, command, created_at FROM outbox WHERE state = 'pending' ORDER BY outbox_id").all();
|
|
366
|
+
return rows.map((row) => ({
|
|
367
|
+
requestId: row.request_id,
|
|
368
|
+
command: this.#parse(row.command),
|
|
369
|
+
createdAt: row.created_at
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* True when an outbox row already exists for `requestId` (the effect committed).
|
|
374
|
+
* The persistence worker consults this before re-executing a retried write so a
|
|
375
|
+
* main-thread retry after a worker restart never double-applies (§3.1, §5.4).
|
|
376
|
+
*/
|
|
377
|
+
hasOutboxEntry(requestId) {
|
|
378
|
+
const row = this.#db.prepare("SELECT 1 FROM outbox WHERE request_id = ?").get(requestId);
|
|
379
|
+
return row !== undefined;
|
|
380
|
+
}
|
|
381
|
+
/** Mark an outbox row as applied (idempotent). */
|
|
382
|
+
markOutboxApplied(requestId) {
|
|
383
|
+
this.#mutate(() => {
|
|
384
|
+
this.#db.prepare("UPDATE outbox SET state = 'applied', applied_at = ? WHERE request_id = ?").run(this.#now(), requestId);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
// -- ID allocation ----------------------------------------------------------
|
|
388
|
+
/**
|
|
389
|
+
* Global IDs (`task-<n>`, `project-<n>`) from `global_sequences` (§5.3). The
|
|
390
|
+
* file store computes these by scanning existing IDs (a read); the counter is
|
|
391
|
+
* the design's replacement and is allocated without bumping the revision.
|
|
392
|
+
*/
|
|
393
|
+
#nextGlobalId(name) {
|
|
394
|
+
const allocate = this.#db.transaction(() => {
|
|
395
|
+
const row = this.#db.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, 1)
|
|
396
|
+
ON CONFLICT(name) DO UPDATE SET high_water = high_water + 1
|
|
397
|
+
RETURNING high_water`).get(name);
|
|
398
|
+
return `${name}-${row.high_water}`;
|
|
399
|
+
});
|
|
400
|
+
return allocate();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Task-record IDs from `id_sequences` (replaces StoredTask.idHighWaterMarks).
|
|
404
|
+
* Allocating a high-water mark is a durable write, so it bumps the revision
|
|
405
|
+
* exactly as the file store does.
|
|
406
|
+
*/
|
|
407
|
+
#nextTaskRecordId(taskId, kind) {
|
|
408
|
+
this.#requireTask(taskId);
|
|
409
|
+
return this.#mutate(() => {
|
|
410
|
+
const row = this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, 1)
|
|
411
|
+
ON CONFLICT(task_id, kind) DO UPDATE SET high_water = high_water + 1
|
|
412
|
+
RETURNING high_water`).get(taskId, kind);
|
|
413
|
+
return `${TASK_RECORD_ID_PREFIXES[kind]}-${row.high_water}`;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
#peekTaskRecordId(taskId, kind) {
|
|
417
|
+
this.#requireTask(taskId);
|
|
418
|
+
const row = this.#db.prepare("SELECT high_water FROM id_sequences WHERE task_id = ? AND kind = ?").get(taskId, kind);
|
|
419
|
+
return `${TASK_RECORD_ID_PREFIXES[kind]}-${(row?.high_water ?? 0) + 1}`;
|
|
420
|
+
}
|
|
421
|
+
#requireTask(taskId) {
|
|
422
|
+
const row = this.#db.prepare("SELECT 1 FROM task_records WHERE task_id = ?").get(taskId);
|
|
423
|
+
if (row === undefined)
|
|
424
|
+
throw new StorageRecordError(`Task not found: ${taskId}`);
|
|
425
|
+
}
|
|
426
|
+
// -- migration bulk-load helpers (state.json -> SQLite, task-21 §8) ----------
|
|
427
|
+
// These are used only by the staged offline migration, which runs with the
|
|
428
|
+
// `migration` option (fence bypass). They seed infrastructure tables that the
|
|
429
|
+
// document owns (home identity/revision, global and per-task ID high-water
|
|
430
|
+
// marks) so the opened store continues from the same counters.
|
|
431
|
+
/** Preserve the document's Home identity and revision in `home_meta`. */
|
|
432
|
+
migrationSetHomeMeta(identity, revision) {
|
|
433
|
+
this.#mutate(() => {
|
|
434
|
+
this.#db.prepare(`UPDATE home_meta SET home_identity = ?, revision = ?, updated_at = ? WHERE id = 1`).run(this.#json(identity), revision, this.#now());
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/** Seed a global ID high-water mark (task/project) at least `highWater`. */
|
|
438
|
+
migrationSeedGlobalSequence(name, highWater) {
|
|
439
|
+
this.#mutate(() => {
|
|
440
|
+
this.#db.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, ?)
|
|
441
|
+
ON CONFLICT(name) DO UPDATE SET high_water = MAX(high_water, ?)`).run(name, highWater, highWater);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
/** Seed a per-task ID high-water mark at least `highWater`. */
|
|
445
|
+
migrationSeedIdSequence(taskId, kind, highWater) {
|
|
446
|
+
this.#mutate(() => {
|
|
447
|
+
this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, ?)
|
|
448
|
+
ON CONFLICT(task_id, kind) DO UPDATE SET high_water = MAX(high_water, ?)`).run(taskId, kind, highWater, highWater);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
// -- generic payload helpers ------------------------------------------------
|
|
452
|
+
#getPayload(table, where, params) {
|
|
453
|
+
const row = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).get(...params);
|
|
454
|
+
return row === undefined ? null : this.#parse(row.payload);
|
|
455
|
+
}
|
|
456
|
+
#listPayload(table, where, params) {
|
|
457
|
+
const rows = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).all(...params);
|
|
458
|
+
return rows.map((row) => this.#parse(row.payload));
|
|
459
|
+
}
|
|
460
|
+
#sortById(rows, idOf) {
|
|
461
|
+
return [...rows].sort((left, right) => numericCompare(idOf(left), idOf(right)));
|
|
462
|
+
}
|
|
463
|
+
// -- config / identity ------------------------------------------------------
|
|
464
|
+
getConfig() {
|
|
465
|
+
const row = this.#db.prepare("SELECT payload FROM config WHERE id = 1").get();
|
|
466
|
+
const config = this.#parse(row.payload);
|
|
467
|
+
// Fail closed on malformed durable config, matching the File store:
|
|
468
|
+
// GC mode and other settings must never silently fall back to defaults.
|
|
469
|
+
validateYuiConfig(config);
|
|
470
|
+
return config;
|
|
471
|
+
}
|
|
472
|
+
saveConfig(config) {
|
|
473
|
+
validateYuiConfig(config);
|
|
474
|
+
this.#mutate(() => {
|
|
475
|
+
this.#db.prepare("UPDATE config SET payload = ?, updated_at = ? WHERE id = 1").run(this.#json(config), this.#now());
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
getHomeIdentity() {
|
|
479
|
+
const row = this.#db.prepare("SELECT home_identity FROM home_meta WHERE id = 1").get();
|
|
480
|
+
return this.#parse(row.home_identity);
|
|
481
|
+
}
|
|
482
|
+
getReviewConfig() {
|
|
483
|
+
return this.getConfig().review ?? null;
|
|
484
|
+
}
|
|
485
|
+
// -- configured agents ------------------------------------------------------
|
|
486
|
+
saveConfiguredAgent(agent) {
|
|
487
|
+
this.#mutate(() => {
|
|
488
|
+
this.#db.prepare(`INSERT INTO configured_agents (id, payload, updated_at) VALUES (?, ?, ?)
|
|
489
|
+
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(agent.id, this.#json(agent), this.#now());
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
createConfiguredAgentIfAbsent(agent) {
|
|
493
|
+
return this.#mutate(() => {
|
|
494
|
+
const result = this.#db.prepare("INSERT OR IGNORE INTO configured_agents (id, payload, updated_at) VALUES (?, ?, ?)").run(agent.id, this.#json(agent), this.#now());
|
|
495
|
+
return result.changes > 0 ? agent : null;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
updateConfiguredAgent(id, patch, now) {
|
|
499
|
+
return this.transaction((store) => {
|
|
500
|
+
const existing = store.getConfiguredAgent(id);
|
|
501
|
+
if (existing === null)
|
|
502
|
+
return null;
|
|
503
|
+
const candidate = { ...existing, ...patch, updatedAt: now.toISOString() };
|
|
504
|
+
const unchanged = isDeepStrictEqual({ ...existing, updatedAt: candidate.updatedAt }, candidate);
|
|
505
|
+
if (unchanged)
|
|
506
|
+
return { status: "unchanged", agent: existing };
|
|
507
|
+
store.saveConfiguredAgent(candidate);
|
|
508
|
+
return { status: "updated", agent: candidate };
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
listConfiguredAgents() {
|
|
512
|
+
return this.#sortById(this.#listPayload("configured_agents", "1=1", []), (agent) => agent.id);
|
|
513
|
+
}
|
|
514
|
+
getConfiguredAgent(id) {
|
|
515
|
+
return this.#getPayload("configured_agents", "id = ?", [id]);
|
|
516
|
+
}
|
|
517
|
+
removeConfiguredAgent(id) {
|
|
518
|
+
return this.#mutate(() => {
|
|
519
|
+
const result = this.#db.prepare("DELETE FROM configured_agents WHERE id = ?").run(id);
|
|
520
|
+
return result.changes > 0;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// -- projects ---------------------------------------------------------------
|
|
524
|
+
nextProjectId() { return this.#nextGlobalId("project"); }
|
|
525
|
+
saveProject(project) {
|
|
526
|
+
this.#mutate(() => {
|
|
527
|
+
this.#db.prepare(`INSERT INTO projects (id, name, path, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
528
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, path = excluded.path, payload = excluded.payload, updated_at = excluded.updated_at`).run(project.id, project.name, project.path, this.#json(project), project.createdAt, project.updatedAt);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
createProjectIfAbsent(project) {
|
|
532
|
+
return this.#mutate(() => {
|
|
533
|
+
const result = this.#db.prepare("INSERT OR IGNORE INTO projects (id, name, path, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(project.id, project.name, project.path, this.#json(project), project.createdAt, project.updatedAt);
|
|
534
|
+
return result.changes > 0 ? project : null;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
listProjects() {
|
|
538
|
+
return this.#sortById(this.#listPayload("projects", "1=1", []), (project) => project.id);
|
|
539
|
+
}
|
|
540
|
+
getProject(id) {
|
|
541
|
+
return this.#getPayload("projects", "id = ?", [id]);
|
|
542
|
+
}
|
|
543
|
+
removeProject(id) {
|
|
544
|
+
return this.#mutate(() => this.#db.prepare("DELETE FROM projects WHERE id = ?").run(id).changes > 0);
|
|
545
|
+
}
|
|
546
|
+
// -- agent profiles ---------------------------------------------------------
|
|
547
|
+
saveAgentProfile(profile) {
|
|
548
|
+
this.#mutate(() => {
|
|
549
|
+
this.#db.prepare(`INSERT INTO agent_profiles (id, payload, updated_at) VALUES (?, ?, ?)
|
|
550
|
+
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(profile.id, this.#json(profile), this.#now());
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
createAgentProfileIfAbsent(profile) {
|
|
554
|
+
return this.#mutate(() => {
|
|
555
|
+
const result = this.#db.prepare("INSERT OR IGNORE INTO agent_profiles (id, payload, updated_at) VALUES (?, ?, ?)").run(profile.id, this.#json(profile), this.#now());
|
|
556
|
+
return result.changes > 0 ? profile : null;
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
listAgentProfiles() {
|
|
560
|
+
return this.#sortById(this.#listPayload("agent_profiles", "1=1", []), (profile) => profile.id);
|
|
561
|
+
}
|
|
562
|
+
getAgentProfile(id) {
|
|
563
|
+
return this.#getPayload("agent_profiles", "id = ?", [id]);
|
|
564
|
+
}
|
|
565
|
+
removeAgentProfile(id) {
|
|
566
|
+
return this.#mutate(() => this.#db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id).changes > 0);
|
|
567
|
+
}
|
|
568
|
+
// -- global roles -----------------------------------------------------------
|
|
569
|
+
saveGlobalRole(role) {
|
|
570
|
+
this.#mutate(() => {
|
|
571
|
+
this.#db.prepare(`INSERT INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)
|
|
572
|
+
ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(role), this.#now());
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
saveGlobalRoleWithSessionSet(role, sessions) {
|
|
576
|
+
this.#mutate(() => {
|
|
577
|
+
this.#db.prepare(`INSERT INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)
|
|
578
|
+
ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(role), this.#now());
|
|
579
|
+
if (sessions !== null) {
|
|
580
|
+
this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
|
|
581
|
+
ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(sessions), this.#now());
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
createGlobalRoleIfAbsent(role) {
|
|
586
|
+
return this.#mutate(() => {
|
|
587
|
+
const result = this.#db.prepare("INSERT OR IGNORE INTO global_roles (name, payload, updated_at) VALUES (?, ?, ?)").run(role.name, this.#json(role), this.#now());
|
|
588
|
+
return result.changes > 0 ? role : null;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
listGlobalRoles() {
|
|
592
|
+
return this.#sortById(this.#listPayload("global_roles", "1=1", []), (role) => role.name);
|
|
593
|
+
}
|
|
594
|
+
getGlobalRole(name) {
|
|
595
|
+
return this.#getPayload("global_roles", "name = ?", [name]);
|
|
596
|
+
}
|
|
597
|
+
removeGlobalRole(name) {
|
|
598
|
+
return this.#mutate(() => this.#db.prepare("DELETE FROM global_roles WHERE name = ?").run(name).changes > 0);
|
|
599
|
+
}
|
|
600
|
+
getGlobalRoleSessionSet(name) {
|
|
601
|
+
return this.#getPayload("global_role_session_sets", "name = ?", [name]);
|
|
602
|
+
}
|
|
603
|
+
listGlobalRoleSessionSets() {
|
|
604
|
+
const rows = this.#listPayload("global_role_session_sets", "1=1", []);
|
|
605
|
+
return [...rows].sort((left, right) => numericCompare(left.owner.roleName, right.owner.roleName));
|
|
606
|
+
}
|
|
607
|
+
saveGlobalRoleSessionSet(sessions) {
|
|
608
|
+
this.#mutate(() => {
|
|
609
|
+
this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
|
|
610
|
+
ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.roleName, this.#json(sessions), this.#now());
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
// -- tasks ------------------------------------------------------------------
|
|
614
|
+
nextTaskId() { return this.#nextGlobalId("task"); }
|
|
615
|
+
saveTask(task) {
|
|
616
|
+
if (typeof task.id !== "string" || task.id.length === 0) {
|
|
617
|
+
throw new StorageRecordError("Task id is required.");
|
|
618
|
+
}
|
|
619
|
+
this.#mutate(() => {
|
|
620
|
+
for (const binding of task.projectBindings) {
|
|
621
|
+
const found = this.#db.prepare("SELECT 1 FROM projects WHERE id = ?").get(binding.projectId);
|
|
622
|
+
if (found === undefined)
|
|
623
|
+
throw new StorageRecordError(`Task Project not found: ${binding.projectId}`);
|
|
624
|
+
}
|
|
625
|
+
const isActive = task.status === "active" ? 1 : 0;
|
|
626
|
+
// The catalog projection is inserted first because task_records FKs it.
|
|
627
|
+
this.#db.prepare(`INSERT INTO tasks_catalog (task_id, status, lifecycle, is_active, created_at, updated_at)
|
|
628
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
629
|
+
ON CONFLICT(task_id) DO UPDATE SET status = excluded.status, lifecycle = excluded.lifecycle,
|
|
630
|
+
is_active = excluded.is_active, updated_at = excluded.updated_at`).run(task.id, task.status, task.status, isActive, task.createdAt, task.updatedAt);
|
|
631
|
+
this.#db.prepare(`INSERT INTO task_records (task_id, payload, updated_at) VALUES (?, ?, ?)
|
|
632
|
+
ON CONFLICT(task_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(task.id, this.#json(task), this.#now());
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
listTasks() {
|
|
636
|
+
const tasks = this.#listPayload("task_records", "1=1", []);
|
|
637
|
+
return this.#sortById(tasks, (task) => task.id);
|
|
638
|
+
}
|
|
639
|
+
getTask(id) {
|
|
640
|
+
return this.#getPayload("task_records", "task_id = ?", [id]);
|
|
641
|
+
}
|
|
642
|
+
readNextActionFacts(taskId) {
|
|
643
|
+
const task = this.#getPayload("task_records", "task_id = ?", [taskId]);
|
|
644
|
+
if (task === null)
|
|
645
|
+
return null;
|
|
646
|
+
// One indexed query (idx_agent_runs_role_status) covers both the active
|
|
647
|
+
// Runs the projection waits on and the Leader Runs the budget consumes.
|
|
648
|
+
const runs = this.#sortById(this.#listPayload("agent_runs", "task_id = ? AND (status = 'active' OR role_name = 'leader')", [taskId]), (run) => run.id);
|
|
649
|
+
return {
|
|
650
|
+
task: {
|
|
651
|
+
id: task.id,
|
|
652
|
+
status: task.status,
|
|
653
|
+
projectBindings: task.projectBindings
|
|
654
|
+
},
|
|
655
|
+
workItems: this.#sortById(this.#listPayload("work_items", "task_id = ?", [taskId]), (item) => item.id),
|
|
656
|
+
changeSets: this.#sortById(this.#listPayload("change_sets", "task_id = ?", [taskId]), (changeSet) => changeSet.id),
|
|
657
|
+
integrations: this.#sortById(this.#listPayload("integration_attempts", "task_id = ?", [taskId]), (attempt) => attempt.id),
|
|
658
|
+
reviewRounds: this.#sortById(this.#listPayload("review_rounds", "task_id = ?", [taskId]), (round) => round.id),
|
|
659
|
+
openInputRequests: this.#sortById(this.#listPayload("input_requests", "task_id = ? AND status = 'open'", [taskId]), (request) => request.id),
|
|
660
|
+
activeRuns: runs.filter((run) => run.status === "active"),
|
|
661
|
+
leaderRuns: runs.filter((run) => run.roleName === "leader")
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/** Task ids flagged active in the catalog projection (the global active index). */
|
|
665
|
+
listActiveTaskIds() {
|
|
666
|
+
const rows = this.#db.prepare("SELECT task_id FROM tasks_catalog WHERE is_active = 1 ORDER BY task_id").all();
|
|
667
|
+
return rows.map((row) => row.task_id);
|
|
668
|
+
}
|
|
669
|
+
getTaskBrief(taskId) {
|
|
670
|
+
const row = this.#db.prepare("SELECT brief FROM task_records WHERE task_id = ?").get(taskId);
|
|
671
|
+
if (row === undefined || row.brief === null)
|
|
672
|
+
return null;
|
|
673
|
+
return this.#parse(row.brief);
|
|
674
|
+
}
|
|
675
|
+
saveTaskBrief(taskId, brief) {
|
|
676
|
+
this.#requireTask(taskId);
|
|
677
|
+
this.#mutate(() => {
|
|
678
|
+
this.#db.prepare("UPDATE task_records SET brief = ?, updated_at = ? WHERE task_id = ?")
|
|
679
|
+
.run(this.#json(brief), this.#now(), taskId);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
clearTaskBrief(taskId) {
|
|
683
|
+
this.#requireTask(taskId);
|
|
684
|
+
this.#mutate(() => {
|
|
685
|
+
this.#db.prepare("UPDATE task_records SET brief = NULL, updated_at = ? WHERE task_id = ?").run(this.#now(), taskId);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// -- change sets ------------------------------------------------------------
|
|
689
|
+
nextChangeSetId(taskId) { return this.#nextTaskRecordId(taskId, "changeSet"); }
|
|
690
|
+
saveChangeSet(taskId, changeSet) {
|
|
691
|
+
if (changeSet.taskId !== taskId)
|
|
692
|
+
throw new StorageRecordError(`Change set belongs to another Task: ${changeSet.taskId}`);
|
|
693
|
+
this.#requireTask(taskId);
|
|
694
|
+
this.#mutate(() => {
|
|
695
|
+
this.#db.prepare(`INSERT INTO change_sets (task_id, change_set_id, project_id, head_sha, payload, created_at)
|
|
696
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
697
|
+
ON CONFLICT(task_id, change_set_id) DO UPDATE SET project_id = excluded.project_id,
|
|
698
|
+
head_sha = excluded.head_sha, payload = excluded.payload`).run(taskId, changeSet.id, changeSet.projectId, changeSet.headCommit, this.#json(changeSet), changeSet.createdAt);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
listChangeSets(taskId) {
|
|
702
|
+
return this.#sortById(this.#listPayload("change_sets", "task_id = ?", [taskId]), (changeSet) => changeSet.id);
|
|
703
|
+
}
|
|
704
|
+
getChangeSet(taskId, changeSetId) {
|
|
705
|
+
return this.#getPayload("change_sets", "task_id = ? AND change_set_id = ?", [taskId, changeSetId]);
|
|
706
|
+
}
|
|
707
|
+
// -- integration attempts ---------------------------------------------------
|
|
708
|
+
nextIntegrationAttemptId(taskId) { return this.#nextTaskRecordId(taskId, "integrationAttempt"); }
|
|
709
|
+
saveIntegrationAttempt(taskId, attempt) {
|
|
710
|
+
if (attempt.taskId !== taskId)
|
|
711
|
+
throw new StorageRecordError(`Integration attempt belongs to another Task: ${attempt.taskId}`);
|
|
712
|
+
this.#requireTask(taskId);
|
|
713
|
+
this.#mutate(() => {
|
|
714
|
+
this.#db.prepare(`INSERT INTO integration_attempts (task_id, integration_id, status, payload, updated_at)
|
|
715
|
+
VALUES (?, ?, ?, ?, ?)
|
|
716
|
+
ON CONFLICT(task_id, integration_id) DO UPDATE SET status = excluded.status,
|
|
717
|
+
payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, attempt.id, attempt.status, this.#json(attempt), this.#now());
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
listIntegrationAttempts(taskId) {
|
|
721
|
+
return this.#sortById(this.#listPayload("integration_attempts", "task_id = ?", [taskId]), (attempt) => attempt.id);
|
|
722
|
+
}
|
|
723
|
+
getIntegrationAttempt(taskId, integrationId) {
|
|
724
|
+
return this.#getPayload("integration_attempts", "task_id = ? AND integration_id = ?", [taskId, integrationId]);
|
|
725
|
+
}
|
|
726
|
+
// -- integration queue -----------------------------------------------------
|
|
727
|
+
nextIntegrationQueueEntryId(taskId) {
|
|
728
|
+
return this.#nextTaskRecordId(taskId, "integrationQueue");
|
|
729
|
+
}
|
|
730
|
+
saveIntegrationQueueEntry(taskId, entry) {
|
|
731
|
+
if (entry.taskId !== taskId) {
|
|
732
|
+
throw new StorageRecordError(`Integration queue entry belongs to another Task: ${entry.taskId}`);
|
|
733
|
+
}
|
|
734
|
+
validateIntegrationQueueEntry(entry);
|
|
735
|
+
this.#requireTask(taskId);
|
|
736
|
+
const changeSet = this.getChangeSet(taskId, entry.changeSetId);
|
|
737
|
+
if (changeSet === null) {
|
|
738
|
+
throw new StorageRecordError(`Integration queue ChangeSet not found: ${entry.changeSetId}`);
|
|
739
|
+
}
|
|
740
|
+
if (changeSet.projectId !== entry.projectId) {
|
|
741
|
+
throw new StorageRecordError(`Integration queue ChangeSet belongs to another Project: ${entry.changeSetId}`);
|
|
742
|
+
}
|
|
743
|
+
const existing = this.getIntegrationQueueEntry(taskId, entry.id);
|
|
744
|
+
if (existing !== null) {
|
|
745
|
+
if (Date.parse(entry.updatedAt) < Date.parse(existing.updatedAt)) {
|
|
746
|
+
throw new StorageRecordError(`Integration queue entry updatedAt cannot move backwards: ${entry.id}`);
|
|
747
|
+
}
|
|
748
|
+
if (!validIntegrationQueueTransition(existing, entry)) {
|
|
749
|
+
throw new StorageRecordError(`Integration queue entry transition is invalid: ${entry.id}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
this.#mutate(() => {
|
|
753
|
+
this.#db.prepare(`INSERT INTO integration_queue (queue_id, task_id, project_id, change_set, status, payload, created_at, updated_at)
|
|
754
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
755
|
+
ON CONFLICT(queue_id) DO UPDATE SET task_id = excluded.task_id,
|
|
756
|
+
project_id = excluded.project_id, change_set = excluded.change_set,
|
|
757
|
+
status = excluded.status, payload = excluded.payload, updated_at = excluded.updated_at`).run(entry.id, taskId, entry.projectId, entry.changeSetId, entry.status, this.#json(entry), entry.createdAt, entry.updatedAt);
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
listIntegrationQueueEntries(taskId) {
|
|
761
|
+
return this.#sortById(this.#listPayload("integration_queue", "task_id = ?", [taskId]), (entry) => entry.id);
|
|
762
|
+
}
|
|
763
|
+
getIntegrationQueueEntry(taskId, entryId) {
|
|
764
|
+
return this.#getPayload("integration_queue", "task_id = ? AND queue_id = ?", [taskId, entryId]);
|
|
765
|
+
}
|
|
766
|
+
// -- durable jobs -----------------------------------------------------------
|
|
767
|
+
nextDurableJobId(taskId) {
|
|
768
|
+
return this.#nextTaskRecordId(taskId, "durableJob");
|
|
769
|
+
}
|
|
770
|
+
saveDurableJob(taskId, job) {
|
|
771
|
+
if (job.taskId !== taskId) {
|
|
772
|
+
throw new StorageRecordError(`DurableJob belongs to another Task: ${job.taskId}`);
|
|
773
|
+
}
|
|
774
|
+
validateDurableJob(job);
|
|
775
|
+
this.#requireTask(taskId);
|
|
776
|
+
const existing = this.getDurableJob(taskId, job.id);
|
|
777
|
+
if (existing !== null) {
|
|
778
|
+
if (Date.parse(job.updatedAt) < Date.parse(existing.updatedAt)) {
|
|
779
|
+
throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${job.id}`);
|
|
780
|
+
}
|
|
781
|
+
if (!validDurableJobTransition(existing, job)) {
|
|
782
|
+
throw new StorageRecordError(`DurableJob transition is invalid: ${job.id}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
this.#mutate(() => {
|
|
786
|
+
this.#db.prepare(`INSERT INTO durable_jobs (job_id, task_id, idempotency_key, status, payload, created_at, updated_at)
|
|
787
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
788
|
+
ON CONFLICT(task_id, job_id) DO UPDATE SET idempotency_key = excluded.idempotency_key,
|
|
789
|
+
status = excluded.status,
|
|
790
|
+
payload = excluded.payload, updated_at = excluded.updated_at`).run(job.id, taskId, job.idempotencyKey ?? null, job.status, this.#json(job), job.createdAt, job.updatedAt);
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
listDurableJobs(taskId) {
|
|
794
|
+
return this.#sortById(this.#listPayload("durable_jobs", "task_id = ?", [taskId]), (job) => job.id);
|
|
795
|
+
}
|
|
796
|
+
getDurableJob(taskId, jobId) {
|
|
797
|
+
return this.#getPayload("durable_jobs", "task_id = ? AND job_id = ?", [taskId, jobId]);
|
|
798
|
+
}
|
|
799
|
+
findDurableJobByIdempotencyKey(taskId, key) {
|
|
800
|
+
return this.#getPayload("durable_jobs", "task_id = ? AND idempotency_key = ?", [taskId, key]);
|
|
801
|
+
}
|
|
802
|
+
listAllDurableJobs() {
|
|
803
|
+
return this.#listPayload("durable_jobs", "1 = 1", []);
|
|
804
|
+
}
|
|
805
|
+
hasActiveDurableJobs() {
|
|
806
|
+
const row = this.#db.prepare("SELECT 1 FROM durable_jobs WHERE status IN ('queued', 'running') LIMIT 1").get();
|
|
807
|
+
return row !== undefined;
|
|
808
|
+
}
|
|
809
|
+
// -- job caller key hashes (rr13) -------------------------------------------
|
|
810
|
+
getJobCallerKeyHash(taskId, roleName, agentId) {
|
|
811
|
+
const row = this.#db.prepare("SELECT hash FROM job_caller_key_hashes WHERE task_id = ? AND role_name = ? AND agent_id = ?").get(taskId, roleName, agentId);
|
|
812
|
+
return row === undefined ? null : row.hash;
|
|
813
|
+
}
|
|
814
|
+
setJobCallerKeyHash(taskId, roleName, agentId, hash) {
|
|
815
|
+
this.#requireTask(taskId);
|
|
816
|
+
if (!/^[a-f0-9]{64}$/u.test(hash)) {
|
|
817
|
+
throw new StorageRecordError(`Job caller key hash is invalid: ${taskId}/${roleName}.`);
|
|
818
|
+
}
|
|
819
|
+
this.#mutate(() => {
|
|
820
|
+
this.#db.prepare(`INSERT INTO job_caller_key_hashes (task_id, role_name, agent_id, hash, updated_at)
|
|
821
|
+
VALUES (?, ?, ?, ?, ?)
|
|
822
|
+
ON CONFLICT(task_id, role_name, agent_id) DO UPDATE SET hash = excluded.hash, updated_at = excluded.updated_at`).run(taskId, roleName, agentId, hash, this.#now());
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
// -- session owners (Issue 03) ----------------------------------------------
|
|
826
|
+
saveSessionOwner(identity) {
|
|
827
|
+
const owner = identity.owner;
|
|
828
|
+
this.#mutate(() => {
|
|
829
|
+
this.#db.prepare(`INSERT INTO session_owners
|
|
830
|
+
(launch_id, scope, task_id, role_name, agent_id, native_session_id,
|
|
831
|
+
provider_root_pid, payload, recorded_at)
|
|
832
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
833
|
+
ON CONFLICT(launch_id) DO UPDATE SET
|
|
834
|
+
scope = excluded.scope, task_id = excluded.task_id,
|
|
835
|
+
role_name = excluded.role_name, agent_id = excluded.agent_id,
|
|
836
|
+
native_session_id = excluded.native_session_id,
|
|
837
|
+
provider_root_pid = excluded.provider_root_pid,
|
|
838
|
+
payload = excluded.payload, recorded_at = excluded.recorded_at`).run(identity.launchId, owner.scope, owner.scope === "task" ? owner.taskId : null, owner.roleName, identity.agentId, identity.nativeSessionId ?? null, identity.providerRoot.pid, this.#json(identity), identity.recordedAt);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
getSessionOwner(launchId) {
|
|
842
|
+
const row = this.#db.prepare("SELECT payload FROM session_owners WHERE launch_id = ?").get(launchId);
|
|
843
|
+
return row === undefined ? null : this.#parse(row.payload);
|
|
844
|
+
}
|
|
845
|
+
listSessionOwners() {
|
|
846
|
+
return this.#listPayload("session_owners", "1=1", []).sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
|
|
847
|
+
}
|
|
848
|
+
listSessionOwnersForOwner(owner) {
|
|
849
|
+
if (owner.scope === "global") {
|
|
850
|
+
return this.#listPayload("session_owners", "scope = 'global' AND role_name = ?", [owner.roleName]);
|
|
851
|
+
}
|
|
852
|
+
return this.#listPayload("session_owners", "scope = 'task' AND task_id = ? AND role_name = ?", [owner.taskId, owner.roleName]);
|
|
853
|
+
}
|
|
854
|
+
removeSessionOwner(launchId) {
|
|
855
|
+
this.#mutate(() => {
|
|
856
|
+
this.#db.prepare("DELETE FROM session_owners WHERE launch_id = ?").run(launchId);
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
// -- task roles -------------------------------------------------------------
|
|
860
|
+
saveRole(taskId, role) {
|
|
861
|
+
this.#requireTask(taskId);
|
|
862
|
+
this.#mutate(() => {
|
|
863
|
+
this.#db.prepare(`INSERT INTO task_roles (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
864
|
+
ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, role.name, this.#json(role), this.#now());
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
listRoles(taskId) {
|
|
868
|
+
return this.#sortById(this.#listPayload("task_roles", "task_id = ?", [taskId]), (role) => role.name);
|
|
869
|
+
}
|
|
870
|
+
getRole(taskId, name) {
|
|
871
|
+
return this.#getPayload("task_roles", "task_id = ? AND role_name = ?", [taskId, name]);
|
|
872
|
+
}
|
|
873
|
+
saveTaskRoleWithSessionSet(role, sessions) {
|
|
874
|
+
this.#requireTask(role.name ? role.taskId : sessions.owner.taskId);
|
|
875
|
+
this.#mutate(() => {
|
|
876
|
+
this.#db.prepare(`INSERT INTO task_roles (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
877
|
+
ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.taskId, role.name, this.#json(role), this.#now());
|
|
878
|
+
this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
879
|
+
ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.taskId, sessions.owner.roleName, this.#json(sessions), this.#now());
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
removeTaskRole(taskId, name) {
|
|
883
|
+
return this.#mutate(() => {
|
|
884
|
+
const result = this.#db.prepare("DELETE FROM task_roles WHERE task_id = ? AND role_name = ?").run(taskId, name);
|
|
885
|
+
return result.changes > 0;
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
// -- managed workspaces -----------------------------------------------------
|
|
889
|
+
saveManagedWorkspace(workspace) {
|
|
890
|
+
const taskId = workspace.owner.taskId;
|
|
891
|
+
this.#requireTask(taskId);
|
|
892
|
+
this.#mutate(() => {
|
|
893
|
+
const ownerId = managedWorkspaceKey(workspace.owner);
|
|
894
|
+
this.#db.prepare(`INSERT INTO managed_workspaces (owner_kind, owner_id, task_id, path, payload, status, created_at, updated_at)
|
|
895
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
896
|
+
ON CONFLICT(owner_kind, owner_id) DO UPDATE SET task_id = excluded.task_id, path = excluded.path,
|
|
897
|
+
payload = excluded.payload, status = excluded.status, updated_at = excluded.updated_at`).run(workspace.owner.type, ownerId, taskId, workspace.root, this.#json(workspace), "active", workspace.createdAt, workspace.updatedAt);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
listManagedWorkspaces(taskId) {
|
|
901
|
+
return this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner));
|
|
902
|
+
}
|
|
903
|
+
listManagedWorkspace(taskId) {
|
|
904
|
+
return this.listManagedWorkspaces(taskId);
|
|
905
|
+
}
|
|
906
|
+
getManagedWorkspace(owner) {
|
|
907
|
+
return this.#getPayload("managed_workspaces", "owner_kind = ? AND owner_id = ?", [owner.type, managedWorkspaceKey(owner)]);
|
|
908
|
+
}
|
|
909
|
+
getTaskWorkspace(taskId) {
|
|
910
|
+
return this.getManagedWorkspace({ type: "task", taskId });
|
|
911
|
+
}
|
|
912
|
+
getWorkItemWorkspace(taskId, workItemId) {
|
|
913
|
+
return this.getManagedWorkspace({ type: "work-item", taskId, workItemId });
|
|
914
|
+
}
|
|
915
|
+
getReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
916
|
+
return this.getManagedWorkspace({ type: "review-round", taskId, reviewRoundId });
|
|
917
|
+
}
|
|
918
|
+
getIntegrationWorkspace(taskId, integrationAttemptId) {
|
|
919
|
+
return this.getManagedWorkspace({ type: "integration-attempt", taskId, integrationAttemptId });
|
|
920
|
+
}
|
|
921
|
+
removeManagedWorkspace(owner) {
|
|
922
|
+
this.#requireTask(owner.taskId);
|
|
923
|
+
return this.#mutate(() => {
|
|
924
|
+
const result = this.#db.prepare("DELETE FROM managed_workspaces WHERE owner_kind = ? AND owner_id = ?").run(owner.type, managedWorkspaceKey(owner));
|
|
925
|
+
return result.changes > 0;
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
// -- role session sets ------------------------------------------------------
|
|
929
|
+
getRoleSessionSet(taskId, roleName) {
|
|
930
|
+
return this.#getPayload("role_session_sets", "task_id = ? AND role_name = ?", [taskId, roleName]);
|
|
931
|
+
}
|
|
932
|
+
getTaskRoleSessionSet(taskId, roleName) {
|
|
933
|
+
return this.getRoleSessionSet(taskId, roleName);
|
|
934
|
+
}
|
|
935
|
+
listRoleSessionSets(taskId) {
|
|
936
|
+
return this.#sortById(this.#listPayload("role_session_sets", "task_id = ?", [taskId]), (set) => set.owner.roleName);
|
|
937
|
+
}
|
|
938
|
+
saveRoleSessionSet(sessions) {
|
|
939
|
+
const taskId = sessions.owner.taskId;
|
|
940
|
+
this.#requireTask(taskId);
|
|
941
|
+
this.#mutate(() => {
|
|
942
|
+
this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
943
|
+
ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, sessions.owner.roleName, this.#json(sessions), this.#now());
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
saveTaskRoleSessionSet(sessions) {
|
|
947
|
+
this.saveRoleSessionSet(sessions);
|
|
948
|
+
}
|
|
949
|
+
getRoleSession(taskId, roleName) {
|
|
950
|
+
const set = this.getRoleSessionSet(taskId, roleName);
|
|
951
|
+
if (set === null)
|
|
952
|
+
return null;
|
|
953
|
+
const session = set.sessions[set.activeAgentId];
|
|
954
|
+
return session === undefined ? null : session;
|
|
955
|
+
}
|
|
956
|
+
// -- work items -------------------------------------------------------------
|
|
957
|
+
nextWorkItemId(taskId) { return this.#nextTaskRecordId(taskId, "workItem"); }
|
|
958
|
+
getWorkItem(taskId, workItemId) {
|
|
959
|
+
return this.#getPayload("work_items", "task_id = ? AND work_item_id = ?", [taskId, workItemId]);
|
|
960
|
+
}
|
|
961
|
+
listWorkItems(taskId) {
|
|
962
|
+
return this.#sortById(this.#listPayload("work_items", "task_id = ?", [taskId]), (item) => item.id);
|
|
963
|
+
}
|
|
964
|
+
saveWorkItem(taskId, item) {
|
|
965
|
+
if (item.taskId !== taskId)
|
|
966
|
+
throw new StorageRecordError(`Work item belongs to another Task: ${item.taskId}`);
|
|
967
|
+
this.#requireTask(taskId);
|
|
968
|
+
this.#mutate(() => {
|
|
969
|
+
this.#db.prepare(`INSERT INTO work_items (task_id, work_item_id, status, payload, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
970
|
+
ON CONFLICT(task_id, work_item_id) DO UPDATE SET status = excluded.status,
|
|
971
|
+
payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, item.id, item.status, this.#json(item), this.#now());
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
// -- capability grants ------------------------------------------------------
|
|
975
|
+
nextCapabilityGrantId(taskId) { return this.#nextTaskRecordId(taskId, "capabilityGrant"); }
|
|
976
|
+
saveCapabilityGrant(taskId, grant) {
|
|
977
|
+
const stored = storedCapabilityGrant(grant);
|
|
978
|
+
if (stored.taskId !== taskId) {
|
|
979
|
+
throw new StorageRecordError(`Capability grant belongs to another Task: ${stored.taskId}`);
|
|
980
|
+
}
|
|
981
|
+
this.#requireTask(taskId);
|
|
982
|
+
this.#mutate(() => {
|
|
983
|
+
const existing = this.#getPayload("capability_grants", "task_id = ? AND grant_id = ?", [taskId, stored.id]);
|
|
984
|
+
if (existing === null) {
|
|
985
|
+
if (stored.revokedAt !== undefined) {
|
|
986
|
+
throw new StorageRecordError(`Capability grant must start unrevoked: ${stored.id}`);
|
|
987
|
+
}
|
|
988
|
+
if (stored.usesUsed !== 0) {
|
|
989
|
+
throw new StorageRecordError(`Capability grant must start unused: ${stored.id}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
else if (!isValidCapabilityGrantTransition(existing, stored)) {
|
|
993
|
+
throw new StorageRecordError(`Capability grant cannot be overwritten: ${taskId}/${stored.id}`);
|
|
994
|
+
}
|
|
995
|
+
this.#db.prepare(`INSERT INTO capability_grants (task_id, grant_id, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
996
|
+
ON CONFLICT(task_id, grant_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, stored.id, this.#json(stored), this.#now());
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
listCapabilityGrants(taskId) {
|
|
1000
|
+
return this.#sortById(this.#listPayload("capability_grants", "task_id = ?", [taskId]), (grant) => grant.id);
|
|
1001
|
+
}
|
|
1002
|
+
getCapabilityGrant(taskId, grantId) {
|
|
1003
|
+
return this.#getPayload("capability_grants", "task_id = ? AND grant_id = ?", [taskId, grantId]);
|
|
1004
|
+
}
|
|
1005
|
+
// -- release workflows ------------------------------------------------------
|
|
1006
|
+
nextReleaseWorkflowId(taskId) { return this.#nextTaskRecordId(taskId, "releaseWorkflow"); }
|
|
1007
|
+
saveReleaseWorkflow(taskId, workflow) {
|
|
1008
|
+
const stored = storedReleaseWorkflow(workflow);
|
|
1009
|
+
if (stored.taskId !== taskId) {
|
|
1010
|
+
throw new StorageRecordError(`Release workflow belongs to another Task: ${stored.taskId}`);
|
|
1011
|
+
}
|
|
1012
|
+
this.#requireTask(taskId);
|
|
1013
|
+
this.#mutate(() => {
|
|
1014
|
+
const existing = this.#getPayload("release_workflows", "task_id = ? AND workflow_id = ?", [taskId, stored.id]);
|
|
1015
|
+
if (existing !== null && !isValidReleaseWorkflowTransition(existing, stored)) {
|
|
1016
|
+
throw new StorageRecordError(`Release workflow cannot be overwritten: ${taskId}/${stored.id}`);
|
|
1017
|
+
}
|
|
1018
|
+
this.#db.prepare(`INSERT INTO release_workflows (task_id, workflow_id, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
1019
|
+
ON CONFLICT(task_id, workflow_id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, stored.id, this.#json(stored), this.#now());
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
listReleaseWorkflows(taskId) {
|
|
1023
|
+
return this.#sortById(this.#listPayload("release_workflows", "task_id = ?", [taskId]), (workflow) => workflow.id);
|
|
1024
|
+
}
|
|
1025
|
+
getReleaseWorkflow(taskId, workflowId) {
|
|
1026
|
+
return this.#getPayload("release_workflows", "task_id = ? AND workflow_id = ?", [taskId, workflowId]);
|
|
1027
|
+
}
|
|
1028
|
+
// -- gate artifacts (Issue 08) ----------------------------------------------
|
|
1029
|
+
saveGateArtifact(artifact, logs) {
|
|
1030
|
+
validateGateArtifact(artifact);
|
|
1031
|
+
this.#mutate(() => {
|
|
1032
|
+
const targetRef = artifact.boundary?.targetRef ?? null;
|
|
1033
|
+
const completedAt = artifact.completedAt ?? null;
|
|
1034
|
+
this.#db.prepare(`INSERT INTO gate_artifacts
|
|
1035
|
+
(key, project_id, level, commit_sha, plan_digest, toolchain_digest,
|
|
1036
|
+
target_ref, status, outcome, payload, created_at, completed_at, last_used_at)
|
|
1037
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1038
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
1039
|
+
project_id = excluded.project_id,
|
|
1040
|
+
level = excluded.level,
|
|
1041
|
+
commit_sha = excluded.commit_sha,
|
|
1042
|
+
plan_digest = excluded.plan_digest,
|
|
1043
|
+
toolchain_digest = excluded.toolchain_digest,
|
|
1044
|
+
target_ref = excluded.target_ref,
|
|
1045
|
+
status = excluded.status,
|
|
1046
|
+
outcome = excluded.outcome,
|
|
1047
|
+
payload = excluded.payload,
|
|
1048
|
+
completed_at = excluded.completed_at,
|
|
1049
|
+
last_used_at = excluded.last_used_at`).run(artifact.key, artifact.projectId, artifact.level, artifact.commit, artifact.planDigest, artifact.toolchainDigest, targetRef, artifact.status, artifact.outcome, this.#json(artifact), artifact.createdAt, completedAt, artifact.lastUsedAt);
|
|
1050
|
+
// Replace all logs for this artifact in the same transaction.
|
|
1051
|
+
this.#db.prepare("DELETE FROM gate_artifact_logs WHERE artifact_key = ?").run(artifact.key);
|
|
1052
|
+
const insertLog = this.#db.prepare(`INSERT INTO gate_artifact_logs (artifact_key, step_name, log_content, log_digest, log_bytes)
|
|
1053
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
1054
|
+
for (const [stepName, content] of logs) {
|
|
1055
|
+
const step = artifact.steps.find((s) => s.name === stepName);
|
|
1056
|
+
if (step === undefined) {
|
|
1057
|
+
throw new StorageRecordError(`Gate artifact log has no matching step: ${artifact.key}/${stepName}`);
|
|
1058
|
+
}
|
|
1059
|
+
insertLog.run(artifact.key, stepName, content, step.logDigest, step.logBytes);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
touchGateArtifact(artifact) {
|
|
1064
|
+
validateGateArtifact(artifact);
|
|
1065
|
+
this.#mutate(() => {
|
|
1066
|
+
const result = this.#db.prepare(`UPDATE gate_artifacts SET payload = ?, last_used_at = ? WHERE key = ?`).run(this.#json(artifact), artifact.lastUsedAt, artifact.key);
|
|
1067
|
+
if (result.changes === 0) {
|
|
1068
|
+
throw new StorageRecordError(`Gate artifact not found for touch: ${artifact.key}`);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
getGateArtifact(projectId, key) {
|
|
1073
|
+
const artifact = this.#getPayload("gate_artifacts", "project_id = ? AND key = ?", [projectId, key]);
|
|
1074
|
+
return artifact === null ? null : validateGateArtifact(artifact);
|
|
1075
|
+
}
|
|
1076
|
+
findGateArtifactByIdentity(identity) {
|
|
1077
|
+
return this.getGateArtifact(identity.projectId, gateArtifactKey(identity));
|
|
1078
|
+
}
|
|
1079
|
+
findL2GateArtifactsForCommit(query) {
|
|
1080
|
+
const rows = this.#listPayload("gate_artifacts", `project_id = ? AND commit_sha = ? AND level = 'L2'
|
|
1081
|
+
AND plan_digest = ? AND toolchain_digest = ? AND target_ref = ?
|
|
1082
|
+
AND status = 'complete' AND outcome = 'succeeded'`, [query.projectId, query.commit, query.planDigest, query.toolchainDigest, query.targetRef]);
|
|
1083
|
+
const valid = [];
|
|
1084
|
+
for (const row of rows) {
|
|
1085
|
+
try {
|
|
1086
|
+
valid.push(validateGateArtifact(row));
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
// Skip corrupt rows; a direct lookup fails closed.
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return valid;
|
|
1093
|
+
}
|
|
1094
|
+
getGateArtifactLogs(artifactKey) {
|
|
1095
|
+
const rows = this.#db.prepare("SELECT step_name, log_content FROM gate_artifact_logs WHERE artifact_key = ?").all(artifactKey);
|
|
1096
|
+
const logs = new Map();
|
|
1097
|
+
for (const row of rows) {
|
|
1098
|
+
logs.set(row.step_name, Buffer.from(row.log_content));
|
|
1099
|
+
}
|
|
1100
|
+
return logs;
|
|
1101
|
+
}
|
|
1102
|
+
pruneGateArtifacts(projectId, options) {
|
|
1103
|
+
// Snapshot candidates outside the write transaction so the isReferenced
|
|
1104
|
+
// callback never holds the SQLite write lock.
|
|
1105
|
+
const rows = this.#db.prepare("SELECT key, payload, last_used_at FROM gate_artifacts WHERE project_id = ?").all(projectId);
|
|
1106
|
+
const toDelete = [];
|
|
1107
|
+
let retained = 0;
|
|
1108
|
+
for (const row of rows) {
|
|
1109
|
+
let artifact;
|
|
1110
|
+
try {
|
|
1111
|
+
artifact = validateGateArtifact(JSON.parse(row.payload));
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
retained += 1;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const age = options.now.getTime() - Date.parse(artifact.lastUsedAt);
|
|
1118
|
+
if (options.isReferenced(artifact.key) || age < options.ttlMs) {
|
|
1119
|
+
retained += 1;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
toDelete.push(artifact.key);
|
|
1123
|
+
}
|
|
1124
|
+
if (toDelete.length === 0) {
|
|
1125
|
+
return Object.freeze({ retained, deleted: 0 });
|
|
1126
|
+
}
|
|
1127
|
+
return this.#mutate(() => {
|
|
1128
|
+
const deleteArtifact = this.#db.prepare("DELETE FROM gate_artifacts WHERE key = ?");
|
|
1129
|
+
for (const key of toDelete) {
|
|
1130
|
+
deleteArtifact.run(key);
|
|
1131
|
+
}
|
|
1132
|
+
return Object.freeze({ retained, deleted: toDelete.length });
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
// -- agent runs -------------------------------------------------------------
|
|
1136
|
+
nextAgentRunId(taskId) { return this.#nextTaskRecordId(taskId, "agentRun"); }
|
|
1137
|
+
peekNextAgentRunId(taskId) { return this.#peekTaskRecordId(taskId, "agentRun"); }
|
|
1138
|
+
getAgentRun(taskId, runId) {
|
|
1139
|
+
return this.#getPayload("agent_runs", "task_id = ? AND run_id = ?", [taskId, runId]);
|
|
1140
|
+
}
|
|
1141
|
+
listAgentRuns(taskId) {
|
|
1142
|
+
return this.#sortById(this.#listPayload("agent_runs", "task_id = ?", [taskId]), (run) => run.id);
|
|
1143
|
+
}
|
|
1144
|
+
saveAgentRun(run) {
|
|
1145
|
+
if (run.taskId !== undefined)
|
|
1146
|
+
this.#requireTask(run.taskId);
|
|
1147
|
+
this.#mutate(() => {
|
|
1148
|
+
this.#db.prepare(`INSERT INTO agent_runs (task_id, run_id, role_name, status, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
1149
|
+
ON CONFLICT(task_id, run_id) DO UPDATE SET role_name = excluded.role_name, status = excluded.status,
|
|
1150
|
+
payload = excluded.payload, updated_at = excluded.updated_at`).run(run.taskId, run.id, run.roleName, run.status, this.#json(run), this.#now());
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Issue 04: SQLite-native pending retry query. A single indexed scan
|
|
1155
|
+
* replaces the adapter's per-Task in-memory sweep, so Controller deadline
|
|
1156
|
+
* arming no longer materializes every Task and Run in JavaScript.
|
|
1157
|
+
*/
|
|
1158
|
+
listPendingProviderRetries() {
|
|
1159
|
+
const rows = this.#db.prepare(`SELECT ar.task_id AS taskId, ar.run_id AS runId, ar.role_name AS roleName,
|
|
1160
|
+
json_extract(ar.payload, '$.providerRetry.nextAttemptAt') AS nextAttemptAt
|
|
1161
|
+
FROM agent_runs ar
|
|
1162
|
+
JOIN tasks_catalog tc ON tc.task_id = ar.task_id
|
|
1163
|
+
WHERE ar.status = 'active'
|
|
1164
|
+
AND tc.is_active = 1
|
|
1165
|
+
AND json_extract(ar.payload, '$.providerRetry.nextAttemptAt') IS NOT NULL`).all();
|
|
1166
|
+
return rows;
|
|
1167
|
+
}
|
|
1168
|
+
// -- review rounds ----------------------------------------------------------
|
|
1169
|
+
nextReviewRoundId(taskId) { return this.#nextTaskRecordId(taskId, "reviewRound"); }
|
|
1170
|
+
getReviewRound(taskId, reviewRoundId) {
|
|
1171
|
+
return this.#getPayload("review_rounds", "task_id = ? AND review_round_id = ?", [taskId, reviewRoundId]);
|
|
1172
|
+
}
|
|
1173
|
+
listReviewRounds(taskId) {
|
|
1174
|
+
return this.#sortById(this.#listPayload("review_rounds", "task_id = ?", [taskId]), (round) => round.id);
|
|
1175
|
+
}
|
|
1176
|
+
saveReviewRound(taskId, round) {
|
|
1177
|
+
if (round.taskId !== taskId)
|
|
1178
|
+
throw new StorageRecordError(`Review round belongs to another Task: ${round.taskId}`);
|
|
1179
|
+
this.#requireTask(taskId);
|
|
1180
|
+
this.#mutate(() => {
|
|
1181
|
+
this.#db.prepare(`INSERT INTO review_rounds (task_id, review_round_id, status, payload, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
1182
|
+
ON CONFLICT(task_id, review_round_id) DO UPDATE SET status = excluded.status,
|
|
1183
|
+
payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, round.id, round.status, this.#json(round), this.#now());
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
// -- review findings --------------------------------------------------------
|
|
1187
|
+
nextReviewFindingId(taskId) { return this.#nextTaskRecordId(taskId, "reviewFinding"); }
|
|
1188
|
+
getReviewFinding(taskId, findingId) {
|
|
1189
|
+
return this.#getPayload("review_findings", "task_id = ? AND finding_id = ?", [taskId, findingId]);
|
|
1190
|
+
}
|
|
1191
|
+
listReviewFindings(taskId) {
|
|
1192
|
+
return this.#sortById(this.#listPayload("review_findings", "task_id = ?", [taskId]), (finding) => finding.id);
|
|
1193
|
+
}
|
|
1194
|
+
saveReviewFinding(taskId, finding) {
|
|
1195
|
+
validateReviewFinding(finding);
|
|
1196
|
+
if (finding.taskId !== taskId) {
|
|
1197
|
+
throw new StorageRecordError(`Review finding belongs to another Task: ${finding.taskId}`);
|
|
1198
|
+
}
|
|
1199
|
+
this.#requireTask(taskId);
|
|
1200
|
+
this.#mutate(() => {
|
|
1201
|
+
this.#db.prepare(`INSERT INTO review_findings (task_id, finding_id, stable_key, severity, payload, updated_at)
|
|
1202
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1203
|
+
ON CONFLICT(task_id, finding_id) DO UPDATE SET stable_key = excluded.stable_key,
|
|
1204
|
+
severity = excluded.severity, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, finding.id, finding.stableKey, finding.severity, this.#json(finding), this.#now());
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
// -- active runs ------------------------------------------------------------
|
|
1208
|
+
#saveActiveRun(taskId, pointer, runId) {
|
|
1209
|
+
this.#mutate(() => {
|
|
1210
|
+
const payload = this.#json({ schemaVersion: 3, runId });
|
|
1211
|
+
this.#db.prepare(`INSERT INTO active_runs (task_id, pointer, run_id, payload, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
1212
|
+
ON CONFLICT(task_id, pointer) DO UPDATE SET run_id = excluded.run_id, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, pointer, runId, payload, this.#now());
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
#getActiveRun(taskId, pointer) {
|
|
1216
|
+
const row = this.#db.prepare("SELECT run_id FROM active_runs WHERE task_id = ? AND pointer = ?").get(taskId, pointer);
|
|
1217
|
+
if (row === undefined)
|
|
1218
|
+
return null;
|
|
1219
|
+
return this.getAgentRun(taskId, row.run_id);
|
|
1220
|
+
}
|
|
1221
|
+
#clearActiveRun(taskId, pointer) {
|
|
1222
|
+
this.#mutate(() => {
|
|
1223
|
+
this.#db.prepare("DELETE FROM active_runs WHERE task_id = ? AND pointer = ?").run(taskId, pointer);
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
getActiveAgentRun(taskId, roleName) {
|
|
1227
|
+
return this.#getActiveRun(taskId, roleName);
|
|
1228
|
+
}
|
|
1229
|
+
saveActiveAgentRun(run) {
|
|
1230
|
+
if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
|
|
1231
|
+
this.saveActiveExecutionLaneRun(run);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
this.#saveActiveRun(run.taskId, run.roleName, run.id);
|
|
1235
|
+
}
|
|
1236
|
+
clearActiveAgentRun(taskId, roleName) {
|
|
1237
|
+
// Older Controller paths only know the Role key. When that key points
|
|
1238
|
+
// at a lane-backed Run, remove the matching lane pointer too; preserve
|
|
1239
|
+
// every other lane for the same Role in a multi-lane group.
|
|
1240
|
+
const rolePointer = this.#getActiveRun(taskId, roleName);
|
|
1241
|
+
this.#clearActiveRun(taskId, roleName);
|
|
1242
|
+
if (rolePointer === null)
|
|
1243
|
+
return;
|
|
1244
|
+
const laneRows = this.#db.prepare("SELECT task_id, pointer FROM active_runs WHERE task_id = ?").all(taskId);
|
|
1245
|
+
for (const row of laneRows) {
|
|
1246
|
+
if (executionLaneActiveRunKeyParts(row.pointer) !== null) {
|
|
1247
|
+
const laneRun = this.#getActiveRun(taskId, row.pointer);
|
|
1248
|
+
if (laneRun !== null && laneRun.id === rolePointer.id) {
|
|
1249
|
+
this.#clearActiveRun(taskId, row.pointer);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
getActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
|
|
1255
|
+
return this.#getActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
|
|
1256
|
+
}
|
|
1257
|
+
saveActiveExecutionLaneRun(run) {
|
|
1258
|
+
if (run.executionGroupId === undefined || run.executionLaneId === undefined) {
|
|
1259
|
+
throw new StorageRecordError(`Active execution-lane run requires group and lane ids: ${run.id}`);
|
|
1260
|
+
}
|
|
1261
|
+
this.#saveActiveRun(run.taskId, executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId), run.id);
|
|
1262
|
+
}
|
|
1263
|
+
clearActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
|
|
1264
|
+
this.#clearActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
|
|
1265
|
+
}
|
|
1266
|
+
// -- messages ----------------------------------------------------------------
|
|
1267
|
+
nextMessageId(taskId) { return this.#nextTaskRecordId(taskId, "message"); }
|
|
1268
|
+
saveMessage(taskId, message) {
|
|
1269
|
+
if (message.taskId !== taskId)
|
|
1270
|
+
throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
|
|
1271
|
+
this.#requireTask(taskId);
|
|
1272
|
+
this.#mutate(() => {
|
|
1273
|
+
const seq = this.#idSequence(message.id, "message");
|
|
1274
|
+
this.#db.prepare(`INSERT INTO messages (task_id, message_id, seq, payload, created_at) VALUES (?, ?, ?, ?, ?)`).run(taskId, message.id, seq, this.#json(message), message.createdAt);
|
|
1275
|
+
this.#observeHighWater(taskId, "message", seq);
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
listMessages(taskId) {
|
|
1279
|
+
return this.#sortById(this.#listPayload("messages", "task_id = ?", [taskId]), (message) => message.id);
|
|
1280
|
+
}
|
|
1281
|
+
// -- input requests ----------------------------------------------------------
|
|
1282
|
+
nextInputRequestId(taskId) { return this.#nextTaskRecordId(taskId, "inputRequest"); }
|
|
1283
|
+
saveInputRequest(taskId, request) {
|
|
1284
|
+
if (request.taskId !== taskId)
|
|
1285
|
+
throw new StorageRecordError(`Input request belongs to another Task: ${request.taskId}`);
|
|
1286
|
+
this.#requireTask(taskId);
|
|
1287
|
+
this.#mutate(() => {
|
|
1288
|
+
this.#db.prepare(`INSERT INTO input_requests (task_id, input_id, status, payload, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
1289
|
+
ON CONFLICT(task_id, input_id) DO UPDATE SET status = excluded.status, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, request.id, request.status, this.#json(request), request.createdAt, this.#now());
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
getInputRequest(taskId, requestId) {
|
|
1293
|
+
return this.#getPayload("input_requests", "task_id = ? AND input_id = ?", [taskId, requestId]);
|
|
1294
|
+
}
|
|
1295
|
+
listInputRequests(taskId) {
|
|
1296
|
+
return this.#sortById(this.#listPayload("input_requests", "task_id = ?", [taskId]), (request) => request.id);
|
|
1297
|
+
}
|
|
1298
|
+
listAllInputRequests() {
|
|
1299
|
+
return this.#sortById(this.#listPayload("input_requests", "1=1", []), (request) => `${request.taskId}/${request.id}`);
|
|
1300
|
+
}
|
|
1301
|
+
// -- decisions ----------------------------------------------------------------
|
|
1302
|
+
nextDecisionId(taskId) { return this.#nextTaskRecordId(taskId, "decision"); }
|
|
1303
|
+
saveDecision(taskId, decision) {
|
|
1304
|
+
if (decision.taskId !== taskId)
|
|
1305
|
+
throw new StorageRecordError(`Decision belongs to another Task: ${decision.taskId}`);
|
|
1306
|
+
this.#requireTask(taskId);
|
|
1307
|
+
this.#mutate(() => {
|
|
1308
|
+
this.#db.prepare(`INSERT INTO decisions (task_id, decision_id, payload, created_at) VALUES (?, ?, ?, ?)
|
|
1309
|
+
ON CONFLICT(task_id, decision_id) DO UPDATE SET payload = excluded.payload`).run(taskId, decision.id, this.#json(decision), decision.createdAt);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
listDecisions(taskId) {
|
|
1313
|
+
return this.#sortById(this.#listPayload("decisions", "task_id = ?", [taskId]), (decision) => decision.id);
|
|
1314
|
+
}
|
|
1315
|
+
getDecision(taskId, decisionId) {
|
|
1316
|
+
return this.#getPayload("decisions", "task_id = ? AND decision_id = ?", [taskId, decisionId]);
|
|
1317
|
+
}
|
|
1318
|
+
// -- milestones ----------------------------------------------------------------
|
|
1319
|
+
nextMilestoneId(taskId) { return this.#nextTaskRecordId(taskId, "milestone"); }
|
|
1320
|
+
saveMilestone(taskId, milestone) {
|
|
1321
|
+
if (milestone.taskId !== taskId)
|
|
1322
|
+
throw new StorageRecordError(`Milestone belongs to another Task: ${milestone.taskId}`);
|
|
1323
|
+
this.#requireTask(taskId);
|
|
1324
|
+
this.#mutate(() => {
|
|
1325
|
+
this.#db.prepare(`INSERT INTO milestones (task_id, milestone_id, payload, created_at) VALUES (?, ?, ?, ?)
|
|
1326
|
+
ON CONFLICT(task_id, milestone_id) DO UPDATE SET payload = excluded.payload`).run(taskId, milestone.id, this.#json(milestone), milestone.createdAt);
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
listMilestones(taskId) {
|
|
1330
|
+
return this.#sortById(this.#listPayload("milestones", "task_id = ?", [taskId]), (milestone) => milestone.id);
|
|
1331
|
+
}
|
|
1332
|
+
getMilestone(taskId, milestoneId) {
|
|
1333
|
+
return this.#getPayload("milestones", "task_id = ? AND milestone_id = ?", [taskId, milestoneId]);
|
|
1334
|
+
}
|
|
1335
|
+
// -- events -------------------------------------------------------------------
|
|
1336
|
+
nextEventId(taskId) { return this.#nextTaskRecordId(taskId, "event"); }
|
|
1337
|
+
saveEvent(taskId, event) {
|
|
1338
|
+
if (event.taskId !== taskId)
|
|
1339
|
+
throw new StorageRecordError(`Task event belongs to another Task: ${event.taskId}`);
|
|
1340
|
+
this.#requireTask(taskId);
|
|
1341
|
+
this.#mutate(() => {
|
|
1342
|
+
const seq = this.#idSequence(event.id, "event");
|
|
1343
|
+
// Events are terminal/semantic: retained individually, never pruned (§9).
|
|
1344
|
+
this.#db.prepare(`INSERT INTO events (task_id, event_id, type, occurred_at, payload) VALUES (?, ?, ?, ?, ?)`).run(taskId, event.id, event.type, event.createdAt, this.#json(event));
|
|
1345
|
+
this.#observeHighWater(taskId, "event", seq);
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
listEvents(taskId) {
|
|
1349
|
+
return this.#sortById(this.#listPayload("events", "task_id = ?", [taskId]), (event) => event.id);
|
|
1350
|
+
}
|
|
1351
|
+
removeEvents(taskId, eventIds) {
|
|
1352
|
+
if (eventIds.length === 0)
|
|
1353
|
+
return 0;
|
|
1354
|
+
this.#requireTask(taskId);
|
|
1355
|
+
return this.#mutate(() => {
|
|
1356
|
+
const deleteEvent = this.#db.prepare("DELETE FROM events WHERE task_id = ? AND event_id = ?");
|
|
1357
|
+
let removed = 0;
|
|
1358
|
+
for (const eventId of eventIds) {
|
|
1359
|
+
removed += deleteEvent.run(taskId, eventId).changes;
|
|
1360
|
+
}
|
|
1361
|
+
return removed;
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
// -- high-water maintenance ---------------------------------------------------
|
|
1365
|
+
/** Extract the numeric suffix of a `<prefix>-<n>` record id. */
|
|
1366
|
+
#idSequence(id, kind) {
|
|
1367
|
+
const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`).exec(id);
|
|
1368
|
+
if (match === null)
|
|
1369
|
+
throw new StorageRecordError(`Task-local ${kind} id is invalid: ${id}.`);
|
|
1370
|
+
return Number.parseInt(match[1], 10);
|
|
1371
|
+
}
|
|
1372
|
+
/** Advance the per-task high-water mark to at least `seq` (mirrors observeTaskRecordId). */
|
|
1373
|
+
#observeHighWater(taskId, kind, seq) {
|
|
1374
|
+
this.#db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, ?, ?)
|
|
1375
|
+
ON CONFLICT(task_id, kind) DO UPDATE SET high_water = MAX(high_water, ?)`).run(taskId, kind, seq, seq);
|
|
1376
|
+
}
|
|
1377
|
+
// -- work mailboxes ------------------------------------------------------------
|
|
1378
|
+
#mailboxCols(target) {
|
|
1379
|
+
return {
|
|
1380
|
+
targetKind: target.kind,
|
|
1381
|
+
taskId: "taskId" in target ? target.taskId : null,
|
|
1382
|
+
roleName: "roleName" in target ? target.roleName : null,
|
|
1383
|
+
targetKey: mailboxTargetKey(target)
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
#rowToMailbox(row) {
|
|
1387
|
+
const target = this.#targetFromCols(row.target_kind, row.task_id, row.role_name);
|
|
1388
|
+
return {
|
|
1389
|
+
schemaVersion: 1,
|
|
1390
|
+
target,
|
|
1391
|
+
nextSequence: row.next_sequence,
|
|
1392
|
+
processing: row.processing === null ? null : this.#parse(row.processing),
|
|
1393
|
+
pending: row.pending === null ? null : this.#parse(row.pending)
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
#targetFromCols(kind, taskId, roleName) {
|
|
1397
|
+
switch (kind) {
|
|
1398
|
+
case "operator": return { kind: "operator" };
|
|
1399
|
+
case "task": return { kind: "task", taskId: taskId };
|
|
1400
|
+
case "role": return { kind: "role", taskId: taskId, roleName: roleName };
|
|
1401
|
+
case "role-runtime": return { kind: "role-runtime", taskId: taskId, roleName: roleName };
|
|
1402
|
+
case "global-role-runtime": return { kind: "global-role-runtime", roleName: roleName };
|
|
1403
|
+
default: throw new StorageRecordError(`Unknown mailbox target kind: ${kind}`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
getWorkMailbox(target) {
|
|
1407
|
+
const cols = this.#mailboxCols(target);
|
|
1408
|
+
const row = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes WHERE target_key = ?").get(cols.targetKey);
|
|
1409
|
+
return row === undefined ? null : this.#rowToMailbox(row);
|
|
1410
|
+
}
|
|
1411
|
+
listWorkMailboxes() {
|
|
1412
|
+
const rows = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
|
|
1413
|
+
return rows.map((row) => this.#rowToMailbox(row));
|
|
1414
|
+
}
|
|
1415
|
+
saveWorkMailbox(mailbox) {
|
|
1416
|
+
const cols = this.#mailboxCols(mailbox.target);
|
|
1417
|
+
this.#mutate(() => {
|
|
1418
|
+
this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending)
|
|
1419
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1420
|
+
ON CONFLICT(target_key) DO UPDATE SET next_sequence = excluded.next_sequence,
|
|
1421
|
+
processing = excluded.processing, pending = excluded.pending`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey, mailbox.nextSequence, mailbox.processing === null ? null : this.#json(mailbox.processing), mailbox.pending === null ? null : this.#json(mailbox.pending));
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
removeWorkMailbox(target) {
|
|
1425
|
+
const cols = this.#mailboxCols(target);
|
|
1426
|
+
return this.#mutate(() => {
|
|
1427
|
+
const result = this.#db.prepare("DELETE FROM mailboxes WHERE target_key = ?").run(cols.targetKey);
|
|
1428
|
+
return result.changes > 0;
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Append a mailbox signal (§4.2). One transaction: insert the signal at the
|
|
1433
|
+
* mailbox's next sequence and advance `next_sequence` on the same row. The
|
|
1434
|
+
* single writer connection serializes enqueues, so sequences stay gapless per
|
|
1435
|
+
* mailbox. `(mailbox_id, sequence)` is the exactly-once key.
|
|
1436
|
+
*/
|
|
1437
|
+
enqueueMailboxSignal(target, input) {
|
|
1438
|
+
return this.#mutate(() => {
|
|
1439
|
+
const cols = this.#mailboxCols(target);
|
|
1440
|
+
let mailboxId;
|
|
1441
|
+
let sequence;
|
|
1442
|
+
const existing = this.#db.prepare("SELECT mailbox_id, next_sequence FROM mailboxes WHERE target_key = ?").get(cols.targetKey);
|
|
1443
|
+
if (existing === undefined) {
|
|
1444
|
+
const result = this.#db.prepare(`INSERT INTO mailboxes (target_kind, task_id, role_name, target_key, next_sequence, processing, pending)
|
|
1445
|
+
VALUES (?, ?, ?, ?, 1, NULL, NULL)`).run(cols.targetKind, cols.taskId, cols.roleName, cols.targetKey);
|
|
1446
|
+
mailboxId = Number(result.lastInsertRowid);
|
|
1447
|
+
sequence = 1;
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
mailboxId = existing.mailbox_id;
|
|
1451
|
+
sequence = existing.next_sequence;
|
|
1452
|
+
}
|
|
1453
|
+
this.#db.prepare(`INSERT INTO mailbox_signals (mailbox_id, sequence, reason, ref_type, ref_task_id, ref_id, occurred_at, request_id)
|
|
1454
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(mailboxId, sequence, input.reason, input.ref?.type ?? null, input.ref && "taskId" in input.ref ? input.ref.taskId : null, input.ref?.id ?? null, this.#now(), input.requestId);
|
|
1455
|
+
this.#db.prepare("UPDATE mailboxes SET next_sequence = ? WHERE mailbox_id = ?").run(sequence + 1, mailboxId);
|
|
1456
|
+
return sequence;
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
// -- scheduler projections -----------------------------------------------------
|
|
1460
|
+
#getProjection(taskId, kind) {
|
|
1461
|
+
const row = this.#db.prepare("SELECT payload FROM task_projections WHERE task_id = ? AND kind = ?").get(taskId, kind);
|
|
1462
|
+
if (row === undefined || row.payload === null)
|
|
1463
|
+
return null;
|
|
1464
|
+
return this.#parse(row.payload);
|
|
1465
|
+
}
|
|
1466
|
+
#saveProjection(taskId, kind, value) {
|
|
1467
|
+
this.#requireTask(taskId);
|
|
1468
|
+
this.#mutate(() => {
|
|
1469
|
+
this.#db.prepare(`INSERT INTO task_projections (task_id, kind, payload, updated_at) VALUES (?, ?, ?, ?)
|
|
1470
|
+
ON CONFLICT(task_id, kind) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, kind, this.#json(value), this.#now());
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
#clearProjection(taskId, kind) {
|
|
1474
|
+
this.#mutate(() => {
|
|
1475
|
+
this.#db.prepare("UPDATE task_projections SET payload = NULL, updated_at = ? WHERE task_id = ? AND kind = ?")
|
|
1476
|
+
.run(this.#now(), taskId, kind);
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
getLeaderFailure(taskId) {
|
|
1480
|
+
return this.#getProjection(taskId, "leader-failure");
|
|
1481
|
+
}
|
|
1482
|
+
saveLeaderFailure(failure) {
|
|
1483
|
+
this.#saveProjection(failure.taskId, "leader-failure", failure);
|
|
1484
|
+
}
|
|
1485
|
+
clearLeaderFailure(taskId) {
|
|
1486
|
+
this.#clearProjection(taskId, "leader-failure");
|
|
1487
|
+
}
|
|
1488
|
+
getOperatorNotification(taskId) {
|
|
1489
|
+
return this.#getProjection(taskId, "operator-notification");
|
|
1490
|
+
}
|
|
1491
|
+
saveOperatorNotification(notification) {
|
|
1492
|
+
this.#saveProjection(notification.taskId, "operator-notification", notification);
|
|
1493
|
+
}
|
|
1494
|
+
clearOperatorNotification(taskId) {
|
|
1495
|
+
this.#clearProjection(taskId, "operator-notification");
|
|
1496
|
+
}
|
|
1497
|
+
// -- pending wakeups (leader-role work-mailbox projection, mirrors taskStore.ts) --
|
|
1498
|
+
getPendingWakeup(taskId) {
|
|
1499
|
+
return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));
|
|
1500
|
+
}
|
|
1501
|
+
listPendingWakeups() {
|
|
1502
|
+
return this.listWorkMailboxes()
|
|
1503
|
+
.flatMap((mailbox) => {
|
|
1504
|
+
const wakeup = pendingWakeupProjection(mailbox);
|
|
1505
|
+
return wakeup === null ? [] : [wakeup];
|
|
1506
|
+
})
|
|
1507
|
+
.sort((a, b) => numericCompare(a.taskId, b.taskId));
|
|
1508
|
+
}
|
|
1509
|
+
savePendingWakeup(value) {
|
|
1510
|
+
const target = { kind: "role", taskId: value.taskId, roleName: "leader" };
|
|
1511
|
+
this.transaction((store) => {
|
|
1512
|
+
const existing = store.getWorkMailbox(target);
|
|
1513
|
+
if (existing !== null && existing.pending !== null
|
|
1514
|
+
&& value.requestCount <= existing.pending.requestCount) {
|
|
1515
|
+
throw new StorageRecordError(`Pending wakeup is stale: ${value.taskId}`);
|
|
1516
|
+
}
|
|
1517
|
+
const fromSequence = existing?.pending?.fromSequence ?? existing?.nextSequence ?? 1;
|
|
1518
|
+
const toSequence = fromSequence + value.requestCount - 1;
|
|
1519
|
+
store.saveWorkMailbox({
|
|
1520
|
+
schemaVersion: CURRENT_WORK_MAILBOX_SCHEMA_VERSION,
|
|
1521
|
+
target,
|
|
1522
|
+
nextSequence: Math.max(existing?.nextSequence ?? 1, toSequence + 1),
|
|
1523
|
+
processing: existing?.processing ?? null,
|
|
1524
|
+
pending: {
|
|
1525
|
+
...existing?.pending,
|
|
1526
|
+
fromSequence,
|
|
1527
|
+
toSequence,
|
|
1528
|
+
reasons: [...value.reasons],
|
|
1529
|
+
refs: existing?.pending?.refs ?? [],
|
|
1530
|
+
requestCount: value.requestCount,
|
|
1531
|
+
firstQueuedAt: value.firstRequestedAt,
|
|
1532
|
+
lastQueuedAt: value.lastRequestedAt
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
clearPendingWakeup(taskId) {
|
|
1538
|
+
this.removeWorkMailbox({ kind: "role", taskId, roleName: "leader" });
|
|
1539
|
+
}
|
|
1540
|
+
// -- telemetry (§4.4) -----------------------------------------------------------
|
|
1541
|
+
/**
|
|
1542
|
+
* Upsert one progress row. The PK is (task_id, role_name, run_id, generation,
|
|
1543
|
+
* progress_id): a repeated progress id updates in place, so a high-frequency
|
|
1544
|
+
* `runtime.provider-turn-progress` event is a single-row write that never
|
|
1545
|
+
* rewrites global state or another Task's rows.
|
|
1546
|
+
*/
|
|
1547
|
+
upsertTelemetryProgress(entry) {
|
|
1548
|
+
this.#mutate(() => {
|
|
1549
|
+
this.#db.prepare(`INSERT INTO telemetry (task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at)
|
|
1550
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1551
|
+
ON CONFLICT(task_id, role_name, run_id, generation, progress_id)
|
|
1552
|
+
DO UPDATE SET sequence = excluded.sequence, payload = excluded.payload, received_at = excluded.received_at`).run(entry.taskId, entry.roleName, entry.runId, entry.generation, entry.progressId, entry.sequence ?? null, this.#json(entry.payload), entry.receivedAt);
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
listTelemetry(taskId, runId) {
|
|
1556
|
+
const rows = runId === undefined
|
|
1557
|
+
? this.#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").all(taskId)
|
|
1558
|
+
: this.#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").all(taskId, runId);
|
|
1559
|
+
return rows
|
|
1560
|
+
.map((row) => ({
|
|
1561
|
+
taskId: row.task_id,
|
|
1562
|
+
roleName: row.role_name,
|
|
1563
|
+
runId: row.run_id,
|
|
1564
|
+
generation: row.generation,
|
|
1565
|
+
progressId: row.progress_id,
|
|
1566
|
+
sequence: row.sequence ?? undefined,
|
|
1567
|
+
payload: this.#parse(row.payload),
|
|
1568
|
+
receivedAt: row.received_at
|
|
1569
|
+
}));
|
|
1570
|
+
}
|
|
1571
|
+
countTelemetry(taskId, runId) {
|
|
1572
|
+
const row = runId === undefined
|
|
1573
|
+
? this.#db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ?").get(taskId)
|
|
1574
|
+
: this.#db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE task_id = ? AND run_id = ?").get(taskId, runId);
|
|
1575
|
+
return row.n;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Bounded retention (§4.4): keep the newest `keep` rows per
|
|
1579
|
+
* (task, role, run, generation) and delete older ones. The DELETE is scoped
|
|
1580
|
+
* by task_id; it never rewrites global rows or other Tasks. Returns the number
|
|
1581
|
+
* of rows deleted. Terminal/semantic events go to `events` and are never pruned.
|
|
1582
|
+
*/
|
|
1583
|
+
pruneTelemetry(taskId, roleName, runId, generation, keep = TELEMETRY_KEEP_PER_GENERATION) {
|
|
1584
|
+
return this.#mutate(() => {
|
|
1585
|
+
const result = this.#db.prepare(`DELETE FROM telemetry
|
|
1586
|
+
WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
|
|
1587
|
+
AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
|
|
1588
|
+
SELECT task_id, role_name, run_id, generation, progress_id
|
|
1589
|
+
FROM telemetry
|
|
1590
|
+
WHERE task_id = ? AND role_name = ? AND run_id = ? AND generation = ?
|
|
1591
|
+
ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
|
|
1592
|
+
LIMIT ?
|
|
1593
|
+
)`).run(taskId, roleName, runId, generation, taskId, roleName, runId, generation, keep);
|
|
1594
|
+
return result.changes;
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Hard cap for an active run (§4.4): trim oldest rows across the run beyond
|
|
1599
|
+
* `cap` (default 50k). Returns the number of rows deleted.
|
|
1600
|
+
*/
|
|
1601
|
+
capTelemetryRun(taskId, runId, cap = TELEMETRY_RUN_CAP) {
|
|
1602
|
+
return this.#mutate(() => {
|
|
1603
|
+
const result = this.#db.prepare(`DELETE FROM telemetry
|
|
1604
|
+
WHERE task_id = ? AND run_id = ?
|
|
1605
|
+
AND (task_id, role_name, run_id, generation, progress_id) NOT IN (
|
|
1606
|
+
SELECT task_id, role_name, run_id, generation, progress_id
|
|
1607
|
+
FROM telemetry
|
|
1608
|
+
WHERE task_id = ? AND run_id = ?
|
|
1609
|
+
ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
|
|
1610
|
+
LIMIT ?
|
|
1611
|
+
)`).run(taskId, runId, taskId, runId, cap);
|
|
1612
|
+
return result.changes;
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function validIntegrationQueueTransition(before, after) {
|
|
1617
|
+
if (before.id !== after.id
|
|
1618
|
+
|| before.taskId !== after.taskId
|
|
1619
|
+
|| before.projectId !== after.projectId
|
|
1620
|
+
|| before.changeSetId !== after.changeSetId
|
|
1621
|
+
|| before.targetRef !== after.targetRef
|
|
1622
|
+
|| !isDeepStrictEqual(before.checkCommands, after.checkCommands)
|
|
1623
|
+
|| !isDeepStrictEqual(before.evidenceRefs, after.evidenceRefs)
|
|
1624
|
+
|| before.createdAt !== after.createdAt)
|
|
1625
|
+
return false;
|
|
1626
|
+
const allowed = {
|
|
1627
|
+
queued: ["queued", "running", "validated", "superseded"],
|
|
1628
|
+
running: ["running", "conflicted", "committed"],
|
|
1629
|
+
conflicted: ["conflicted", "running", "committed", "queued", "superseded"],
|
|
1630
|
+
validated: ["validated", "running", "queued", "superseded"],
|
|
1631
|
+
committed: ["committed"],
|
|
1632
|
+
superseded: ["superseded"]
|
|
1633
|
+
};
|
|
1634
|
+
return allowed[before.status].includes(after.status);
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Open a {@link TaskStore} for the given backend. `file` returns the existing
|
|
1638
|
+
* {@link FileTaskStore}; `sqlite` returns the in-process {@link SqliteTaskStore}.
|
|
1639
|
+
* Backend selection is explicit (design §6); the environment switch lives in
|
|
1640
|
+
* {@link resolveTaskStoreBackend}. The file store is not removed — rollback is
|
|
1641
|
+
* a config flip.
|
|
1642
|
+
*/
|
|
1643
|
+
export function openTaskStore(home, backend, options) {
|
|
1644
|
+
if (backend === "sqlite") {
|
|
1645
|
+
return new SqliteTaskStore(home, options);
|
|
1646
|
+
}
|
|
1647
|
+
return new FileTaskStore(home);
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Resolve the storage backend from `YUI_STORE_BACKEND` (default `file`,
|
|
1651
|
+
* design §6). Only the exact value `sqlite` selects the SQLite store; any
|
|
1652
|
+
* other value (including unset) keeps the file store.
|
|
1653
|
+
*/
|
|
1654
|
+
export function resolveTaskStoreBackend(env = process.env) {
|
|
1655
|
+
return env.YUI_STORE_BACKEND?.toLowerCase() === "sqlite" ? "sqlite" : "file";
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Resolve the storage backend from the Home's verified manifest (Issue 01).
|
|
1659
|
+
*
|
|
1660
|
+
* The Home decides: a layout-7 Home's authoritative backend is SQLite WAL, so
|
|
1661
|
+
* ordinary CLI/Controller startup opens SQLite without requiring
|
|
1662
|
+
* `YUI_STORE_BACKEND=sqlite`. An explicit `YUI_STORE_BACKEND` env value still
|
|
1663
|
+
* wins — it is reserved for tests and explicit recovery commands — and any
|
|
1664
|
+
* other value (including unset) defers to the Home. A Home whose manifest
|
|
1665
|
+
* cannot be read falls back to the file store; the classifier/doctor surfaces
|
|
1666
|
+
* the manifest problem separately.
|
|
1667
|
+
*/
|
|
1668
|
+
export function resolveTaskStoreBackendForHome(home, env = process.env) {
|
|
1669
|
+
const explicit = env.YUI_STORE_BACKEND?.toLowerCase();
|
|
1670
|
+
if (explicit === "sqlite" || explicit === "file")
|
|
1671
|
+
return explicit;
|
|
1672
|
+
const schema = inspectStorageSchema(home);
|
|
1673
|
+
const layout = schema.status === "current" || schema.status === "unsupported"
|
|
1674
|
+
? schema.currentLayoutVersion
|
|
1675
|
+
: 0;
|
|
1676
|
+
if (layout < 7)
|
|
1677
|
+
return "file";
|
|
1678
|
+
// Issue 01: a layout-7 Home's authoritative backend is SQLite WAL, but only
|
|
1679
|
+
// when yui.db actually exists. A pseudo-layout-7 Home (manifest 7, no
|
|
1680
|
+
// yui.db) is classified NEEDS_STORAGE_REPAIR; until repair runs, the file
|
|
1681
|
+
// store remains the readable fallback. This keeps the Controller's backend
|
|
1682
|
+
// resolution consistent with openCompatibleFileTaskStore's physical check.
|
|
1683
|
+
// Uses the literal "yui.db" (not COMMITTED_DATABASE_FILENAME) to avoid a
|
|
1684
|
+
// circular import: sqliteStateMigration.ts imports SqliteTaskStore from here.
|
|
1685
|
+
return existsSync(join(home, "yui.db")) ? "sqlite" : "file";
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Convenience: open the store for the backend resolved from the environment
|
|
1689
|
+
* and the Home's verified manifest (see {@link resolveTaskStoreBackendForHome}).
|
|
1690
|
+
* CLI/controller entry points call this instead of {@link openTaskStore}
|
|
1691
|
+
* directly so a layout-7 Home opens SQLite without an env opt-in.
|
|
1692
|
+
*/
|
|
1693
|
+
export function openConfiguredTaskStore(home, options, env = process.env) {
|
|
1694
|
+
return openTaskStore(home, resolveTaskStoreBackendForHome(home, env), options);
|
|
1695
|
+
}
|