@knpkv/herdr-work 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/display-state.d.ts +10 -0
- package/dist/display-state.d.ts.map +1 -0
- package/dist/display-state.js +5 -0
- package/dist/display-state.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/legacy-lane-claim.d.ts +74 -0
- package/dist/internal/legacy-lane-claim.d.ts.map +1 -0
- package/dist/internal/legacy-lane-claim.js +81 -0
- package/dist/internal/legacy-lane-claim.js.map +1 -0
- package/dist/internal/session-index.d.ts +18 -0
- package/dist/internal/session-index.d.ts.map +1 -0
- package/dist/internal/session-index.js +23 -0
- package/dist/internal/session-index.js.map +1 -0
- package/dist/internal/terminal-reconcile.d.ts +29 -0
- package/dist/internal/terminal-reconcile.d.ts.map +1 -0
- package/dist/internal/terminal-reconcile.js +79 -0
- package/dist/internal/terminal-reconcile.js.map +1 -0
- package/dist/model.d.ts +909 -29
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +236 -7
- package/dist/model.js.map +1 -1
- package/dist/observed.d.ts +77 -0
- package/dist/observed.d.ts.map +1 -0
- package/dist/observed.js +240 -0
- package/dist/observed.js.map +1 -0
- package/dist/projection.d.ts +192 -12
- package/dist/projection.d.ts.map +1 -1
- package/dist/service.d.ts +121 -992
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +13 -3
- package/dist/service.js.map +1 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +76 -3
- package/dist/sql.js.map +1 -1
- package/dist/store.d.ts +193 -11
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1296 -639
- package/dist/store.js.map +1 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +169 -103
- package/dist/view.js.map +1 -1
- package/dist/work-triage.d.ts +72 -0
- package/dist/work-triage.d.ts.map +1 -0
- package/dist/work-triage.js +114 -0
- package/dist/work-triage.js.map +1 -0
- package/package.json +3 -3
- package/src/styles.css +229 -237
package/dist/store.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { agentConnectTarget, fleetResponseBodyMaxBytes, workReassignActivitySummary } from "@knpkv/herdr-fleet";
|
|
1
|
+
import { agentConnectTarget, fleetResponseBodyMaxBytes, workAbandonActivitySummary, workReassignActivitySummary } from "@knpkv/herdr-fleet";
|
|
2
2
|
import { openPrivateSqlite } from "@knpkv/herdr-fleet/sqlite";
|
|
3
|
-
import { Clock, Crypto, Effect, Equal, Option, Schema } from "effect";
|
|
3
|
+
import { Clock, Crypto, Effect, Equal, Option, Result, Schema } from "effect";
|
|
4
4
|
import { Hex } from "effect/encoding";
|
|
5
5
|
import { makeWorkAgentBinding } from "./agent-binding.js";
|
|
6
|
-
import { WorkAdmissionConflictError, WorkAgentBindingAuthorityError, WorkAgentBindingConflictError, WorkCheckpointConflictError, WorkCoordinatorHandoffConflictError, WorkDecisionAuthorityConflictError, WorkDecisionHandoffConflictError, WorkDecisionRevisionConflictError, WorkGoalAgentTargetConflictError, WorkGoalBindingRequiresAgentError, WorkGoalOwnerMismatchError, WorkGoalReassignmentConflictError, WorkGoalRevisionConflictError, WorkLaneClaimConflictError, WorkLaneGoalConflictError, WorkLaneOperationConflictError, WorkProjectionError, WorkPullRequestLinkError, WorkStoreError, WorkTransactionConflictError } from "./errors.js";
|
|
6
|
+
import { WorkAdmissionConflictError, WorkAgentBindingAuthorityError, WorkAgentBindingConflictError, WorkCheckpointConflictError, WorkCoordinatorHandoffConflictError, WorkDecisionAuthorityConflictError, WorkDecisionHandoffConflictError, WorkDecisionRevisionConflictError, WorkGoalAbandonmentConflictError, WorkGoalAgentTargetConflictError, WorkGoalBindingRequiresAgentError, WorkGoalLaneActiveError, WorkGoalOwnerMismatchError, WorkGoalReassignmentConflictError, WorkGoalRevisionConflictError, WorkGoalTerminalError, WorkLaneClaimConflictError, WorkLaneGoalConflictError, WorkLaneOperationConflictError, WorkProjectionError, WorkPullRequestLinkError, WorkStoreError, WorkTransactionConflictError } from "./errors.js";
|
|
7
7
|
import { validateGoalFamilyHistory } from "./goal-family.js";
|
|
8
8
|
import { agentBindingAdmissionError, workAgentBindingLaneOperationMaxBytes, workAgentBindingLaneOperationMaxRecords, workAgentBindingMaximumSnapshotBytes, workAgentBindingSnapshotEnvelopeMaxBytes, workMaximumSnapshotBytesForHistory } from "./internal/agent-binding-admission.js";
|
|
9
9
|
import { AgentBindingGoalEventRow, AgentBindingLaneOperationRow, agentBindingReadbackError, AgentBindingRow, decodeAgentBindingGoalEvent, decodeAgentBindingRow, laneOperationReadbackError } from "./internal/agent-binding-readback.js";
|
|
10
10
|
import { CoordinatorCommandRow, CoordinatorLifecycleDispatchRow, CoordinatorLifecycleEventRow, coordinatorLifecycleRunningAt, CoordinatorRouteDiscriminatorRow, coordinatorRouteRequiresWorkLink, requireCoordinatorFailedLunaAuthority, requireCoordinatorLifecycleAuthority, requireCoordinatorRouteAuthority, requireCoordinatorRouteBinding } from "./internal/coordinator-authority.js";
|
|
11
11
|
import { currentDecisionHandoffEquivalent, CurrentMetadataWorkLink, decodePreviousDecisionHandoff, previousDecisionHandoffEquivalent, PreviousWorkDecisionHandoff, upgradePreviousDecisionHandoff, workDispatchLineageContainedBy, workDispatchLineageEquivalent } from "./internal/decision-handoff-migration.js";
|
|
12
12
|
import { workHistoryError } from "./internal/history-validation.js";
|
|
13
|
-
import {
|
|
13
|
+
import { LaneOperationLedgerRow, LaneOperationTotalsRow, planLegacyLaneOperations, resolveLegacyLaneClaim } from "./internal/legacy-lane-claim.js";
|
|
14
|
+
import { enforcesOneHandoffPerSession, SessionIndexDefinition, sessionIndexDefinitionQuery } from "./internal/session-index.js";
|
|
15
|
+
import { goalActivityLimit, terminalCandidates, terminalCheckpoint, withoutBlockers } from "./internal/terminal-reconcile.js";
|
|
16
|
+
import { admissionEvidence, isTerminalWorkState, WorkAdmissionTarget, WorkAgentBinding, WorkAgentBindingRequest, WorkCoordinatorSessionId, WorkDecisionHandoff, WorkDispatchHandoff, WorkExistingGoalRecovery, WorkGoal, WorkGoalAbandoned, WorkGoalAbandonment, WorkGoalCheckpoint, WorkGoalId, WorkGoalReassigned, WorkGoalReassignment, workHistoryMaxEvents, WorkLaneClaim, WorkLaneClaimed, WorkObservationEnvelope, workObservationMaxSkewMillis, WorkObservationSubject, WorkObservedAdmission, WorkObservedFact, workObservedFactMaxBytes, workObservedFactMaxRecords, WorkObservedFailure, WorkProspectiveAdmission, WorkPullRequestLink, workReconcilerHeadroom, WorkRecoveryTarget, workSnapshotMaxGoals } from "./model.js";
|
|
17
|
+
import { asciiLower, canonicalSubject, observationSubject, reconcilerEventPrefix } from "./observed.js";
|
|
14
18
|
const StoredEventRow = Schema.Struct({ record: Schema.String });
|
|
15
19
|
const StoredEventRows = Schema.Array(StoredEventRow);
|
|
16
20
|
const StoredEventWithTransactionRow = Schema.Struct({
|
|
@@ -138,544 +142,498 @@ const legacyDecisionEquivalent = Schema.toEquivalence(LegacyDecisionRecord);
|
|
|
138
142
|
const TransactionId = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256), Schema.isPattern(/^(?:[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF])*$/));
|
|
139
143
|
const storeError = (operation) => (cause) => new WorkStoreError({ cause, operation });
|
|
140
144
|
const fromPrivateDatabaseError = (error) => new WorkStoreError({ cause: error.cause, operation: error.operation });
|
|
145
|
+
/**
|
|
146
|
+
* Files written before the session index was unique carry a plain index under
|
|
147
|
+
* the same name, which `CREATE UNIQUE INDEX IF NOT EXISTS` leaves in place.
|
|
148
|
+
* Replace it, failing closed when two handoffs already share a session. Runs
|
|
149
|
+
* inside `WorkStore.open`'s schema transaction, so a failure leaves the plain
|
|
150
|
+
* index in place.
|
|
151
|
+
*/
|
|
152
|
+
const requireUniqueSessionIndex = (database) => {
|
|
153
|
+
const sessionIndex = Schema.decodeUnknownSync(Schema.Array(SessionIndexDefinition))(database.prepare(sessionIndexDefinitionQuery).all())[0];
|
|
154
|
+
if (sessionIndex === undefined || enforcesOneHandoffPerSession(sessionIndex))
|
|
155
|
+
return;
|
|
156
|
+
// NULL sessions never collide under a UNIQUE index, so they are not duplicates.
|
|
157
|
+
const duplicateGroups = `SELECT session_id AS sessionId, COUNT(*) AS rows, group_concat(handoff_id) AS handoffIds
|
|
158
|
+
FROM work_decision_handoffs WHERE session_id IS NOT NULL
|
|
159
|
+
GROUP BY session_id HAVING COUNT(*) > 1`;
|
|
160
|
+
const duplicates = database.prepare(`${duplicateGroups} LIMIT 10`).all();
|
|
161
|
+
if (duplicates.length > 0) {
|
|
162
|
+
const total = database.prepare(`SELECT COUNT(*) AS sessions FROM (${duplicateGroups})`).get();
|
|
163
|
+
throw new WorkStoreError({ cause: { duplicates, total }, operation: "open.migrate.session-index" });
|
|
164
|
+
}
|
|
165
|
+
database.exec(`
|
|
166
|
+
DROP INDEX work_decision_handoffs_session;
|
|
167
|
+
CREATE UNIQUE INDEX work_decision_handoffs_session ON work_decision_handoffs (session_id);
|
|
168
|
+
`);
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Upgrades pre-session Work tables in place. Runs inside `WorkStore.open`'s
|
|
172
|
+
* schema transaction, so a failure here or in a later schema step leaves the
|
|
173
|
+
* file as it was.
|
|
174
|
+
*/
|
|
141
175
|
const migrateLegacyAuthorityTables = (database) => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
?
|
|
176
|
+
const columns = (table) => Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ name: Schema.String })))(database.prepare(`PRAGMA table_info(${table})`).all()).map(({ name }) => name);
|
|
177
|
+
const laneColumns = columns("work_lane_claims");
|
|
178
|
+
const decisionColumns = columns("work_decision_handoffs");
|
|
179
|
+
const tables = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ name: Schema.String })))(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()).map(({ name }) => name);
|
|
180
|
+
const coordinatorDispatchColumns = tables.includes("orchestrator_dispatches")
|
|
181
|
+
? columns("orchestrator_dispatches")
|
|
182
|
+
: [];
|
|
183
|
+
const requireBindingCompanions = (binding, operation) => {
|
|
184
|
+
const laneInput = tables.includes("work_lane_operations")
|
|
185
|
+
? database.prepare(`SELECT operation_id AS operationId, lane_id AS laneId, goal_id AS goalId,
|
|
186
|
+
phase, revision, record
|
|
187
|
+
FROM work_lane_operations WHERE operation_id = ?`).get(binding.lane.operationId)
|
|
188
|
+
: undefined;
|
|
189
|
+
const checkpointInput = tables.includes("work_goal_events")
|
|
190
|
+
? database.prepare(`SELECT event_id AS eventId, goal_id AS goalId, occurred_at AS occurredAt, record
|
|
191
|
+
FROM work_goal_events WHERE event_id = ?`).get(binding.checkpoint.eventId)
|
|
192
|
+
: undefined;
|
|
193
|
+
const error = agentBindingReadbackError(binding, laneInput === undefined ? undefined : Schema.decodeUnknownSync(AgentBindingLaneOperationRow)(laneInput), checkpointInput === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: Schema.decodeUnknownSync(AgentBindingGoalEventRow)(checkpointInput), operation);
|
|
196
|
+
if (error !== undefined)
|
|
197
|
+
throw error;
|
|
198
|
+
};
|
|
199
|
+
const coordinatorTablesPresent = {
|
|
200
|
+
dispatch: tables.includes("orchestrator_dispatches"),
|
|
201
|
+
event: tables.includes("orchestrator_events"),
|
|
202
|
+
metadata: tables.includes("orchestrator_dispatch_metadata")
|
|
203
|
+
};
|
|
204
|
+
const requireCoordinatorSchema = (operation) => {
|
|
205
|
+
const present = Object.values(coordinatorTablesPresent).filter(Boolean).length;
|
|
206
|
+
if (present !== 0 && present !== 3) {
|
|
207
|
+
throw new WorkStoreError({ cause: coordinatorTablesPresent, operation: `${operation}.schema` });
|
|
208
|
+
}
|
|
209
|
+
return present === 3;
|
|
210
|
+
};
|
|
211
|
+
requireCoordinatorSchema("open.migrate");
|
|
212
|
+
const readCoordinatorLifecycle = (dispatchRequestId) => ({
|
|
213
|
+
dispatchRows: Schema.decodeUnknownSync(Schema.Array(CoordinatorLifecycleDispatchRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId,
|
|
214
|
+
activity_idempotency_key AS activityIdempotencyKey, command,
|
|
215
|
+
accepted_at AS acceptedAt, status
|
|
216
|
+
FROM orchestrator_dispatches WHERE dispatch_request_id = ? LIMIT 2`).all(dispatchRequestId)),
|
|
217
|
+
eventRows: Schema.decodeUnknownSync(Schema.Array(CoordinatorLifecycleEventRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, sequence, type,
|
|
218
|
+
activity_idempotency_key AS activityIdempotencyKey,
|
|
219
|
+
occurred_at AS occurredAt, detail, result
|
|
220
|
+
FROM orchestrator_events WHERE dispatch_request_id = ?
|
|
221
|
+
ORDER BY sequence ASC LIMIT 5`).all(dispatchRequestId))
|
|
222
|
+
});
|
|
223
|
+
const routeStorageAuthority = (dispatchRequestId, operation) => {
|
|
224
|
+
if (!coordinatorDispatchColumns.includes("is_routed")) {
|
|
225
|
+
return { _tag: "legacy_without_routed_discriminator" };
|
|
226
|
+
}
|
|
227
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(CoordinatorRouteDiscriminatorRow))(database.prepare(`SELECT is_routed AS isRouted FROM orchestrator_dispatches
|
|
228
|
+
WHERE dispatch_request_id = ? LIMIT 2`).all(dispatchRequestId));
|
|
229
|
+
const row = rows[0];
|
|
230
|
+
if (rows.length !== 1 || row === undefined) {
|
|
231
|
+
throw new WorkStoreError({ cause: { dispatchRequestId, rows }, operation });
|
|
232
|
+
}
|
|
233
|
+
return { _tag: "routed_discriminator", isRouted: row.isRouted };
|
|
234
|
+
};
|
|
235
|
+
const requireLinkedParentAuthority = (linkedRequestId, operation) => {
|
|
236
|
+
if (linkedRequestId === null)
|
|
237
|
+
return;
|
|
238
|
+
const metadataRows = Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
239
|
+
FROM orchestrator_dispatch_metadata WHERE dispatch_request_id = ? LIMIT 2`).all(linkedRequestId));
|
|
240
|
+
const metadata = metadataRows[0];
|
|
241
|
+
if (metadataRows.length !== 1 || metadata === undefined || metadata.workLink !== null) {
|
|
242
|
+
throw new WorkStoreError({ cause: { linkedRequestId, metadataRows }, operation });
|
|
243
|
+
}
|
|
244
|
+
const lifecycle = readCoordinatorLifecycle(linkedRequestId);
|
|
245
|
+
requireCoordinatorFailedLunaAuthority(lifecycle.dispatchRows, lifecycle.eventRows, metadata.route, routeStorageAuthority(linkedRequestId, operation), operation);
|
|
246
|
+
};
|
|
247
|
+
const requireMetadataAuthority = (dispatchRequestId, routeText, lineage, operation) => {
|
|
248
|
+
const commandRows = Schema.decodeUnknownSync(Schema.Array(CoordinatorCommandRow))(database.prepare(`SELECT activity_idempotency_key AS activityIdempotencyKey, command FROM orchestrator_dispatches
|
|
249
|
+
WHERE dispatch_request_id = ? LIMIT 2`).all(dispatchRequestId));
|
|
250
|
+
const commandRow = commandRows[0];
|
|
251
|
+
if (commandRows.length !== 1 || commandRow === undefined) {
|
|
252
|
+
throw new WorkStoreError({ cause: { commandRows, dispatchRequestId }, operation });
|
|
253
|
+
}
|
|
254
|
+
const route = requireCoordinatorRouteAuthority(commandRow.command, commandRow.activityIdempotencyKey, routeText, lineage, routeStorageAuthority(dispatchRequestId, operation), operation);
|
|
255
|
+
requireLinkedParentAuthority(route.linkedRequestId, `${operation}.parent`);
|
|
256
|
+
};
|
|
257
|
+
const requireLifecycleAuthority = (dispatchRequestId, expectedRunningAt, operation) => {
|
|
258
|
+
if (!requireCoordinatorSchema(operation))
|
|
259
|
+
return;
|
|
260
|
+
const lifecycle = readCoordinatorLifecycle(dispatchRequestId);
|
|
261
|
+
requireCoordinatorLifecycleAuthority(lifecycle.dispatchRows, lifecycle.eventRows, expectedRunningAt, operation);
|
|
262
|
+
};
|
|
263
|
+
const requireCurrentAgentBindingAuthority = (dispatchRequestId, handoff, operation) => {
|
|
264
|
+
const lifecycle = readCoordinatorLifecycle(dispatchRequestId);
|
|
265
|
+
const runningAt = coordinatorLifecycleRunningAt(lifecycle.dispatchRows, lifecycle.eventRows, `${operation}.lifecycle`);
|
|
266
|
+
const bindingRows = tables.includes("work_agent_bindings")
|
|
267
|
+
? Schema.decodeUnknownSync(Schema.Array(AgentBindingRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, lane_id AS laneId,
|
|
268
|
+
expected_revision AS expectedRevision, revision, agent_id AS agentId, host, record
|
|
269
|
+
FROM work_agent_bindings WHERE dispatch_request_id = ? LIMIT 2`).all(dispatchRequestId))
|
|
152
270
|
: [];
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
271
|
+
if (runningAt === null && bindingRows.length === 0)
|
|
272
|
+
return;
|
|
273
|
+
const bindingRow = bindingRows[0];
|
|
274
|
+
if (runningAt === null || bindingRows.length !== 1 || bindingRow === undefined) {
|
|
275
|
+
throw new WorkStoreError({ cause: { bindingRows, lifecycle }, operation });
|
|
276
|
+
}
|
|
277
|
+
const decoded = decodeAgentBindingRow(bindingRow, { dispatchRequestId, laneId: handoff.laneId }, operation);
|
|
278
|
+
if (decoded._tag === "invalid")
|
|
279
|
+
throw decoded.error;
|
|
280
|
+
if (decoded.binding.request.expectedRevision !== handoff.expectedRevision ||
|
|
281
|
+
decoded.binding.lane.goalId !== handoff.goalId ||
|
|
282
|
+
decoded.binding.checkpoint.occurredAt !== runningAt) {
|
|
283
|
+
throw new WorkStoreError({ cause: { binding: decoded.binding, handoff, runningAt }, operation });
|
|
284
|
+
}
|
|
285
|
+
requireBindingCompanions(decoded.binding, operation);
|
|
286
|
+
const currentLaneRows = Schema.decodeUnknownSync(Schema.Array(LaneRow))(database.prepare(`SELECT lane_id AS laneId, goal_id AS goalId, operation_id AS operationId,
|
|
287
|
+
phase, revision, record
|
|
288
|
+
FROM work_lane_claims WHERE lane_id = ? LIMIT 2`).all(decoded.binding.lane.laneId));
|
|
289
|
+
const currentLaneRow = currentLaneRows[0];
|
|
290
|
+
if (currentLaneRows.length !== 1 || currentLaneRow === undefined) {
|
|
291
|
+
throw new WorkStoreError({
|
|
292
|
+
cause: { binding: decoded.binding, currentLaneRows },
|
|
293
|
+
operation: `${operation}.lane-revision`
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
let currentLane;
|
|
297
|
+
try {
|
|
298
|
+
currentLane = Schema.decodeUnknownSync(WorkLaneClaimed)(JSON.parse(currentLaneRow.record));
|
|
299
|
+
}
|
|
300
|
+
catch (cause) {
|
|
301
|
+
throw new WorkStoreError({ cause, operation: `${operation}.lane-revision` });
|
|
302
|
+
}
|
|
303
|
+
const currentLaneSchema = laneColumns.includes("goal_id");
|
|
304
|
+
const currentOperationRows = currentLaneSchema
|
|
305
|
+
? Schema.decodeUnknownSync(Schema.Array(AgentBindingLaneOperationRow))(database.prepare(`SELECT operation_id AS operationId, lane_id AS laneId, goal_id AS goalId,
|
|
156
306
|
phase, revision, record
|
|
157
|
-
FROM work_lane_operations WHERE operation_id =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
307
|
+
FROM work_lane_operations WHERE operation_id = ? LIMIT 2`).all(currentLane.operationId))
|
|
308
|
+
: [];
|
|
309
|
+
const currentOperationError = currentLaneSchema
|
|
310
|
+
? laneOperationReadbackError(currentLane, currentOperationRows.length === 1 ? currentOperationRows[0] : undefined, `${operation}.lane-revision`)
|
|
311
|
+
: undefined;
|
|
312
|
+
if (currentLane.laneId !== currentLaneRow.laneId ||
|
|
313
|
+
currentLane.goalId !== currentLaneRow.goalId ||
|
|
314
|
+
currentLane.operationId !== currentLaneRow.operationId ||
|
|
315
|
+
currentLane.phase !== currentLaneRow.phase ||
|
|
316
|
+
currentLane.revision !== currentLaneRow.revision ||
|
|
317
|
+
currentLane.laneId !== decoded.binding.lane.laneId ||
|
|
318
|
+
currentLane.goalId !== decoded.binding.lane.goalId ||
|
|
319
|
+
currentLane.revision < decoded.binding.lane.revision ||
|
|
320
|
+
(currentLaneSchema && currentLane.revision === decoded.binding.lane.revision &&
|
|
321
|
+
!Equal.equals(currentLane, decoded.binding.lane)) ||
|
|
322
|
+
currentOperationError !== undefined) {
|
|
323
|
+
throw new WorkStoreError({
|
|
324
|
+
cause: { binding: decoded.binding, currentLane, currentLaneRow, currentOperationError },
|
|
325
|
+
operation: `${operation}.lane-revision`
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const dispatches = tables.includes("work_dispatch_handoffs")
|
|
330
|
+
? Schema.decodeUnknownSync(Schema.Array(LegacyDispatchStoredRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, handoff_id AS handoffId,
|
|
331
|
+
lane_id AS laneId, occurred_at AS occurredAt, lineage, record
|
|
332
|
+
FROM work_dispatch_handoffs`).all())
|
|
333
|
+
: [];
|
|
334
|
+
const decisionIdentities = decisionColumns.length > 0
|
|
335
|
+
? Schema.decodeUnknownSync(Schema.Array(DecisionIdentityRow))(database.prepare("SELECT handoff_id AS handoffId FROM work_decision_handoffs").all())
|
|
336
|
+
: [];
|
|
337
|
+
const orphanDispatch = dispatches.find(({ handoffId }) => !decisionIdentities.some((decision) => decision.handoffId === handoffId));
|
|
338
|
+
if (orphanDispatch !== undefined) {
|
|
339
|
+
throw new WorkStoreError({
|
|
340
|
+
cause: { decisionIdentities, dispatch: orphanDispatch },
|
|
341
|
+
operation: "open.migrate.dispatch-decision"
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (tables.includes("orchestrator_dispatch_metadata")) {
|
|
345
|
+
const linkedMetadata = Schema.decodeUnknownSync(Schema.Array(MetadataHandoffIdentityRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId,
|
|
346
|
+
CASE WHEN work_link IS NULL THEN 0 ELSE 1 END AS hasWorkLink,
|
|
347
|
+
CASE WHEN json_valid(work_link) AND json_type(work_link, '$.handoff.id') = 'text'
|
|
348
|
+
THEN json_extract(work_link, '$.handoff.id') END AS handoffId,
|
|
349
|
+
route
|
|
350
|
+
FROM orchestrator_dispatch_metadata AS metadata
|
|
351
|
+
WHERE work_link IS NOT NULL OR EXISTS (
|
|
352
|
+
SELECT 1 FROM orchestrator_dispatch_metadata AS linked
|
|
353
|
+
WHERE linked.dispatch_request_id = metadata.dispatch_request_id
|
|
354
|
+
AND linked.work_link IS NOT NULL
|
|
355
|
+
) OR (json_valid(route) AND json_extract(route, '$.model') = 'gpt-5.6-sol')
|
|
356
|
+
LIMIT ${workDecisionMaxRecords + 1}`).all());
|
|
357
|
+
if (linkedMetadata.length > workDecisionMaxRecords) {
|
|
358
|
+
throw new WorkStoreError({ cause: linkedMetadata.length, operation: "open.migrate.metadata-capacity" });
|
|
359
|
+
}
|
|
360
|
+
const metadataCounts = new Map();
|
|
361
|
+
const dispatchCounts = new Map();
|
|
362
|
+
const decisionCounts = new Map();
|
|
363
|
+
for (const metadata of linkedMetadata) {
|
|
364
|
+
metadataCounts.set(metadata.dispatchRequestId, (metadataCounts.get(metadata.dispatchRequestId) ?? 0) + 1);
|
|
365
|
+
}
|
|
366
|
+
for (const dispatch of dispatches) {
|
|
367
|
+
const identity = JSON.stringify([dispatch.dispatchRequestId, dispatch.handoffId]);
|
|
368
|
+
dispatchCounts.set(identity, (dispatchCounts.get(identity) ?? 0) + 1);
|
|
369
|
+
}
|
|
370
|
+
for (const decision of decisionIdentities) {
|
|
371
|
+
decisionCounts.set(decision.handoffId, (decisionCounts.get(decision.handoffId) ?? 0) + 1);
|
|
372
|
+
}
|
|
373
|
+
const invalidMetadata = linkedMetadata.find((metadata) => {
|
|
374
|
+
if (metadata.route === null)
|
|
375
|
+
return true;
|
|
376
|
+
const requiresWorkLink = coordinatorRouteRequiresWorkLink(metadata.route, routeStorageAuthority(metadata.dispatchRequestId, "open.migrate.metadata-decision"), "open.migrate.metadata-decision");
|
|
377
|
+
if (!requiresWorkLink)
|
|
378
|
+
return metadata.hasWorkLink === 1;
|
|
379
|
+
const dispatchIdentity = JSON.stringify([metadata.dispatchRequestId, metadata.handoffId]);
|
|
380
|
+
return (metadata.handoffId === null || metadataCounts.get(metadata.dispatchRequestId) !== 1 ||
|
|
381
|
+
dispatchCounts.get(dispatchIdentity) !== 1 || decisionCounts.get(metadata.handoffId) !== 1);
|
|
192
382
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
383
|
+
if (invalidMetadata !== undefined) {
|
|
384
|
+
throw new WorkStoreError({
|
|
385
|
+
cause: { decisionIdentities, dispatches, linkedMetadata, metadata: invalidMetadata },
|
|
386
|
+
operation: "open.migrate.metadata-decision"
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const lanes = laneColumns.length > 0 && !laneColumns.includes("goal_id")
|
|
391
|
+
? Schema.decodeUnknownSync(Schema.Array(LegacyLaneStoredRow))(database.prepare("SELECT lane_id AS laneId, revision, record FROM work_lane_claims").all()).map((row) => {
|
|
392
|
+
const legacy = Schema.decodeUnknownSync(LegacyLaneRecord)(JSON.parse(row.record));
|
|
393
|
+
if (legacy.laneId !== row.laneId || legacy.revision !== row.revision) {
|
|
394
|
+
throw new WorkStoreError({ cause: { legacy, row }, operation: "open.migrate.lane-identity" });
|
|
196
395
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
396
|
+
return Schema.decodeUnknownSync(WorkLaneClaimed)({
|
|
397
|
+
...legacy,
|
|
398
|
+
goalId: legacy.laneId,
|
|
399
|
+
operationId: legacy.laneId
|
|
400
|
+
});
|
|
401
|
+
})
|
|
402
|
+
: [];
|
|
403
|
+
// Lanes the running bindings of legacy handoffs recorded; see resolveLegacyLaneClaim.
|
|
404
|
+
const bindingLanes = [];
|
|
405
|
+
const decisions = decisionColumns.length > 0 && !decisionColumns.includes("session_id")
|
|
406
|
+
? Schema.decodeUnknownSync(Schema.Array(LegacyDecisionStoredRow))(database.prepare("SELECT handoff_id AS handoffId, record FROM work_decision_handoffs").all()).map((row) => {
|
|
407
|
+
const legacy = Schema.decodeUnknownSync(LegacyDecisionRecord)(JSON.parse(row.record));
|
|
408
|
+
if (legacy.id !== row.handoffId) {
|
|
409
|
+
throw new WorkStoreError({ cause: { legacy, row }, operation: "open.migrate.handoff-identity" });
|
|
202
410
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
const metadataRows = Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
209
|
-
FROM orchestrator_dispatch_metadata WHERE dispatch_request_id = ? LIMIT 2`).all(linkedRequestId));
|
|
210
|
-
const metadata = metadataRows[0];
|
|
211
|
-
if (metadataRows.length !== 1 || metadata === undefined || metadata.workLink !== null) {
|
|
212
|
-
throw new WorkStoreError({ cause: { linkedRequestId, metadataRows }, operation });
|
|
411
|
+
const matchingDispatches = dispatches.filter(({ handoffId }) => handoffId === legacy.id);
|
|
412
|
+
const dispatch = matchingDispatches[0];
|
|
413
|
+
const lane = lanes.find(({ laneId }) => laneId === legacy.laneId);
|
|
414
|
+
if (lane === undefined) {
|
|
415
|
+
throw new WorkStoreError({ cause: legacy, operation: "open.migrate.handoff-lane" });
|
|
213
416
|
}
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
417
|
+
const dispatchIds = dispatch === undefined
|
|
418
|
+
? []
|
|
419
|
+
: Schema.decodeUnknownSync(WorkDispatchHandoff.fields.lineage)(JSON.parse(dispatch.lineage));
|
|
420
|
+
if (matchingDispatches.length !== 1 || dispatch === undefined) {
|
|
421
|
+
throw new WorkStoreError({
|
|
422
|
+
cause: { legacy, matchingDispatches },
|
|
423
|
+
operation: "open.migrate.legacy-dispatch-cardinality"
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const dispatchHandoff = Schema.decodeUnknownSync(LegacyDecisionRecord)(JSON.parse(dispatch.record));
|
|
427
|
+
if (dispatch.laneId !== legacy.laneId || dispatch.occurredAt !== legacy.occurredAt ||
|
|
428
|
+
!legacyDecisionEquivalent(dispatchHandoff, legacy)) {
|
|
429
|
+
throw new WorkStoreError({
|
|
430
|
+
cause: { dispatch, dispatchHandoff, legacy },
|
|
431
|
+
operation: "open.migrate.legacy-dispatch-authority"
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (tables.includes("orchestrator_dispatch_metadata")) {
|
|
435
|
+
const metadataRows = Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
436
|
+
FROM orchestrator_dispatch_metadata WHERE dispatch_request_id = ? LIMIT 2`).all(dispatch.dispatchRequestId));
|
|
437
|
+
const metadata = metadataRows[0];
|
|
438
|
+
if (metadataRows.length !== 1 || metadata === undefined) {
|
|
439
|
+
throw new WorkStoreError({
|
|
440
|
+
cause: { dispatch, metadataRows },
|
|
441
|
+
operation: "open.migrate.legacy-metadata-authority"
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (metadata.workLink === null) {
|
|
445
|
+
throw new WorkStoreError({
|
|
446
|
+
cause: { dispatch, metadata },
|
|
447
|
+
operation: "open.migrate.legacy-metadata-authority"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const workLink = Schema.decodeUnknownSync(LegacyMetadataWorkLink)(JSON.parse(metadata.workLink));
|
|
451
|
+
requireMetadataAuthority(dispatch.dispatchRequestId, metadata.route, workLink.lineage, "open.migrate.legacy-metadata-authority");
|
|
452
|
+
if (!legacyDecisionEquivalent(workLink.handoff, legacy) ||
|
|
453
|
+
!workDispatchLineageEquivalent(workLink.lineage, dispatchIds)) {
|
|
454
|
+
throw new WorkStoreError({
|
|
455
|
+
cause: { legacy, workLink },
|
|
456
|
+
operation: "open.migrate.legacy-metadata-authority"
|
|
457
|
+
});
|
|
458
|
+
}
|
|
223
459
|
}
|
|
224
|
-
const route = requireCoordinatorRouteAuthority(commandRow.command, commandRow.activityIdempotencyKey, routeText, lineage, routeStorageAuthority(dispatchRequestId, operation), operation);
|
|
225
|
-
requireLinkedParentAuthority(route.linkedRequestId, `${operation}.parent`);
|
|
226
|
-
};
|
|
227
|
-
const requireLifecycleAuthority = (dispatchRequestId, expectedRunningAt, operation) => {
|
|
228
|
-
if (!requireCoordinatorSchema(operation))
|
|
229
|
-
return;
|
|
230
|
-
const lifecycle = readCoordinatorLifecycle(dispatchRequestId);
|
|
231
|
-
requireCoordinatorLifecycleAuthority(lifecycle.dispatchRows, lifecycle.eventRows, expectedRunningAt, operation);
|
|
232
|
-
};
|
|
233
|
-
const requireCurrentAgentBindingAuthority = (dispatchRequestId, handoff, operation) => {
|
|
234
|
-
const lifecycle = readCoordinatorLifecycle(dispatchRequestId);
|
|
235
|
-
const runningAt = coordinatorLifecycleRunningAt(lifecycle.dispatchRows, lifecycle.eventRows, `${operation}.lifecycle`);
|
|
236
460
|
const bindingRows = tables.includes("work_agent_bindings")
|
|
237
461
|
? Schema.decodeUnknownSync(Schema.Array(AgentBindingRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, lane_id AS laneId,
|
|
238
462
|
expected_revision AS expectedRevision, revision, agent_id AS agentId, host, record
|
|
239
|
-
FROM work_agent_bindings
|
|
463
|
+
FROM work_agent_bindings
|
|
464
|
+
WHERE dispatch_request_id = ?`).all(dispatch.dispatchRequestId))
|
|
240
465
|
: [];
|
|
241
|
-
if (runningAt === null && bindingRows.length === 0)
|
|
242
|
-
return;
|
|
243
466
|
const bindingRow = bindingRows[0];
|
|
244
|
-
if (
|
|
245
|
-
throw new WorkStoreError({ cause: { bindingRows, lifecycle }, operation });
|
|
246
|
-
}
|
|
247
|
-
const decoded = decodeAgentBindingRow(bindingRow, { dispatchRequestId, laneId: handoff.laneId }, operation);
|
|
248
|
-
if (decoded._tag === "invalid")
|
|
249
|
-
throw decoded.error;
|
|
250
|
-
if (decoded.binding.request.expectedRevision !== handoff.expectedRevision ||
|
|
251
|
-
decoded.binding.lane.goalId !== handoff.goalId ||
|
|
252
|
-
decoded.binding.checkpoint.occurredAt !== runningAt) {
|
|
253
|
-
throw new WorkStoreError({ cause: { binding: decoded.binding, handoff, runningAt }, operation });
|
|
254
|
-
}
|
|
255
|
-
requireBindingCompanions(decoded.binding, operation);
|
|
256
|
-
const currentLaneRows = Schema.decodeUnknownSync(Schema.Array(LaneRow))(database.prepare(`SELECT lane_id AS laneId, goal_id AS goalId, operation_id AS operationId,
|
|
257
|
-
phase, revision, record
|
|
258
|
-
FROM work_lane_claims WHERE lane_id = ? LIMIT 2`).all(decoded.binding.lane.laneId));
|
|
259
|
-
const currentLaneRow = currentLaneRows[0];
|
|
260
|
-
if (currentLaneRows.length !== 1 || currentLaneRow === undefined) {
|
|
467
|
+
if (bindingRows.length !== 1 || bindingRow === undefined) {
|
|
261
468
|
throw new WorkStoreError({
|
|
262
|
-
cause: {
|
|
263
|
-
operation:
|
|
469
|
+
cause: { bindingRows, dispatch, legacy },
|
|
470
|
+
operation: "open.migrate.legacy-handoff-revision"
|
|
264
471
|
});
|
|
265
472
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
473
|
+
const bindingDecision = decodeAgentBindingRow(bindingRow, { dispatchRequestId: dispatch.dispatchRequestId, laneId: legacy.laneId }, "open.migrate.legacy-agent-binding");
|
|
474
|
+
if (bindingDecision._tag === "invalid")
|
|
475
|
+
throw bindingDecision.error;
|
|
476
|
+
if (bindingDecision.binding.lane.goalId !== legacy.goalId) {
|
|
477
|
+
throw new WorkStoreError({
|
|
478
|
+
cause: { binding: bindingDecision.binding, legacy },
|
|
479
|
+
operation: "open.migrate.legacy-agent-binding.goal"
|
|
480
|
+
});
|
|
272
481
|
}
|
|
273
|
-
|
|
274
|
-
const currentOperationRows = currentLaneSchema
|
|
275
|
-
? Schema.decodeUnknownSync(Schema.Array(AgentBindingLaneOperationRow))(database.prepare(`SELECT operation_id AS operationId, lane_id AS laneId, goal_id AS goalId,
|
|
276
|
-
phase, revision, record
|
|
277
|
-
FROM work_lane_operations WHERE operation_id = ? LIMIT 2`).all(currentLane.operationId))
|
|
278
|
-
: [];
|
|
279
|
-
const currentOperationError = currentLaneSchema
|
|
280
|
-
? laneOperationReadbackError(currentLane, currentOperationRows.length === 1 ? currentOperationRows[0] : undefined, `${operation}.lane-revision`)
|
|
281
|
-
: undefined;
|
|
282
|
-
if (currentLane.laneId !== currentLaneRow.laneId ||
|
|
283
|
-
currentLane.goalId !== currentLaneRow.goalId ||
|
|
284
|
-
currentLane.operationId !== currentLaneRow.operationId ||
|
|
285
|
-
currentLane.phase !== currentLaneRow.phase ||
|
|
286
|
-
currentLane.revision !== currentLaneRow.revision ||
|
|
287
|
-
currentLane.laneId !== decoded.binding.lane.laneId ||
|
|
288
|
-
currentLane.goalId !== decoded.binding.lane.goalId ||
|
|
289
|
-
currentLane.revision < decoded.binding.lane.revision ||
|
|
290
|
-
(currentLaneSchema && currentLane.revision === decoded.binding.lane.revision &&
|
|
291
|
-
!Equal.equals(currentLane, decoded.binding.lane)) ||
|
|
292
|
-
currentOperationError !== undefined) {
|
|
482
|
+
if (lane.revision < bindingDecision.binding.lane.revision) {
|
|
293
483
|
throw new WorkStoreError({
|
|
294
|
-
cause: { binding:
|
|
295
|
-
operation:
|
|
484
|
+
cause: { binding: bindingDecision.binding, lane, legacy },
|
|
485
|
+
operation: "open.migrate.legacy-handoff-lane-revision"
|
|
296
486
|
});
|
|
297
487
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
cause: { decisionIdentities, dispatch: orphanDispatch },
|
|
311
|
-
operation: "open.migrate.dispatch-decision"
|
|
488
|
+
bindingLanes.push(bindingDecision.binding.lane);
|
|
489
|
+
requireBindingCompanions(bindingDecision.binding, "open.migrate.legacy-agent-binding");
|
|
490
|
+
requireLifecycleAuthority(bindingDecision.binding.request.dispatchRequestId, bindingDecision.binding.checkpoint.occurredAt, "open.migrate.legacy-agent-binding.lifecycle");
|
|
491
|
+
return Schema.decodeUnknownSync(WorkDecisionHandoff)({
|
|
492
|
+
...legacy,
|
|
493
|
+
contextDelta: legacy.summary,
|
|
494
|
+
expectedRevision: bindingDecision.binding.request.expectedRevision,
|
|
495
|
+
sessionId: legacy.id,
|
|
496
|
+
dispatchIds,
|
|
497
|
+
blockers: [],
|
|
498
|
+
evidenceRefs: [],
|
|
499
|
+
version: "herdr.work.decision.v2"
|
|
312
500
|
});
|
|
501
|
+
})
|
|
502
|
+
: [];
|
|
503
|
+
const migratedClaims = lanes.map((lane) => resolveLegacyLaneClaim(lane, bindingLanes));
|
|
504
|
+
if (laneColumns.length > 0 && !laneColumns.includes("goal_id")) {
|
|
505
|
+
database.exec("ALTER TABLE work_lane_claims ADD COLUMN goal_id TEXT");
|
|
506
|
+
database.exec("ALTER TABLE work_lane_claims ADD COLUMN operation_id TEXT");
|
|
507
|
+
database.exec("ALTER TABLE work_lane_claims ADD COLUMN phase TEXT");
|
|
508
|
+
const update = database.prepare("UPDATE work_lane_claims SET goal_id = ?, operation_id = ?, phase = ?, record = ? WHERE lane_id = ?");
|
|
509
|
+
// An existing lane-operation ledger is not backfilled later, so record each
|
|
510
|
+
// migrated claim's operation here; the claim's readback requires it.
|
|
511
|
+
// Without a ledger, schema creation below creates it and backfills every
|
|
512
|
+
// claim, so the same bound applies to an empty ledger.
|
|
513
|
+
const hasLedger = tables.includes("work_lane_operations");
|
|
514
|
+
const operations = planLegacyLaneOperations(migratedClaims.map(({ lane }) => lane), new Map(hasLedger
|
|
515
|
+
? migratedClaims.flatMap(({ lane }) => {
|
|
516
|
+
const matches = database.prepare(`SELECT operation_id AS operationId, lane_id AS laneId, goal_id AS goalId, phase, revision, record
|
|
517
|
+
FROM work_lane_operations WHERE CAST(operation_id AS TEXT) = ?`).all(lane.operationId);
|
|
518
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(LaneOperationLedgerRow))(matches);
|
|
519
|
+
// A key stored as a blob, or twice, is a collision even when its bytes match.
|
|
520
|
+
const found = rows.find(({ operationId }) => operationId !== lane.operationId) ?? rows[0];
|
|
521
|
+
return found === undefined ? [] : [[lane.operationId, found]];
|
|
522
|
+
})
|
|
523
|
+
: []), hasLedger
|
|
524
|
+
? Schema.decodeUnknownSync(LaneOperationTotalsRow)(database.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(
|
|
525
|
+
length(CAST(operation_id AS BLOB)) + length(CAST(record AS BLOB))), 0) AS bytes
|
|
526
|
+
FROM work_lane_operations`).get())
|
|
527
|
+
: { bytes: 0, count: 0 }, { bytes: workLaneOperationMaxBytes, records: workLaneOperationMaxRecords });
|
|
528
|
+
if (operations._tag === "collision") {
|
|
529
|
+
throw new WorkStoreError({ cause: operations, operation: "open.migrate.lane-operation-collision" });
|
|
313
530
|
}
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
) OR (json_valid(route) AND json_extract(route, '$.model') = 'gpt-5.6-sol')
|
|
326
|
-
LIMIT ${workDecisionMaxRecords + 1}`).all());
|
|
327
|
-
if (linkedMetadata.length > workDecisionMaxRecords) {
|
|
328
|
-
throw new WorkStoreError({ cause: linkedMetadata.length, operation: "open.migrate.metadata-capacity" });
|
|
329
|
-
}
|
|
330
|
-
const metadataCounts = new Map();
|
|
331
|
-
const dispatchCounts = new Map();
|
|
332
|
-
const decisionCounts = new Map();
|
|
333
|
-
for (const metadata of linkedMetadata) {
|
|
334
|
-
metadataCounts.set(metadata.dispatchRequestId, (metadataCounts.get(metadata.dispatchRequestId) ?? 0) + 1);
|
|
531
|
+
if (operations._tag === "capacity") {
|
|
532
|
+
throw new WorkStoreError({ cause: operations, operation: "open.migrate.lane-operation-capacity" });
|
|
533
|
+
}
|
|
534
|
+
for (const { lane } of migratedClaims) {
|
|
535
|
+
update.run(lane.goalId, lane.operationId, lane.phase, JSON.stringify(lane), lane.laneId);
|
|
536
|
+
}
|
|
537
|
+
if (hasLedger && operations.inserts.length > 0) {
|
|
538
|
+
const recordOperation = database.prepare(`INSERT INTO work_lane_operations
|
|
539
|
+
(operation_id, lane_id, goal_id, phase, revision, record) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
540
|
+
for (const lane of operations.inserts) {
|
|
541
|
+
recordOperation.run(lane.operationId, lane.laneId, lane.goalId, lane.phase, lane.revision, JSON.stringify(lane));
|
|
335
542
|
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (decisionColumns.length > 0 && !decisionColumns.includes("session_id")) {
|
|
546
|
+
database.exec("ALTER TABLE work_decision_handoffs ADD COLUMN session_id TEXT");
|
|
547
|
+
const update = database.prepare("UPDATE work_decision_handoffs SET session_id = ?, record = ? WHERE handoff_id = ?");
|
|
548
|
+
for (const decision of decisions)
|
|
549
|
+
update.run(decision.sessionId, JSON.stringify(decision), decision.id);
|
|
550
|
+
if (dispatches.length > 0) {
|
|
551
|
+
const updateDispatch = database.prepare("UPDATE work_dispatch_handoffs SET record = ? WHERE dispatch_request_id = ?");
|
|
552
|
+
const updateMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
553
|
+
? database.prepare("UPDATE orchestrator_dispatch_metadata SET work_link = ? WHERE dispatch_request_id = ?")
|
|
554
|
+
: null;
|
|
336
555
|
for (const dispatch of dispatches) {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
const invalidMetadata = linkedMetadata.find((metadata) => {
|
|
344
|
-
if (metadata.route === null)
|
|
345
|
-
return true;
|
|
346
|
-
const requiresWorkLink = coordinatorRouteRequiresWorkLink(metadata.route, routeStorageAuthority(metadata.dispatchRequestId, "open.migrate.metadata-decision"), "open.migrate.metadata-decision");
|
|
347
|
-
if (!requiresWorkLink)
|
|
348
|
-
return metadata.hasWorkLink === 1;
|
|
349
|
-
const dispatchIdentity = JSON.stringify([metadata.dispatchRequestId, metadata.handoffId]);
|
|
350
|
-
return (metadata.handoffId === null || metadataCounts.get(metadata.dispatchRequestId) !== 1 ||
|
|
351
|
-
dispatchCounts.get(dispatchIdentity) !== 1 || decisionCounts.get(metadata.handoffId) !== 1);
|
|
352
|
-
});
|
|
353
|
-
if (invalidMetadata !== undefined) {
|
|
354
|
-
throw new WorkStoreError({
|
|
355
|
-
cause: { decisionIdentities, dispatches, linkedMetadata, metadata: invalidMetadata },
|
|
356
|
-
operation: "open.migrate.metadata-decision"
|
|
357
|
-
});
|
|
556
|
+
const decision = decisions.find(({ id }) => id === dispatch.handoffId);
|
|
557
|
+
if (decision === undefined)
|
|
558
|
+
continue;
|
|
559
|
+
updateDispatch.run(JSON.stringify(decision), dispatch.dispatchRequestId);
|
|
560
|
+
updateMetadata?.run(JSON.stringify({ handoff: decision, lineage: decision.dispatchIds }), dispatch.dispatchRequestId);
|
|
358
561
|
}
|
|
359
562
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
:
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const dispatchIds = dispatch === undefined
|
|
386
|
-
? []
|
|
387
|
-
: Schema.decodeUnknownSync(WorkDispatchHandoff.fields.lineage)(JSON.parse(dispatch.lineage));
|
|
388
|
-
if (matchingDispatches.length !== 1 || dispatch === undefined) {
|
|
389
|
-
throw new WorkStoreError({
|
|
390
|
-
cause: { legacy, matchingDispatches },
|
|
391
|
-
operation: "open.migrate.legacy-dispatch-cardinality"
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
const dispatchHandoff = Schema.decodeUnknownSync(LegacyDecisionRecord)(JSON.parse(dispatch.record));
|
|
395
|
-
if (dispatch.laneId !== legacy.laneId || dispatch.occurredAt !== legacy.occurredAt ||
|
|
396
|
-
!legacyDecisionEquivalent(dispatchHandoff, legacy)) {
|
|
397
|
-
throw new WorkStoreError({
|
|
398
|
-
cause: { dispatch, dispatchHandoff, legacy },
|
|
399
|
-
operation: "open.migrate.legacy-dispatch-authority"
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
if (tables.includes("orchestrator_dispatch_metadata")) {
|
|
403
|
-
const metadataRows = Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
404
|
-
FROM orchestrator_dispatch_metadata WHERE dispatch_request_id = ? LIMIT 2`).all(dispatch.dispatchRequestId));
|
|
405
|
-
const metadata = metadataRows[0];
|
|
406
|
-
if (metadataRows.length !== 1 || metadata === undefined) {
|
|
563
|
+
}
|
|
564
|
+
if (decisionColumns.length > 0) {
|
|
565
|
+
const storedDecisions = Schema.decodeUnknownSync(Schema.Array(DecisionRow))(database.prepare(`SELECT handoff_id AS handoffId, session_id AS sessionId, lane_id AS laneId,
|
|
566
|
+
occurred_at AS occurredAt, record
|
|
567
|
+
FROM work_decision_handoffs`).all());
|
|
568
|
+
const updateDecision = database.prepare("UPDATE work_decision_handoffs SET record = ? WHERE handoff_id = ?");
|
|
569
|
+
const updateDispatch = tables.includes("work_dispatch_handoffs")
|
|
570
|
+
? database.prepare("UPDATE work_dispatch_handoffs SET record = ? WHERE handoff_id = ?")
|
|
571
|
+
: null;
|
|
572
|
+
const updateMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
573
|
+
? database.prepare(`UPDATE orchestrator_dispatch_metadata SET work_link = ?
|
|
574
|
+
WHERE dispatch_request_id = ?`)
|
|
575
|
+
: null;
|
|
576
|
+
const previousDecisions = storedDecisions.flatMap((row) => {
|
|
577
|
+
const input = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(row.record);
|
|
578
|
+
const previous = decodePreviousDecisionHandoff(input);
|
|
579
|
+
const handoffDispatches = tables.includes("work_dispatch_handoffs")
|
|
580
|
+
? Schema.decodeUnknownSync(Schema.Array(LegacyDispatchStoredRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, handoff_id AS handoffId,
|
|
581
|
+
lane_id AS laneId, occurred_at AS occurredAt, lineage, record
|
|
582
|
+
FROM work_dispatch_handoffs WHERE handoff_id = ?`).all(row.handoffId))
|
|
583
|
+
: [];
|
|
584
|
+
switch (previous._tag) {
|
|
585
|
+
case "current": {
|
|
586
|
+
if (previous.value.id !== row.handoffId || previous.value.sessionId !== row.sessionId ||
|
|
587
|
+
previous.value.laneId !== row.laneId || previous.value.occurredAt !== row.occurredAt) {
|
|
407
588
|
throw new WorkStoreError({
|
|
408
|
-
cause: {
|
|
409
|
-
operation: "open.migrate.
|
|
589
|
+
cause: { handoff: previous.value, row },
|
|
590
|
+
operation: "open.migrate.handoff-identity"
|
|
410
591
|
});
|
|
411
592
|
}
|
|
412
|
-
if (
|
|
593
|
+
if (handoffDispatches.length > 1) {
|
|
413
594
|
throw new WorkStoreError({
|
|
414
|
-
cause: {
|
|
415
|
-
operation: "open.migrate.
|
|
595
|
+
cause: { handoffDispatches, row },
|
|
596
|
+
operation: "open.migrate.dispatch-cardinality"
|
|
416
597
|
});
|
|
417
598
|
}
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
599
|
+
const linkedMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
600
|
+
? Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
601
|
+
FROM orchestrator_dispatch_metadata
|
|
602
|
+
WHERE work_link IS NOT NULL
|
|
603
|
+
AND CASE WHEN json_valid(work_link)
|
|
604
|
+
THEN json_extract(work_link, '$.handoff.id') END = ?`).all(row.handoffId))
|
|
605
|
+
: [];
|
|
606
|
+
if (tables.includes("orchestrator_dispatch_metadata") &&
|
|
607
|
+
(linkedMetadata.length !== handoffDispatches.length ||
|
|
608
|
+
linkedMetadata.some((metadata) => !handoffDispatches.some(({ dispatchRequestId }) => dispatchRequestId === metadata.dispatchRequestId)))) {
|
|
422
609
|
throw new WorkStoreError({
|
|
423
|
-
cause: {
|
|
424
|
-
operation: "open.migrate.
|
|
610
|
+
cause: { handoffDispatches, linkedMetadata, row },
|
|
611
|
+
operation: "open.migrate.metadata-authority"
|
|
425
612
|
});
|
|
426
613
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const bindingRow = bindingRows[0];
|
|
435
|
-
if (bindingRows.length !== 1 || bindingRow === undefined) {
|
|
436
|
-
throw new WorkStoreError({
|
|
437
|
-
cause: { bindingRows, dispatch, legacy },
|
|
438
|
-
operation: "open.migrate.legacy-handoff-revision"
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
const bindingDecision = decodeAgentBindingRow(bindingRow, { dispatchRequestId: dispatch.dispatchRequestId, laneId: legacy.laneId }, "open.migrate.legacy-agent-binding");
|
|
442
|
-
if (bindingDecision._tag === "invalid")
|
|
443
|
-
throw bindingDecision.error;
|
|
444
|
-
if (bindingDecision.binding.lane.goalId !== legacy.goalId) {
|
|
445
|
-
throw new WorkStoreError({
|
|
446
|
-
cause: { binding: bindingDecision.binding, legacy },
|
|
447
|
-
operation: "open.migrate.legacy-agent-binding.goal"
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
if (lane.revision < bindingDecision.binding.lane.revision) {
|
|
451
|
-
throw new WorkStoreError({
|
|
452
|
-
cause: { binding: bindingDecision.binding, lane, legacy },
|
|
453
|
-
operation: "open.migrate.legacy-handoff-lane-revision"
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
requireBindingCompanions(bindingDecision.binding, "open.migrate.legacy-agent-binding");
|
|
457
|
-
requireLifecycleAuthority(bindingDecision.binding.request.dispatchRequestId, bindingDecision.binding.checkpoint.occurredAt, "open.migrate.legacy-agent-binding.lifecycle");
|
|
458
|
-
return Schema.decodeUnknownSync(WorkDecisionHandoff)({
|
|
459
|
-
...legacy,
|
|
460
|
-
contextDelta: legacy.summary,
|
|
461
|
-
expectedRevision: bindingDecision.binding.request.expectedRevision,
|
|
462
|
-
sessionId: legacy.id,
|
|
463
|
-
dispatchIds,
|
|
464
|
-
blockers: [],
|
|
465
|
-
evidenceRefs: [],
|
|
466
|
-
version: "herdr.work.decision.v2"
|
|
467
|
-
});
|
|
468
|
-
})
|
|
469
|
-
: [];
|
|
470
|
-
if (laneColumns.length > 0 && !laneColumns.includes("goal_id")) {
|
|
471
|
-
database.exec("ALTER TABLE work_lane_claims ADD COLUMN goal_id TEXT");
|
|
472
|
-
database.exec("ALTER TABLE work_lane_claims ADD COLUMN operation_id TEXT");
|
|
473
|
-
database.exec("ALTER TABLE work_lane_claims ADD COLUMN phase TEXT");
|
|
474
|
-
const update = database.prepare("UPDATE work_lane_claims SET goal_id = ?, operation_id = ?, phase = ?, record = ? WHERE lane_id = ?");
|
|
475
|
-
for (const lane of lanes)
|
|
476
|
-
update.run(lane.goalId, lane.operationId, lane.phase, JSON.stringify(lane), lane.laneId);
|
|
477
|
-
}
|
|
478
|
-
if (decisionColumns.length > 0 && !decisionColumns.includes("session_id")) {
|
|
479
|
-
database.exec("ALTER TABLE work_decision_handoffs ADD COLUMN session_id TEXT");
|
|
480
|
-
const update = database.prepare("UPDATE work_decision_handoffs SET session_id = ?, record = ? WHERE handoff_id = ?");
|
|
481
|
-
for (const decision of decisions)
|
|
482
|
-
update.run(decision.sessionId, JSON.stringify(decision), decision.id);
|
|
483
|
-
if (dispatches.length > 0) {
|
|
484
|
-
const updateDispatch = database.prepare("UPDATE work_dispatch_handoffs SET record = ? WHERE dispatch_request_id = ?");
|
|
485
|
-
const updateMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
486
|
-
? database.prepare("UPDATE orchestrator_dispatch_metadata SET work_link = ? WHERE dispatch_request_id = ?")
|
|
487
|
-
: null;
|
|
488
|
-
for (const dispatch of dispatches) {
|
|
489
|
-
const decision = decisions.find(({ id }) => id === dispatch.handoffId);
|
|
490
|
-
if (decision === undefined)
|
|
491
|
-
continue;
|
|
492
|
-
updateDispatch.run(JSON.stringify(decision), dispatch.dispatchRequestId);
|
|
493
|
-
updateMetadata?.run(JSON.stringify({ handoff: decision, lineage: decision.dispatchIds }), dispatch.dispatchRequestId);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
if (decisionColumns.length > 0) {
|
|
498
|
-
const storedDecisions = Schema.decodeUnknownSync(Schema.Array(DecisionRow))(database.prepare(`SELECT handoff_id AS handoffId, session_id AS sessionId, lane_id AS laneId,
|
|
499
|
-
occurred_at AS occurredAt, record
|
|
500
|
-
FROM work_decision_handoffs`).all());
|
|
501
|
-
const updateDecision = database.prepare("UPDATE work_decision_handoffs SET record = ? WHERE handoff_id = ?");
|
|
502
|
-
const updateDispatch = tables.includes("work_dispatch_handoffs")
|
|
503
|
-
? database.prepare("UPDATE work_dispatch_handoffs SET record = ? WHERE handoff_id = ?")
|
|
504
|
-
: null;
|
|
505
|
-
const updateMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
506
|
-
? database.prepare(`UPDATE orchestrator_dispatch_metadata SET work_link = ?
|
|
507
|
-
WHERE dispatch_request_id = ?`)
|
|
508
|
-
: null;
|
|
509
|
-
const previousDecisions = storedDecisions.flatMap((row) => {
|
|
510
|
-
const input = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))(row.record);
|
|
511
|
-
const previous = decodePreviousDecisionHandoff(input);
|
|
512
|
-
const handoffDispatches = tables.includes("work_dispatch_handoffs")
|
|
513
|
-
? Schema.decodeUnknownSync(Schema.Array(LegacyDispatchStoredRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, handoff_id AS handoffId,
|
|
514
|
-
lane_id AS laneId, occurred_at AS occurredAt, lineage, record
|
|
515
|
-
FROM work_dispatch_handoffs WHERE handoff_id = ?`).all(row.handoffId))
|
|
516
|
-
: [];
|
|
517
|
-
switch (previous._tag) {
|
|
518
|
-
case "current": {
|
|
519
|
-
if (previous.value.id !== row.handoffId || previous.value.sessionId !== row.sessionId ||
|
|
520
|
-
previous.value.laneId !== row.laneId || previous.value.occurredAt !== row.occurredAt) {
|
|
521
|
-
throw new WorkStoreError({
|
|
522
|
-
cause: { handoff: previous.value, row },
|
|
523
|
-
operation: "open.migrate.handoff-identity"
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
if (handoffDispatches.length > 1) {
|
|
527
|
-
throw new WorkStoreError({
|
|
528
|
-
cause: { handoffDispatches, row },
|
|
529
|
-
operation: "open.migrate.dispatch-cardinality"
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
const linkedMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
533
|
-
? Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
534
|
-
FROM orchestrator_dispatch_metadata
|
|
535
|
-
WHERE work_link IS NOT NULL
|
|
536
|
-
AND CASE WHEN json_valid(work_link)
|
|
537
|
-
THEN json_extract(work_link, '$.handoff.id') END = ?`).all(row.handoffId))
|
|
538
|
-
: [];
|
|
539
|
-
if (tables.includes("orchestrator_dispatch_metadata") &&
|
|
540
|
-
(linkedMetadata.length !== handoffDispatches.length ||
|
|
541
|
-
linkedMetadata.some((metadata) => !handoffDispatches.some(({ dispatchRequestId }) => dispatchRequestId === metadata.dispatchRequestId)))) {
|
|
542
|
-
throw new WorkStoreError({
|
|
543
|
-
cause: { handoffDispatches, linkedMetadata, row },
|
|
544
|
-
operation: "open.migrate.metadata-authority"
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
for (const dispatch of handoffDispatches) {
|
|
548
|
-
const dispatchAuthority = (() => {
|
|
549
|
-
try {
|
|
550
|
-
return {
|
|
551
|
-
handoff: Schema.decodeUnknownSync(WorkDecisionHandoff)(JSON.parse(dispatch.record)),
|
|
552
|
-
lineage: Schema.decodeUnknownSync(WorkDispatchHandoff.fields.lineage)(JSON.parse(dispatch.lineage))
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
catch (cause) {
|
|
556
|
-
throw new WorkStoreError({ cause, operation: "open.migrate.decode-dispatch-authority" });
|
|
557
|
-
}
|
|
558
|
-
})();
|
|
559
|
-
if (dispatch.laneId !== previous.value.laneId ||
|
|
560
|
-
dispatch.occurredAt !== previous.value.occurredAt ||
|
|
561
|
-
!currentDecisionHandoffEquivalent(dispatchAuthority.handoff, previous.value) ||
|
|
562
|
-
!workDispatchLineageContainedBy(dispatchAuthority.lineage, previous.value.dispatchIds)) {
|
|
563
|
-
throw new WorkStoreError({
|
|
564
|
-
cause: { dispatch, dispatchAuthority, handoff: previous.value },
|
|
565
|
-
operation: "open.migrate.dispatch-authority"
|
|
566
|
-
});
|
|
614
|
+
for (const dispatch of handoffDispatches) {
|
|
615
|
+
const dispatchAuthority = (() => {
|
|
616
|
+
try {
|
|
617
|
+
return {
|
|
618
|
+
handoff: Schema.decodeUnknownSync(WorkDecisionHandoff)(JSON.parse(dispatch.record)),
|
|
619
|
+
lineage: Schema.decodeUnknownSync(WorkDispatchHandoff.fields.lineage)(JSON.parse(dispatch.lineage))
|
|
620
|
+
};
|
|
567
621
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const metadata = linkedMetadata.find(({ dispatchRequestId }) => dispatchRequestId === dispatch.dispatchRequestId);
|
|
571
|
-
if (metadata?.workLink === null || metadata?.workLink === undefined) {
|
|
572
|
-
throw new WorkStoreError({
|
|
573
|
-
cause: { dispatch, metadata },
|
|
574
|
-
operation: "open.migrate.metadata-authority"
|
|
575
|
-
});
|
|
622
|
+
catch (cause) {
|
|
623
|
+
throw new WorkStoreError({ cause, operation: "open.migrate.decode-dispatch-authority" });
|
|
576
624
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (!currentDecisionHandoffEquivalent(workLink.handoff, previous.value) ||
|
|
587
|
-
!workDispatchLineageEquivalent(workLink.lineage, dispatchAuthority.lineage)) {
|
|
588
|
-
throw new WorkStoreError({
|
|
589
|
-
cause: { handoff: previous.value, workLink },
|
|
590
|
-
operation: "open.migrate.metadata-authority"
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
requireCurrentAgentBindingAuthority(dispatch.dispatchRequestId, previous.value, "open.migrate.current-agent-binding");
|
|
625
|
+
})();
|
|
626
|
+
if (dispatch.laneId !== previous.value.laneId ||
|
|
627
|
+
dispatch.occurredAt !== previous.value.occurredAt ||
|
|
628
|
+
!currentDecisionHandoffEquivalent(dispatchAuthority.handoff, previous.value) ||
|
|
629
|
+
!workDispatchLineageContainedBy(dispatchAuthority.lineage, previous.value.dispatchIds)) {
|
|
630
|
+
throw new WorkStoreError({
|
|
631
|
+
cause: { dispatch, dispatchAuthority, handoff: previous.value },
|
|
632
|
+
operation: "open.migrate.dispatch-authority"
|
|
633
|
+
});
|
|
594
634
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
case "invalid":
|
|
598
|
-
throw new WorkStoreError({
|
|
599
|
-
cause: previous.cause,
|
|
600
|
-
operation: "open.migrate.invalid-handoff"
|
|
601
|
-
});
|
|
602
|
-
case "previous":
|
|
603
|
-
return [{ previous: previous.value, row }];
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
for (const { previous, row } of previousDecisions) {
|
|
607
|
-
const laneRevision = Schema.decodeUnknownSync(LaneRevisionRow)(database.prepare("SELECT revision FROM work_lane_claims WHERE lane_id = ?").get(row.laneId));
|
|
608
|
-
const handoffDispatches = tables.includes("work_dispatch_handoffs")
|
|
609
|
-
? Schema.decodeUnknownSync(Schema.Array(LegacyDispatchStoredRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, handoff_id AS handoffId,
|
|
610
|
-
lane_id AS laneId, occurred_at AS occurredAt, lineage, record
|
|
611
|
-
FROM work_dispatch_handoffs WHERE handoff_id = ?`).all(row.handoffId))
|
|
612
|
-
: [];
|
|
613
|
-
if (handoffDispatches.length > 1) {
|
|
614
|
-
throw new WorkStoreError({
|
|
615
|
-
cause: { handoffDispatches, row },
|
|
616
|
-
operation: "open.migrate.dispatch-cardinality"
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
const linkedMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
620
|
-
? Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
621
|
-
FROM orchestrator_dispatch_metadata
|
|
622
|
-
WHERE work_link IS NOT NULL
|
|
623
|
-
AND CASE WHEN json_valid(work_link)
|
|
624
|
-
THEN json_extract(work_link, '$.handoff.id') END = ?`).all(row.handoffId))
|
|
625
|
-
: [];
|
|
626
|
-
if (tables.includes("orchestrator_dispatch_metadata") &&
|
|
627
|
-
(linkedMetadata.length !== handoffDispatches.length ||
|
|
628
|
-
linkedMetadata.some((metadata) => !handoffDispatches.some(({ dispatchRequestId }) => dispatchRequestId === metadata.dispatchRequestId)))) {
|
|
629
|
-
throw new WorkStoreError({
|
|
630
|
-
cause: { handoffDispatches, linkedMetadata, row },
|
|
631
|
-
operation: "open.migrate.metadata-authority"
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
const bindingRows = tables.includes("work_agent_bindings") && tables.includes("work_dispatch_handoffs")
|
|
635
|
-
? Schema.decodeUnknownSync(Schema.Array(AgentBindingRow))(database.prepare(`SELECT binding.dispatch_request_id AS dispatchRequestId, binding.lane_id AS laneId,
|
|
636
|
-
binding.expected_revision AS expectedRevision, binding.revision,
|
|
637
|
-
binding.agent_id AS agentId, binding.host, binding.record
|
|
638
|
-
FROM work_agent_bindings binding
|
|
639
|
-
JOIN work_dispatch_handoffs dispatch
|
|
640
|
-
ON dispatch.dispatch_request_id = binding.dispatch_request_id
|
|
641
|
-
WHERE dispatch.handoff_id = ?`).all(row.handoffId))
|
|
642
|
-
: [];
|
|
643
|
-
const bindingRow = bindingRows[0];
|
|
644
|
-
const handoffDispatch = bindingRow === undefined
|
|
645
|
-
? undefined
|
|
646
|
-
: handoffDispatches.find(({ dispatchRequestId }) => dispatchRequestId === bindingRow.dispatchRequestId);
|
|
647
|
-
if (bindingRows.length !== 1 || bindingRow === undefined || handoffDispatch === undefined) {
|
|
648
|
-
throw new WorkStoreError({ cause: { bindingRows, row }, operation: "open.migrate.handoff-revision" });
|
|
649
|
-
}
|
|
650
|
-
const bindingDecision = decodeAgentBindingRow(bindingRow, { dispatchRequestId: handoffDispatch.dispatchRequestId, laneId: previous.laneId }, "open.migrate.agent-binding");
|
|
651
|
-
if (bindingDecision._tag === "invalid")
|
|
652
|
-
throw bindingDecision.error;
|
|
653
|
-
if (laneRevision.revision < bindingDecision.binding.lane.revision) {
|
|
654
|
-
throw new WorkStoreError({
|
|
655
|
-
cause: { binding: bindingDecision.binding, laneRevision, previous },
|
|
656
|
-
operation: "open.migrate.handoff-lane-revision"
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
if (bindingDecision.binding.lane.goalId !== previous.goalId) {
|
|
660
|
-
throw new WorkStoreError({
|
|
661
|
-
cause: { binding: bindingDecision.binding, previous },
|
|
662
|
-
operation: "open.migrate.agent-binding.goal"
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
requireBindingCompanions(bindingDecision.binding, "open.migrate.agent-binding");
|
|
666
|
-
requireLifecycleAuthority(bindingDecision.binding.request.dispatchRequestId, bindingDecision.binding.checkpoint.occurredAt, "open.migrate.agent-binding.lifecycle");
|
|
667
|
-
const verifiedDispatches = handoffDispatches.map((dispatch) => {
|
|
668
|
-
const dispatchHandoff = Schema.decodeUnknownSync(PreviousWorkDecisionHandoff)(JSON.parse(dispatch.record));
|
|
669
|
-
const lineage = Schema.decodeUnknownSync(WorkDispatchHandoff.fields.lineage)(JSON.parse(dispatch.lineage));
|
|
670
|
-
if (dispatch.laneId !== previous.laneId || dispatch.occurredAt !== previous.occurredAt ||
|
|
671
|
-
!previousDecisionHandoffEquivalent(dispatchHandoff, previous) ||
|
|
672
|
-
!workDispatchLineageContainedBy(lineage, previous.dispatchIds)) {
|
|
673
|
-
throw new WorkStoreError({
|
|
674
|
-
cause: { dispatch, dispatchHandoff, previous },
|
|
675
|
-
operation: "open.migrate.dispatch-authority"
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
if (tables.includes("orchestrator_dispatch_metadata")) {
|
|
635
|
+
if (!tables.includes("orchestrator_dispatch_metadata"))
|
|
636
|
+
continue;
|
|
679
637
|
const metadata = linkedMetadata.find(({ dispatchRequestId }) => dispatchRequestId === dispatch.dispatchRequestId);
|
|
680
638
|
if (metadata?.workLink === null || metadata?.workLink === undefined) {
|
|
681
639
|
throw new WorkStoreError({
|
|
@@ -683,116 +641,227 @@ const migrateLegacyAuthorityTables = (database) => {
|
|
|
683
641
|
operation: "open.migrate.metadata-authority"
|
|
684
642
|
});
|
|
685
643
|
}
|
|
686
|
-
const workLink =
|
|
644
|
+
const workLink = (() => {
|
|
645
|
+
try {
|
|
646
|
+
return Schema.decodeUnknownSync(CurrentMetadataWorkLink)(JSON.parse(metadata.workLink));
|
|
647
|
+
}
|
|
648
|
+
catch (cause) {
|
|
649
|
+
throw new WorkStoreError({ cause, operation: "open.migrate.decode-metadata-authority" });
|
|
650
|
+
}
|
|
651
|
+
})();
|
|
687
652
|
requireMetadataAuthority(dispatch.dispatchRequestId, metadata.route, workLink.lineage, "open.migrate.metadata-authority");
|
|
688
|
-
if (!
|
|
689
|
-
!workDispatchLineageEquivalent(workLink.lineage, lineage)) {
|
|
653
|
+
if (!currentDecisionHandoffEquivalent(workLink.handoff, previous.value) ||
|
|
654
|
+
!workDispatchLineageEquivalent(workLink.lineage, dispatchAuthority.lineage)) {
|
|
690
655
|
throw new WorkStoreError({
|
|
691
|
-
cause: { previous, workLink },
|
|
656
|
+
cause: { handoff: previous.value, workLink },
|
|
692
657
|
operation: "open.migrate.metadata-authority"
|
|
693
658
|
});
|
|
694
659
|
}
|
|
660
|
+
requireCurrentAgentBindingAuthority(dispatch.dispatchRequestId, previous.value, "open.migrate.current-agent-binding");
|
|
695
661
|
}
|
|
696
|
-
return
|
|
697
|
-
});
|
|
698
|
-
const migrated = upgradePreviousDecisionHandoff(previous, bindingDecision.binding.request.expectedRevision);
|
|
699
|
-
if (migrated.id !== row.handoffId || migrated.sessionId !== row.sessionId ||
|
|
700
|
-
migrated.laneId !== row.laneId || migrated.occurredAt !== row.occurredAt) {
|
|
701
|
-
throw new WorkStoreError({ cause: { migrated, row }, operation: "open.migrate.handoff-identity" });
|
|
702
|
-
}
|
|
703
|
-
const encoded = JSON.stringify(migrated);
|
|
704
|
-
updateDecision.run(encoded, row.handoffId);
|
|
705
|
-
updateDispatch?.run(encoded, row.handoffId);
|
|
706
|
-
for (const { dispatch, lineage } of verifiedDispatches) {
|
|
707
|
-
updateMetadata?.run(JSON.stringify({ handoff: migrated, lineage }), dispatch.dispatchRequestId);
|
|
662
|
+
return [];
|
|
708
663
|
}
|
|
664
|
+
case "invalid":
|
|
665
|
+
throw new WorkStoreError({
|
|
666
|
+
cause: previous.cause,
|
|
667
|
+
operation: "open.migrate.invalid-handoff"
|
|
668
|
+
});
|
|
669
|
+
case "previous":
|
|
670
|
+
return [{ previous: previous.value, row }];
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
for (const { previous, row } of previousDecisions) {
|
|
674
|
+
const laneRevision = Schema.decodeUnknownSync(LaneRevisionRow)(database.prepare("SELECT revision FROM work_lane_claims WHERE lane_id = ?").get(row.laneId));
|
|
675
|
+
const handoffDispatches = tables.includes("work_dispatch_handoffs")
|
|
676
|
+
? Schema.decodeUnknownSync(Schema.Array(LegacyDispatchStoredRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, handoff_id AS handoffId,
|
|
677
|
+
lane_id AS laneId, occurred_at AS occurredAt, lineage, record
|
|
678
|
+
FROM work_dispatch_handoffs WHERE handoff_id = ?`).all(row.handoffId))
|
|
679
|
+
: [];
|
|
680
|
+
if (handoffDispatches.length > 1) {
|
|
681
|
+
throw new WorkStoreError({
|
|
682
|
+
cause: { handoffDispatches, row },
|
|
683
|
+
operation: "open.migrate.dispatch-cardinality"
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
const linkedMetadata = tables.includes("orchestrator_dispatch_metadata")
|
|
687
|
+
? Schema.decodeUnknownSync(Schema.Array(MetadataWorkLinkRow))(database.prepare(`SELECT dispatch_request_id AS dispatchRequestId, route, work_link AS workLink
|
|
688
|
+
FROM orchestrator_dispatch_metadata
|
|
689
|
+
WHERE work_link IS NOT NULL
|
|
690
|
+
AND CASE WHEN json_valid(work_link)
|
|
691
|
+
THEN json_extract(work_link, '$.handoff.id') END = ?`).all(row.handoffId))
|
|
692
|
+
: [];
|
|
693
|
+
if (tables.includes("orchestrator_dispatch_metadata") &&
|
|
694
|
+
(linkedMetadata.length !== handoffDispatches.length ||
|
|
695
|
+
linkedMetadata.some((metadata) => !handoffDispatches.some(({ dispatchRequestId }) => dispatchRequestId === metadata.dispatchRequestId)))) {
|
|
696
|
+
throw new WorkStoreError({
|
|
697
|
+
cause: { handoffDispatches, linkedMetadata, row },
|
|
698
|
+
operation: "open.migrate.metadata-authority"
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
const bindingRows = tables.includes("work_agent_bindings") && tables.includes("work_dispatch_handoffs")
|
|
702
|
+
? Schema.decodeUnknownSync(Schema.Array(AgentBindingRow))(database.prepare(`SELECT binding.dispatch_request_id AS dispatchRequestId, binding.lane_id AS laneId,
|
|
703
|
+
binding.expected_revision AS expectedRevision, binding.revision,
|
|
704
|
+
binding.agent_id AS agentId, binding.host, binding.record
|
|
705
|
+
FROM work_agent_bindings binding
|
|
706
|
+
JOIN work_dispatch_handoffs dispatch
|
|
707
|
+
ON dispatch.dispatch_request_id = binding.dispatch_request_id
|
|
708
|
+
WHERE dispatch.handoff_id = ?`).all(row.handoffId))
|
|
709
|
+
: [];
|
|
710
|
+
const bindingRow = bindingRows[0];
|
|
711
|
+
const handoffDispatch = bindingRow === undefined
|
|
712
|
+
? undefined
|
|
713
|
+
: handoffDispatches.find(({ dispatchRequestId }) => dispatchRequestId === bindingRow.dispatchRequestId);
|
|
714
|
+
if (bindingRows.length !== 1 || bindingRow === undefined || handoffDispatch === undefined) {
|
|
715
|
+
throw new WorkStoreError({ cause: { bindingRows, row }, operation: "open.migrate.handoff-revision" });
|
|
709
716
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
717
|
+
const bindingDecision = decodeAgentBindingRow(bindingRow, { dispatchRequestId: handoffDispatch.dispatchRequestId, laneId: previous.laneId }, "open.migrate.agent-binding");
|
|
718
|
+
if (bindingDecision._tag === "invalid")
|
|
719
|
+
throw bindingDecision.error;
|
|
720
|
+
if (laneRevision.revision < bindingDecision.binding.lane.revision) {
|
|
721
|
+
throw new WorkStoreError({
|
|
722
|
+
cause: { binding: bindingDecision.binding, laneRevision, previous },
|
|
723
|
+
operation: "open.migrate.handoff-lane-revision"
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
if (bindingDecision.binding.lane.goalId !== previous.goalId) {
|
|
727
|
+
throw new WorkStoreError({
|
|
728
|
+
cause: { binding: bindingDecision.binding, previous },
|
|
729
|
+
operation: "open.migrate.agent-binding.goal"
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
requireBindingCompanions(bindingDecision.binding, "open.migrate.agent-binding");
|
|
733
|
+
requireLifecycleAuthority(bindingDecision.binding.request.dispatchRequestId, bindingDecision.binding.checkpoint.occurredAt, "open.migrate.agent-binding.lifecycle");
|
|
734
|
+
const verifiedDispatches = handoffDispatches.map((dispatch) => {
|
|
735
|
+
const dispatchHandoff = Schema.decodeUnknownSync(PreviousWorkDecisionHandoff)(JSON.parse(dispatch.record));
|
|
736
|
+
const lineage = Schema.decodeUnknownSync(WorkDispatchHandoff.fields.lineage)(JSON.parse(dispatch.lineage));
|
|
737
|
+
if (dispatch.laneId !== previous.laneId || dispatch.occurredAt !== previous.occurredAt ||
|
|
738
|
+
!previousDecisionHandoffEquivalent(dispatchHandoff, previous) ||
|
|
739
|
+
!workDispatchLineageContainedBy(lineage, previous.dispatchIds)) {
|
|
716
740
|
throw new WorkStoreError({
|
|
717
|
-
cause:
|
|
718
|
-
operation: "open.migrate.
|
|
741
|
+
cause: { dispatch, dispatchHandoff, previous },
|
|
742
|
+
operation: "open.migrate.dispatch-authority"
|
|
719
743
|
});
|
|
720
744
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
? database.prepare(`SELECT CAST(metadata.rowid AS TEXT) AS rowId,
|
|
729
|
-
metadata.dispatch_request_id AS dispatchRequestId,
|
|
730
|
-
dispatch.activity_idempotency_key AS activityIdempotencyKey,
|
|
731
|
-
dispatch.command, dispatch.is_routed AS isRouted,
|
|
732
|
-
CASE WHEN metadata.work_link IS NULL THEN 0 ELSE 1 END AS hasWorkLink,
|
|
733
|
-
metadata.route
|
|
734
|
-
FROM orchestrator_dispatch_metadata AS metadata
|
|
735
|
-
LEFT JOIN orchestrator_dispatches AS dispatch
|
|
736
|
-
ON dispatch.dispatch_request_id = metadata.dispatch_request_id
|
|
737
|
-
ORDER BY metadata.rowid ASC LIMIT ?`).all(pageSize)
|
|
738
|
-
: database.prepare(`SELECT CAST(metadata.rowid AS TEXT) AS rowId,
|
|
739
|
-
metadata.dispatch_request_id AS dispatchRequestId,
|
|
740
|
-
dispatch.activity_idempotency_key AS activityIdempotencyKey,
|
|
741
|
-
dispatch.command, dispatch.is_routed AS isRouted,
|
|
742
|
-
CASE WHEN metadata.work_link IS NULL THEN 0 ELSE 1 END AS hasWorkLink,
|
|
743
|
-
metadata.route
|
|
744
|
-
FROM orchestrator_dispatch_metadata AS metadata
|
|
745
|
-
LEFT JOIN orchestrator_dispatches AS dispatch
|
|
746
|
-
ON dispatch.dispatch_request_id = metadata.dispatch_request_id
|
|
747
|
-
WHERE metadata.rowid > CAST(? AS INTEGER)
|
|
748
|
-
ORDER BY metadata.rowid ASC LIMIT ?`).all(cursor, pageSize);
|
|
749
|
-
const rows = Schema.decodeUnknownSync(Schema.Array(CoordinatorMetadataRouteRow))(input);
|
|
750
|
-
for (const row of rows) {
|
|
751
|
-
try {
|
|
752
|
-
const isLegacyUnroutedOrphan = row.activityIdempotencyKey === null && row.command === null &&
|
|
753
|
-
row.isRouted === null && row.route === null && row.hasWorkLink === 0;
|
|
754
|
-
if (isLegacyUnroutedOrphan)
|
|
755
|
-
continue;
|
|
756
|
-
if (row.activityIdempotencyKey === null || row.command === null ||
|
|
757
|
-
row.route === null || row.isRouted === null) {
|
|
758
|
-
throw new WorkStoreError({ cause: row, operation: "open.migrate.metadata-route" });
|
|
759
|
-
}
|
|
760
|
-
requireCoordinatorRouteBinding(row.command, row.activityIdempotencyKey, row.route, row.hasWorkLink === 1, { _tag: "routed_discriminator", isRouted: row.isRouted }, "open.migrate.metadata-route");
|
|
745
|
+
if (tables.includes("orchestrator_dispatch_metadata")) {
|
|
746
|
+
const metadata = linkedMetadata.find(({ dispatchRequestId }) => dispatchRequestId === dispatch.dispatchRequestId);
|
|
747
|
+
if (metadata?.workLink === null || metadata?.workLink === undefined) {
|
|
748
|
+
throw new WorkStoreError({
|
|
749
|
+
cause: { dispatch, metadata },
|
|
750
|
+
operation: "open.migrate.metadata-authority"
|
|
751
|
+
});
|
|
761
752
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
753
|
+
const workLink = Schema.decodeUnknownSync(PreviousMetadataWorkLink)(JSON.parse(metadata.workLink));
|
|
754
|
+
requireMetadataAuthority(dispatch.dispatchRequestId, metadata.route, workLink.lineage, "open.migrate.metadata-authority");
|
|
755
|
+
if (!previousDecisionHandoffEquivalent(workLink.handoff, previous) ||
|
|
756
|
+
!workDispatchLineageEquivalent(workLink.lineage, lineage)) {
|
|
757
|
+
throw new WorkStoreError({
|
|
758
|
+
cause: { previous, workLink },
|
|
759
|
+
operation: "open.migrate.metadata-authority"
|
|
760
|
+
});
|
|
766
761
|
}
|
|
767
762
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
763
|
+
return { dispatch, lineage };
|
|
764
|
+
});
|
|
765
|
+
const migrated = upgradePreviousDecisionHandoff(previous, bindingDecision.binding.request.expectedRevision);
|
|
766
|
+
if (migrated.id !== row.handoffId || migrated.sessionId !== row.sessionId ||
|
|
767
|
+
migrated.laneId !== row.laneId || migrated.occurredAt !== row.occurredAt) {
|
|
768
|
+
throw new WorkStoreError({ cause: { migrated, row }, operation: "open.migrate.handoff-identity" });
|
|
769
|
+
}
|
|
770
|
+
const encoded = JSON.stringify(migrated);
|
|
771
|
+
updateDecision.run(encoded, row.handoffId);
|
|
772
|
+
updateDispatch?.run(encoded, row.handoffId);
|
|
773
|
+
for (const { dispatch, lineage } of verifiedDispatches) {
|
|
774
|
+
updateMetadata?.run(JSON.stringify({ handoff: migrated, lineage }), dispatch.dispatchRequestId);
|
|
772
775
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
HAVING COUNT(metadata.dispatch_request_id) <> 1
|
|
781
|
-
LIMIT 1`).all())[0];
|
|
782
|
-
if (invalidRoutedMetadata !== undefined) {
|
|
776
|
+
}
|
|
777
|
+
if (decisions.length > 0 || previousDecisions.length > 0) {
|
|
778
|
+
const migratedLedger = Schema.decodeUnknownSync(LedgerBytesRow)(database.prepare(`SELECT COALESCE(SUM(
|
|
779
|
+
length(CAST(handoff_id AS BLOB)) + length(CAST(record AS BLOB))
|
|
780
|
+
), 0) AS bytes
|
|
781
|
+
FROM work_decision_handoffs`).get());
|
|
782
|
+
if (migratedLedger.bytes > workDecisionMaxBytes) {
|
|
783
783
|
throw new WorkStoreError({
|
|
784
|
-
cause:
|
|
785
|
-
operation: "open.migrate.
|
|
784
|
+
cause: migratedLedger,
|
|
785
|
+
operation: "open.migrate.handoff-capacity"
|
|
786
786
|
});
|
|
787
787
|
}
|
|
788
788
|
}
|
|
789
|
-
database.exec("COMMIT");
|
|
790
|
-
transaction = false;
|
|
791
789
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
790
|
+
// Reported after capacity: lane CAS allows one binding per revision, so
|
|
791
|
+
// several mean the file is inconsistent rather than merely large.
|
|
792
|
+
const ambiguousClaims = migratedClaims.filter(({ _tag }) => _tag === "ambiguous");
|
|
793
|
+
if (ambiguousClaims.length > 0) {
|
|
794
|
+
throw new WorkStoreError({ cause: { claims: ambiguousClaims }, operation: "open.migrate.lane-binding-ambiguous" });
|
|
795
|
+
}
|
|
796
|
+
const mismatchedClaims = migratedClaims.filter(({ _tag }) => _tag === "mismatch");
|
|
797
|
+
if (mismatchedClaims.length > 0) {
|
|
798
|
+
throw new WorkStoreError({ cause: { claims: mismatchedClaims }, operation: "open.migrate.lane-binding-mismatch" });
|
|
799
|
+
}
|
|
800
|
+
if (tables.includes("orchestrator_dispatch_metadata") && coordinatorDispatchColumns.includes("is_routed")) {
|
|
801
|
+
const pageSize = 512;
|
|
802
|
+
let cursor = null;
|
|
803
|
+
while (true) {
|
|
804
|
+
const input = cursor === null
|
|
805
|
+
? database.prepare(`SELECT CAST(metadata.rowid AS TEXT) AS rowId,
|
|
806
|
+
metadata.dispatch_request_id AS dispatchRequestId,
|
|
807
|
+
dispatch.activity_idempotency_key AS activityIdempotencyKey,
|
|
808
|
+
dispatch.command, dispatch.is_routed AS isRouted,
|
|
809
|
+
CASE WHEN metadata.work_link IS NULL THEN 0 ELSE 1 END AS hasWorkLink,
|
|
810
|
+
metadata.route
|
|
811
|
+
FROM orchestrator_dispatch_metadata AS metadata
|
|
812
|
+
LEFT JOIN orchestrator_dispatches AS dispatch
|
|
813
|
+
ON dispatch.dispatch_request_id = metadata.dispatch_request_id
|
|
814
|
+
ORDER BY metadata.rowid ASC LIMIT ?`).all(pageSize)
|
|
815
|
+
: database.prepare(`SELECT CAST(metadata.rowid AS TEXT) AS rowId,
|
|
816
|
+
metadata.dispatch_request_id AS dispatchRequestId,
|
|
817
|
+
dispatch.activity_idempotency_key AS activityIdempotencyKey,
|
|
818
|
+
dispatch.command, dispatch.is_routed AS isRouted,
|
|
819
|
+
CASE WHEN metadata.work_link IS NULL THEN 0 ELSE 1 END AS hasWorkLink,
|
|
820
|
+
metadata.route
|
|
821
|
+
FROM orchestrator_dispatch_metadata AS metadata
|
|
822
|
+
LEFT JOIN orchestrator_dispatches AS dispatch
|
|
823
|
+
ON dispatch.dispatch_request_id = metadata.dispatch_request_id
|
|
824
|
+
WHERE metadata.rowid > CAST(? AS INTEGER)
|
|
825
|
+
ORDER BY metadata.rowid ASC LIMIT ?`).all(cursor, pageSize);
|
|
826
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(CoordinatorMetadataRouteRow))(input);
|
|
827
|
+
for (const row of rows) {
|
|
828
|
+
try {
|
|
829
|
+
const isLegacyUnroutedOrphan = row.activityIdempotencyKey === null && row.command === null &&
|
|
830
|
+
row.isRouted === null && row.route === null && row.hasWorkLink === 0;
|
|
831
|
+
if (isLegacyUnroutedOrphan)
|
|
832
|
+
continue;
|
|
833
|
+
if (row.activityIdempotencyKey === null || row.command === null ||
|
|
834
|
+
row.route === null || row.isRouted === null) {
|
|
835
|
+
throw new WorkStoreError({ cause: row, operation: "open.migrate.metadata-route" });
|
|
836
|
+
}
|
|
837
|
+
requireCoordinatorRouteBinding(row.command, row.activityIdempotencyKey, row.route, row.hasWorkLink === 1, { _tag: "routed_discriminator", isRouted: row.isRouted }, "open.migrate.metadata-route");
|
|
838
|
+
}
|
|
839
|
+
catch (cause) {
|
|
840
|
+
if (Schema.is(WorkStoreError)(cause))
|
|
841
|
+
throw cause;
|
|
842
|
+
throw new WorkStoreError({ cause, operation: "open.migrate.metadata-route" });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const last = rows.at(-1);
|
|
846
|
+
if (rows.length < pageSize || last === undefined)
|
|
847
|
+
break;
|
|
848
|
+
cursor = last.rowId;
|
|
849
|
+
}
|
|
850
|
+
const invalidRoutedMetadata = Schema.decodeUnknownSync(Schema.Array(RoutedMetadataCardinalityRow))(database.prepare(`SELECT dispatch.dispatch_request_id AS dispatchRequestId,
|
|
851
|
+
COUNT(metadata.dispatch_request_id) AS metadataCount
|
|
852
|
+
FROM orchestrator_dispatches AS dispatch
|
|
853
|
+
LEFT JOIN orchestrator_dispatch_metadata AS metadata
|
|
854
|
+
ON metadata.dispatch_request_id = dispatch.dispatch_request_id
|
|
855
|
+
WHERE dispatch.is_routed = 1
|
|
856
|
+
GROUP BY dispatch.dispatch_request_id
|
|
857
|
+
HAVING COUNT(metadata.dispatch_request_id) <> 1
|
|
858
|
+
LIMIT 1`).all())[0];
|
|
859
|
+
if (invalidRoutedMetadata !== undefined) {
|
|
860
|
+
throw new WorkStoreError({
|
|
861
|
+
cause: invalidRoutedMetadata,
|
|
862
|
+
operation: "open.migrate.routed-metadata"
|
|
863
|
+
});
|
|
864
|
+
}
|
|
796
865
|
}
|
|
797
866
|
};
|
|
798
867
|
const readTransactionLedgerTotals = (database) => Schema.decodeUnknownSync(TransactionLedgerTotalsRow)(database.prepare(`SELECT transaction_count AS transactionCount, transaction_bytes AS transactionBytes
|
|
@@ -801,6 +870,7 @@ const readDecisionLedgerTotals = (database) => Schema.decodeUnknownSync(Decision
|
|
|
801
870
|
FROM work_decision_totals WHERE singleton = 1`).get());
|
|
802
871
|
const readLaneOperationLedgerTotals = (database) => Schema.decodeUnknownSync(LaneOperationLedgerTotalsRow)(database.prepare(`SELECT operation_count AS operationCount, operation_bytes AS operationBytes
|
|
803
872
|
FROM work_lane_operation_totals WHERE singleton = 1`).get());
|
|
873
|
+
const ObservationIdRow = Schema.Struct({ observationId: Schema.String });
|
|
804
874
|
const ReassignmentRow = Schema.Struct({ approvalJobId: Schema.String, goalId: Schema.String, record: Schema.String });
|
|
805
875
|
/** Applies a reassignment's agent choice; `keep` is only reached when the goal has no target. */
|
|
806
876
|
const withReassignedAgent = (goal, toAgent) => {
|
|
@@ -953,11 +1023,11 @@ const admissionState = (database, target) => {
|
|
|
953
1023
|
(claim.worktree === target.worktree && claim.laneId !== target.laneId)) ||
|
|
954
1024
|
authority.latest.some(({ request }) => request.worker.agentId === target.worker.agentId && request.laneId !== target.laneId) ||
|
|
955
1025
|
// A session id stays claimed by its lane even after a reassignment supersedes that binding.
|
|
956
|
-
bindings.some(({ request }) => (request
|
|
1026
|
+
bindings.some(({ request }) => (admissionEvidence(request)?.sessionId === target.sessionId && request.laneId !== target.laneId) ||
|
|
957
1027
|
(request.existingGoalRecovery?.sessionId === target.sessionId && request.laneId !== target.laneId)) || authority.ambiguousLanes.has(target.laneId);
|
|
958
1028
|
const exact = goal !== undefined && lane !== undefined && binding !== undefined &&
|
|
959
|
-
goal.goal.id === target.goalId && goal.goal.state
|
|
960
|
-
goal.goal.
|
|
1029
|
+
goal.goal.id === target.goalId && !isTerminalWorkState(goal.goal.state) &&
|
|
1030
|
+
goal.goal.goalFamily?.role === "canonical" &&
|
|
961
1031
|
goal.goal.goalFamily.canonicalGoalId === target.goalId &&
|
|
962
1032
|
Equal.equals(goal.goal.owner, target.owner) &&
|
|
963
1033
|
lane.goalId === target.goalId && lane.head === target.head &&
|
|
@@ -965,9 +1035,9 @@ const admissionState = (database, target) => {
|
|
|
965
1035
|
lane.phase !== "shipped" && Equal.equals(lane.owner, target.owner) &&
|
|
966
1036
|
binding.lane.laneId === target.laneId &&
|
|
967
1037
|
Equal.equals(binding.request.worker, target.worker) &&
|
|
968
|
-
(binding.request
|
|
969
|
-
binding.request
|
|
970
|
-
binding.request
|
|
1038
|
+
(admissionEvidence(binding.request) === undefined || (admissionEvidence(binding.request)?.sessionId === target.sessionId &&
|
|
1039
|
+
admissionEvidence(binding.request)?.workAssignment === target.expectedWork &&
|
|
1040
|
+
admissionEvidence(binding.request)?.baseHead === target.baseHead)) &&
|
|
971
1041
|
(binding.request.existingGoalRecovery === undefined ||
|
|
972
1042
|
(binding.request.existingGoalRecovery.sessionId === target.sessionId &&
|
|
973
1043
|
binding.request.existingGoalRecovery.workAssignment === target.expectedWork &&
|
|
@@ -994,7 +1064,7 @@ const admissionState = (database, target) => {
|
|
|
994
1064
|
entry.goalFamily?.canonicalGoalId === target.goalId ||
|
|
995
1065
|
entry.agentHierarchy?.agent.agentId === target.worker.agentId) || lanes.entries.some(({ claim }) => claim.laneId === target.laneId || claim.goalId === target.goalId ||
|
|
996
1066
|
claim.worktree === target.worktree) || authority.latest.some(({ request }) => request.worker.agentId === target.worker.agentId) ||
|
|
997
|
-
bindings.some(({ request }) => request
|
|
1067
|
+
bindings.some(({ request }) => admissionEvidence(request)?.sessionId === target.sessionId ||
|
|
998
1068
|
request.existingGoalRecovery?.sessionId === target.sessionId);
|
|
999
1069
|
if (conflicts) {
|
|
1000
1070
|
return { _tag: "conflict", target, reason: "durable PR, goal, lane, worker, or worktree is already bound" };
|
|
@@ -1131,8 +1201,7 @@ const recoveryState = (database, target) => {
|
|
|
1131
1201
|
goal.connectTarget !== null ||
|
|
1132
1202
|
(goal.agentHierarchy !== undefined && goal.agentHierarchy !== null) ||
|
|
1133
1203
|
(goal.review?.url !== null && goal.review?.url !== undefined && goal.review.url !== target.reviewUrl) ||
|
|
1134
|
-
goal.state
|
|
1135
|
-
goal.state === "deployed")) {
|
|
1204
|
+
isTerminalWorkState(goal.state))) {
|
|
1136
1205
|
return conflict("canonical goal history is linked, terminal, or belongs to another owner");
|
|
1137
1206
|
}
|
|
1138
1207
|
const bindingRows = Schema.decodeUnknownSync(AgentBindingRows)(database
|
|
@@ -1165,7 +1234,7 @@ const recoveryState = (database, target) => {
|
|
|
1165
1234
|
operations.claims.some(({ goalId, laneId, worktree }) => goalId === target.goalId || laneId === target.laneId || worktree === target.worktree) ||
|
|
1166
1235
|
bindings.some(({ request }) => request.laneId === target.laneId ||
|
|
1167
1236
|
request.worker.agentId === target.worker.agentId ||
|
|
1168
|
-
request
|
|
1237
|
+
admissionEvidence(request)?.sessionId === target.sessionId ||
|
|
1169
1238
|
request.existingGoalRecovery?.sessionId === target.sessionId) ||
|
|
1170
1239
|
decisions.some(({ record }) => {
|
|
1171
1240
|
const handoff = Schema.decodeUnknownSync(WorkDecisionHandoff)(JSON.parse(record));
|
|
@@ -1193,6 +1262,140 @@ const readRecoveryState = (database, target) => {
|
|
|
1193
1262
|
throw cause;
|
|
1194
1263
|
}
|
|
1195
1264
|
};
|
|
1265
|
+
const ReassignmentApprovalRow = Schema.Struct({ approvalJobId: Schema.String, goalId: Schema.String });
|
|
1266
|
+
const ReconcilerEventRow = Schema.Struct({ eventId: Schema.String, goalId: Schema.String });
|
|
1267
|
+
const ObservedFactRow = Schema.Struct({
|
|
1268
|
+
subject: Schema.String,
|
|
1269
|
+
observationId: Schema.String,
|
|
1270
|
+
observedAt: Schema.Number,
|
|
1271
|
+
confirmedAt: Schema.Number,
|
|
1272
|
+
record: Schema.String
|
|
1273
|
+
});
|
|
1274
|
+
const StoredFactRow = Schema.Struct({ observationId: Schema.String, confirmedAt: Schema.Number });
|
|
1275
|
+
const FactTotalsRow = Schema.Struct({ count: Schema.Number, bytes: Schema.Number });
|
|
1276
|
+
const EvictionRow = Schema.Struct({ subject: Schema.String, bytes: Schema.Number });
|
|
1277
|
+
const canonicalObservation = (observation) => observation._tag === "agent"
|
|
1278
|
+
? { ...observation, host: asciiLower(observation.host) }
|
|
1279
|
+
: { ...observation, repository: asciiLower(observation.repository) };
|
|
1280
|
+
/**
|
|
1281
|
+
* Applies each observation in one transaction, then evicts the oldest rows
|
|
1282
|
+
* until facts and failures are each within their bounds.
|
|
1283
|
+
*
|
|
1284
|
+
* - The same facts again keep their first-seen time and move `confirmed_at`.
|
|
1285
|
+
* - Different facts replace the stored ones, unless the stored ones were
|
|
1286
|
+
* confirmed at or after this observation (then it is stale).
|
|
1287
|
+
* - A good read ends the subject's run of failures. A failure starts a run,
|
|
1288
|
+
* or keeps the running one's `since`; one older than the last good read is stale.
|
|
1289
|
+
*/
|
|
1290
|
+
const writeObservations = (database, prepared) => {
|
|
1291
|
+
database.exec("BEGIN IMMEDIATE");
|
|
1292
|
+
try {
|
|
1293
|
+
const readFact = database.prepare(`SELECT observation_id AS observationId, confirmed_at AS confirmedAt
|
|
1294
|
+
FROM work_observed_facts WHERE subject = ?`);
|
|
1295
|
+
const upsertFact = database.prepare(`INSERT INTO work_observed_facts (subject, observation_id, observed_at, confirmed_at, record)
|
|
1296
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1297
|
+
ON CONFLICT (subject) DO UPDATE SET observation_id = excluded.observation_id,
|
|
1298
|
+
observed_at = excluded.observed_at, confirmed_at = excluded.confirmed_at, record = excluded.record`);
|
|
1299
|
+
const confirmFact = database.prepare("UPDATE work_observed_facts SET confirmed_at = max(confirmed_at, ?) WHERE subject = ?");
|
|
1300
|
+
// A good read ends a run of failures only if it is newer than the run's
|
|
1301
|
+
// latest failure.
|
|
1302
|
+
const clearFailures = database.prepare("DELETE FROM work_observed_failures WHERE subject = ? AND last_at <= ?");
|
|
1303
|
+
// We keep only the run's first and latest failure, so a run split by a
|
|
1304
|
+
// delayed good read restarts at its latest failure: a real failed read.
|
|
1305
|
+
const trimFailures = database.prepare("UPDATE work_observed_failures SET since = last_at WHERE subject = ? AND since <= ? AND last_at > ?");
|
|
1306
|
+
const endFailures = {
|
|
1307
|
+
run: (subject, observedAt) => {
|
|
1308
|
+
clearFailures.run(subject, observedAt);
|
|
1309
|
+
trimFailures.run(subject, observedAt, observedAt);
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
// `since` is the earliest failure of the run; source and reason come from
|
|
1313
|
+
// the latest one, whatever order the failures arrive in.
|
|
1314
|
+
const recordFailure = database.prepare(`INSERT INTO work_observed_failures (subject, source, reason, since, last_at) VALUES (?, ?, ?, ?, ?)
|
|
1315
|
+
ON CONFLICT (subject) DO UPDATE SET
|
|
1316
|
+
since = min(since, excluded.since),
|
|
1317
|
+
source = CASE WHEN excluded.last_at >= last_at THEN excluded.source ELSE source END,
|
|
1318
|
+
reason = CASE WHEN excluded.last_at >= last_at THEN excluded.reason ELSE reason END,
|
|
1319
|
+
last_at = max(last_at, excluded.last_at)`);
|
|
1320
|
+
const outcomes = prepared.map((item) => {
|
|
1321
|
+
if (item._tag === "future")
|
|
1322
|
+
return { _tag: "stale", subject: item.subject };
|
|
1323
|
+
const row = readFact.get(item.subject);
|
|
1324
|
+
const stored = row === undefined ? undefined : Schema.decodeUnknownSync(StoredFactRow)(row);
|
|
1325
|
+
if (item._tag === "unknown") {
|
|
1326
|
+
if (stored !== undefined && stored.confirmedAt >= item.observedAt) {
|
|
1327
|
+
return { _tag: "stale", subject: item.subject };
|
|
1328
|
+
}
|
|
1329
|
+
recordFailure.run(item.subject, item.source, item.reason, item.observedAt, item.observedAt);
|
|
1330
|
+
return { _tag: "unknown", reason: item.reason, subject: item.subject };
|
|
1331
|
+
}
|
|
1332
|
+
if (stored?.observationId === item.observationId) {
|
|
1333
|
+
confirmFact.run(item.observedAt, item.subject);
|
|
1334
|
+
endFailures.run(item.subject, item.observedAt);
|
|
1335
|
+
return { _tag: "unchanged", subject: item.subject };
|
|
1336
|
+
}
|
|
1337
|
+
if (stored !== undefined && stored.confirmedAt >= item.observedAt)
|
|
1338
|
+
return { _tag: "stale", subject: item.subject };
|
|
1339
|
+
upsertFact.run(item.subject, item.observationId, item.observedAt, item.observedAt, item.record);
|
|
1340
|
+
endFailures.run(item.subject, item.observedAt);
|
|
1341
|
+
return { _tag: "stored", subject: item.subject };
|
|
1342
|
+
});
|
|
1343
|
+
// Totals are read once and kept current as rows go, so eviction is one
|
|
1344
|
+
// indexed lookup and delete per row, not a full rescan per row.
|
|
1345
|
+
const evict = (table, age, payload) => {
|
|
1346
|
+
const size = `length(CAST(subject AS BLOB)) + length(CAST(${payload} AS BLOB))`;
|
|
1347
|
+
const totals = Schema.decodeUnknownSync(FactTotalsRow)(database.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(${size}), 0) AS bytes FROM ${table}`).get());
|
|
1348
|
+
const oldest = database.prepare(`SELECT subject, ${size} AS bytes FROM ${table} ORDER BY ${age} ASC, subject ASC LIMIT 1`);
|
|
1349
|
+
const remove = database.prepare(`DELETE FROM ${table} WHERE subject = ?`);
|
|
1350
|
+
let { bytes, count } = totals;
|
|
1351
|
+
let evicted = 0;
|
|
1352
|
+
while (count > workObservedFactMaxRecords || bytes > workObservedFactMaxBytes) {
|
|
1353
|
+
const row = Schema.decodeUnknownSync(EvictionRow)(oldest.get());
|
|
1354
|
+
remove.run(row.subject);
|
|
1355
|
+
count -= 1;
|
|
1356
|
+
bytes -= row.bytes;
|
|
1357
|
+
evicted += 1;
|
|
1358
|
+
}
|
|
1359
|
+
return evicted;
|
|
1360
|
+
};
|
|
1361
|
+
// The least recently read rows go first: a fact by its last confirmation,
|
|
1362
|
+
// a failure by its latest failed read.
|
|
1363
|
+
const evicted = evict("work_observed_facts", "confirmed_at", "record") +
|
|
1364
|
+
evict("work_observed_failures", "last_at", "reason");
|
|
1365
|
+
database.exec("COMMIT");
|
|
1366
|
+
return { evicted, outcomes };
|
|
1367
|
+
}
|
|
1368
|
+
catch (cause) {
|
|
1369
|
+
if (database.isTransaction)
|
|
1370
|
+
database.exec("ROLLBACK");
|
|
1371
|
+
throw cause;
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
const admissionSummary = (provenance) => provenance._tag === "approved"
|
|
1375
|
+
? `Prospective admission of an existing owner by approved Fleet job ${provenance.approvalJobId}`
|
|
1376
|
+
: `Admission of an observed worker by the reconciler from observation ${provenance.observationId}`;
|
|
1377
|
+
/** The binding-request key that records how a worker was admitted. */
|
|
1378
|
+
const admissionRecord = (provenance, evidence) => provenance._tag === "approved"
|
|
1379
|
+
? {
|
|
1380
|
+
prospectiveAdmission: {
|
|
1381
|
+
...evidence,
|
|
1382
|
+
approvalJobId: provenance.approvalJobId,
|
|
1383
|
+
approvalActor: provenance.approvalActor
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
: { observedAdmission: { ...evidence, actor: "reconciler", observationId: provenance.observationId } };
|
|
1387
|
+
/** Whether a stored binding was admitted by exactly this provenance and absence evidence (an exact replay). */
|
|
1388
|
+
const sameAdmission = (request, expectedAbsenceToken, provenance) => {
|
|
1389
|
+
if (provenance._tag === "approved") {
|
|
1390
|
+
const stored = request.prospectiveAdmission;
|
|
1391
|
+
return stored !== undefined &&
|
|
1392
|
+
stored.expectedAbsenceToken === expectedAbsenceToken &&
|
|
1393
|
+
stored.approvalJobId === provenance.approvalJobId && stored.approvalActor === provenance.approvalActor;
|
|
1394
|
+
}
|
|
1395
|
+
const stored = request.observedAdmission;
|
|
1396
|
+
return stored !== undefined &&
|
|
1397
|
+
stored.expectedAbsenceToken === expectedAbsenceToken && stored.observationId === provenance.observationId;
|
|
1398
|
+
};
|
|
1196
1399
|
export class WorkStore {
|
|
1197
1400
|
#database;
|
|
1198
1401
|
#cryptoService;
|
|
@@ -1209,9 +1412,14 @@ export class WorkStore {
|
|
|
1209
1412
|
const opened = yield* openPrivateSqlite(path, {
|
|
1210
1413
|
busyTimeoutMillis: workStoreBusyTimeoutMillis,
|
|
1211
1414
|
initialize: (database) => {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1415
|
+
// One transaction: legacy migration and schema creation succeed or fail
|
|
1416
|
+
// together, and their statements share one commit instead of syncing
|
|
1417
|
+
// the file once each.
|
|
1418
|
+
database.exec("BEGIN IMMEDIATE");
|
|
1419
|
+
try {
|
|
1420
|
+
migrateLegacyAuthorityTables(database);
|
|
1421
|
+
const hadLaneOperationLedger = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'work_lane_operations'").get() !== undefined;
|
|
1422
|
+
database.exec(`
|
|
1215
1423
|
CREATE TABLE IF NOT EXISTS work_goal_events (
|
|
1216
1424
|
event_id TEXT PRIMARY KEY,
|
|
1217
1425
|
goal_id TEXT NOT NULL,
|
|
@@ -1291,6 +1499,13 @@ export class WorkStore {
|
|
|
1291
1499
|
goal_id TEXT NOT NULL,
|
|
1292
1500
|
record TEXT NOT NULL
|
|
1293
1501
|
);
|
|
1502
|
+
-- One row per approved abandonment, committed with the goal checkpoint whose
|
|
1503
|
+
-- event id equals its approval job id, so work_goal_events bounds it too.
|
|
1504
|
+
CREATE TABLE IF NOT EXISTS work_goal_abandonments (
|
|
1505
|
+
approval_job_id TEXT PRIMARY KEY,
|
|
1506
|
+
goal_id TEXT NOT NULL,
|
|
1507
|
+
record TEXT NOT NULL
|
|
1508
|
+
);
|
|
1294
1509
|
CREATE TABLE IF NOT EXISTS work_decision_handoffs (
|
|
1295
1510
|
handoff_id TEXT PRIMARY KEY,
|
|
1296
1511
|
session_id TEXT NOT NULL UNIQUE,
|
|
@@ -1314,22 +1529,45 @@ export class WorkStore {
|
|
|
1314
1529
|
END;
|
|
1315
1530
|
CREATE INDEX IF NOT EXISTS work_decision_handoffs_lane_time
|
|
1316
1531
|
ON work_decision_handoffs (lane_id, occurred_at, handoff_id);
|
|
1317
|
-
CREATE INDEX IF NOT EXISTS work_decision_handoffs_session
|
|
1532
|
+
CREATE UNIQUE INDEX IF NOT EXISTS work_decision_handoffs_session
|
|
1318
1533
|
ON work_decision_handoffs (session_id);
|
|
1534
|
+
CREATE TABLE IF NOT EXISTS work_observed_facts (
|
|
1535
|
+
subject TEXT PRIMARY KEY,
|
|
1536
|
+
observation_id TEXT NOT NULL,
|
|
1537
|
+
observed_at INTEGER NOT NULL,
|
|
1538
|
+
confirmed_at INTEGER NOT NULL,
|
|
1539
|
+
record TEXT NOT NULL
|
|
1540
|
+
);
|
|
1541
|
+
CREATE INDEX IF NOT EXISTS work_observed_facts_age
|
|
1542
|
+
ON work_observed_facts (confirmed_at, subject);
|
|
1543
|
+
CREATE TABLE IF NOT EXISTS work_reconciler_events (
|
|
1544
|
+
event_id TEXT PRIMARY KEY,
|
|
1545
|
+
goal_id TEXT NOT NULL
|
|
1546
|
+
);
|
|
1547
|
+
CREATE TABLE IF NOT EXISTS work_observed_failures (
|
|
1548
|
+
subject TEXT PRIMARY KEY,
|
|
1549
|
+
source TEXT NOT NULL,
|
|
1550
|
+
reason TEXT NOT NULL,
|
|
1551
|
+
since INTEGER NOT NULL,
|
|
1552
|
+
last_at INTEGER NOT NULL
|
|
1553
|
+
);
|
|
1554
|
+
CREATE INDEX IF NOT EXISTS work_observed_failures_age
|
|
1555
|
+
ON work_observed_failures (last_at, subject);
|
|
1319
1556
|
`);
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1557
|
+
requireUniqueSessionIndex(database);
|
|
1558
|
+
const columns = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ name: Schema.String })))(database.prepare("PRAGMA table_info(work_goal_events)").all());
|
|
1559
|
+
if (!columns.some(({ name }) => name === "transaction_id")) {
|
|
1560
|
+
database.exec("ALTER TABLE work_goal_events ADD COLUMN transaction_id TEXT");
|
|
1561
|
+
}
|
|
1562
|
+
if (!hadLaneOperationLedger) {
|
|
1563
|
+
database.exec(`
|
|
1326
1564
|
INSERT OR IGNORE INTO work_lane_operations
|
|
1327
1565
|
(operation_id, lane_id, goal_id, phase, revision, record)
|
|
1328
1566
|
SELECT operation_id, lane_id, goal_id, phase, revision, record
|
|
1329
1567
|
FROM work_lane_claims
|
|
1330
1568
|
`);
|
|
1331
|
-
|
|
1332
|
-
|
|
1569
|
+
}
|
|
1570
|
+
database.exec(`
|
|
1333
1571
|
INSERT OR IGNORE INTO work_lane_operation_totals
|
|
1334
1572
|
(singleton, operation_count, operation_bytes)
|
|
1335
1573
|
SELECT 1, COUNT(*), COALESCE(SUM(
|
|
@@ -1337,7 +1575,7 @@ export class WorkStore {
|
|
|
1337
1575
|
), 0)
|
|
1338
1576
|
FROM work_lane_operations
|
|
1339
1577
|
`);
|
|
1340
|
-
|
|
1578
|
+
database.exec(`
|
|
1341
1579
|
INSERT OR IGNORE INTO work_goal_transaction_totals
|
|
1342
1580
|
(singleton, transaction_count, transaction_bytes)
|
|
1343
1581
|
SELECT 1, COUNT(*), COALESCE(SUM(
|
|
@@ -1345,7 +1583,7 @@ export class WorkStore {
|
|
|
1345
1583
|
), 0)
|
|
1346
1584
|
FROM work_goal_transactions
|
|
1347
1585
|
`);
|
|
1348
|
-
|
|
1586
|
+
database.exec(`
|
|
1349
1587
|
INSERT OR REPLACE INTO work_decision_totals
|
|
1350
1588
|
(singleton, decision_count, decision_bytes)
|
|
1351
1589
|
SELECT 1, COUNT(*), COALESCE(SUM(
|
|
@@ -1353,6 +1591,15 @@ export class WorkStore {
|
|
|
1353
1591
|
), 0)
|
|
1354
1592
|
FROM work_decision_handoffs
|
|
1355
1593
|
`);
|
|
1594
|
+
database.exec("COMMIT");
|
|
1595
|
+
}
|
|
1596
|
+
catch (error) {
|
|
1597
|
+
// SQLite may already have rolled back (SQLITE_FULL, IOERR); a second
|
|
1598
|
+
// ROLLBACK would throw and replace the real error.
|
|
1599
|
+
if (database.isTransaction)
|
|
1600
|
+
database.exec("ROLLBACK");
|
|
1601
|
+
throw error;
|
|
1602
|
+
}
|
|
1356
1603
|
}
|
|
1357
1604
|
}).pipe(Effect.mapError(fromPrivateDatabaseError));
|
|
1358
1605
|
return new WorkStore(path, opened, cryptoService);
|
|
@@ -1869,6 +2116,155 @@ export class WorkStore {
|
|
|
1869
2116
|
return yield* decision.error;
|
|
1870
2117
|
return decision.result;
|
|
1871
2118
|
});
|
|
2119
|
+
/**
|
|
2120
|
+
* Applies an approved `work.abandon` job: the goal, owned by exactly the
|
|
2121
|
+
* approved owner and still at the approved head, becomes `abandoned` with its
|
|
2122
|
+
* blocker cleared and a status activity naming the job. A goal with an
|
|
2123
|
+
* active lane, or already finished, is refused; nothing else is written. An
|
|
2124
|
+
* exact replay of the job returns the stored result.
|
|
2125
|
+
*/
|
|
2126
|
+
abandon = Effect.fn("WorkStore.abandon")(function* (request) {
|
|
2127
|
+
const decoded = yield* Schema.decodeUnknownEffect(WorkGoalAbandonment)(request).pipe(Effect.mapError(storeError("abandon.decode")));
|
|
2128
|
+
const jobId = decoded.approvalJobId;
|
|
2129
|
+
const observedAt = yield* Clock.currentTimeMillis;
|
|
2130
|
+
yield* this.secureFiles();
|
|
2131
|
+
const decision = yield* Effect.try({
|
|
2132
|
+
try: () => {
|
|
2133
|
+
let transaction = false;
|
|
2134
|
+
const reject = (error) => {
|
|
2135
|
+
this.#database.exec("ROLLBACK");
|
|
2136
|
+
transaction = false;
|
|
2137
|
+
return { _tag: "rejected", error };
|
|
2138
|
+
};
|
|
2139
|
+
try {
|
|
2140
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
2141
|
+
transaction = true;
|
|
2142
|
+
const priorRaw = this.#database
|
|
2143
|
+
.prepare(`SELECT approval_job_id AS approvalJobId, goal_id AS goalId, record
|
|
2144
|
+
FROM work_goal_abandonments WHERE approval_job_id = ?`)
|
|
2145
|
+
.get(jobId);
|
|
2146
|
+
if (priorRaw !== undefined) {
|
|
2147
|
+
const row = Schema.decodeUnknownSync(ReassignmentRow)(priorRaw);
|
|
2148
|
+
const prior = Schema.decodeUnknownSync(WorkGoalAbandoned)(JSON.parse(row.record));
|
|
2149
|
+
if (row.approvalJobId !== prior.abandonment.approvalJobId || row.goalId !== prior.abandonment.goalId) {
|
|
2150
|
+
return reject(new WorkStoreError({ cause: { prior, row }, operation: "abandon.replay.identity-mismatch" }));
|
|
2151
|
+
}
|
|
2152
|
+
if (!Equal.equals(prior.abandonment, decoded)) {
|
|
2153
|
+
return reject(new WorkGoalAbandonmentConflictError({ approvalJobId: jobId, reason: "payload_mismatch" }));
|
|
2154
|
+
}
|
|
2155
|
+
this.#database.exec("ROLLBACK");
|
|
2156
|
+
transaction = false;
|
|
2157
|
+
return { _tag: "abandoned", result: prior };
|
|
2158
|
+
}
|
|
2159
|
+
const collision = this.#database
|
|
2160
|
+
.prepare(`SELECT 1 FROM work_goal_events WHERE event_id = ?
|
|
2161
|
+
UNION ALL SELECT 1 FROM work_lane_operations WHERE operation_id = ?
|
|
2162
|
+
UNION ALL SELECT 1 FROM work_agent_bindings WHERE dispatch_request_id = ? LIMIT 1`)
|
|
2163
|
+
.get(jobId, jobId, jobId);
|
|
2164
|
+
if (collision !== undefined) {
|
|
2165
|
+
return reject(new WorkGoalAbandonmentConflictError({ approvalJobId: jobId, reason: "identifier_in_use" }));
|
|
2166
|
+
}
|
|
2167
|
+
const eventRows = Schema.decodeUnknownSync(Schema.Array(AgentBindingGoalEventRow))(this.#database
|
|
2168
|
+
.prepare(`SELECT event_id AS eventId, goal_id AS goalId, occurred_at AS occurredAt, record
|
|
2169
|
+
FROM work_goal_events ORDER BY occurred_at ASC, event_id ASC LIMIT ?`)
|
|
2170
|
+
.all(workHistoryMaxEvents + 1));
|
|
2171
|
+
const history = [];
|
|
2172
|
+
for (const row of eventRows) {
|
|
2173
|
+
const event = decodeAgentBindingGoalEvent(row, "abandon.history");
|
|
2174
|
+
if (event._tag === "invalid")
|
|
2175
|
+
return reject(event.error);
|
|
2176
|
+
history.push(event.checkpoint);
|
|
2177
|
+
}
|
|
2178
|
+
const head = history.filter(({ goal }) => goal.id === decoded.goalId).at(-1);
|
|
2179
|
+
if (head === undefined ||
|
|
2180
|
+
head.eventId !== decoded.expectedGoalEventId ||
|
|
2181
|
+
head.goal.updatedAt !== decoded.expectedGoalUpdatedAt) {
|
|
2182
|
+
return reject(new WorkGoalRevisionConflictError({
|
|
2183
|
+
goalId: decoded.goalId,
|
|
2184
|
+
expectedEventId: decoded.expectedGoalEventId,
|
|
2185
|
+
expectedUpdatedAt: decoded.expectedGoalUpdatedAt,
|
|
2186
|
+
actualEventId: head?.eventId ?? null,
|
|
2187
|
+
actualUpdatedAt: head?.goal.updatedAt ?? null
|
|
2188
|
+
}));
|
|
2189
|
+
}
|
|
2190
|
+
if (!Equal.equals(head.goal.owner, decoded.owner)) {
|
|
2191
|
+
return reject(new WorkGoalOwnerMismatchError({
|
|
2192
|
+
goalId: decoded.goalId,
|
|
2193
|
+
laneId: null,
|
|
2194
|
+
expectedOwner: { id: decoded.owner.id, name: decoded.owner.name },
|
|
2195
|
+
actualOwner: { id: head.goal.owner.id, name: head.goal.owner.name }
|
|
2196
|
+
}));
|
|
2197
|
+
}
|
|
2198
|
+
if (isTerminalWorkState(head.goal.state)) {
|
|
2199
|
+
return reject(new WorkGoalTerminalError({ goalId: decoded.goalId, state: head.goal.state }));
|
|
2200
|
+
}
|
|
2201
|
+
const ledger = readValidatedLaneLedger(this.#database, "abandon.lane");
|
|
2202
|
+
if (ledger._tag === "invalid")
|
|
2203
|
+
return reject(ledger.error);
|
|
2204
|
+
const active = ledger.entries.find(({ claim }) => claim.goalId === decoded.goalId && claim.phase !== "shipped");
|
|
2205
|
+
if (active !== undefined) {
|
|
2206
|
+
return reject(new WorkGoalLaneActiveError({ goalId: decoded.goalId, laneId: active.claim.laneId }));
|
|
2207
|
+
}
|
|
2208
|
+
// occurredAt equals updatedAt for every valid checkpoint; both bound the ordering defensively.
|
|
2209
|
+
const at = Math.max(observedAt, head.occurredAt + 1, head.goal.updatedAt + 1);
|
|
2210
|
+
const existingActivity = head.goal.activity ?? [];
|
|
2211
|
+
const goal = Schema.decodeUnknownSync(WorkGoal)({
|
|
2212
|
+
...withoutBlockers(head.goal),
|
|
2213
|
+
state: "abandoned",
|
|
2214
|
+
// A full activity list keeps the owner's entries and takes no note; the
|
|
2215
|
+
// checkpoint's event id and the abandonments table still name the job.
|
|
2216
|
+
activity: existingActivity.length >= goalActivityLimit ? existingActivity : [
|
|
2217
|
+
...existingActivity,
|
|
2218
|
+
{
|
|
2219
|
+
id: jobId,
|
|
2220
|
+
kind: "status",
|
|
2221
|
+
summary: workAbandonActivitySummary(decoded, jobId, decoded.approvalHash),
|
|
2222
|
+
occurredAt: at
|
|
2223
|
+
}
|
|
2224
|
+
],
|
|
2225
|
+
updatedAt: at
|
|
2226
|
+
});
|
|
2227
|
+
const checkpoint = Schema.decodeUnknownSync(WorkGoalCheckpoint)({
|
|
2228
|
+
version: "herdr.work.event.v1",
|
|
2229
|
+
eventId: jobId,
|
|
2230
|
+
occurredAt: at,
|
|
2231
|
+
goal
|
|
2232
|
+
});
|
|
2233
|
+
const result = Schema.decodeUnknownSync(WorkGoalAbandoned)({ abandonment: decoded, checkpoint });
|
|
2234
|
+
const familyError = validateGoalFamilyHistory([...history, checkpoint]);
|
|
2235
|
+
if (familyError !== undefined)
|
|
2236
|
+
return reject(familyError);
|
|
2237
|
+
const capacity = agentBindingAdmissionError({
|
|
2238
|
+
history,
|
|
2239
|
+
candidate: checkpoint,
|
|
2240
|
+
operationCount: 0,
|
|
2241
|
+
operationBytes: 0,
|
|
2242
|
+
candidateOperationBytes: 0
|
|
2243
|
+
});
|
|
2244
|
+
if (capacity !== undefined)
|
|
2245
|
+
return reject(capacity);
|
|
2246
|
+
this.#database
|
|
2247
|
+
.prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)")
|
|
2248
|
+
.run(checkpoint.eventId, checkpoint.goal.id, checkpoint.occurredAt, JSON.stringify(checkpoint));
|
|
2249
|
+
this.#database
|
|
2250
|
+
.prepare("INSERT INTO work_goal_abandonments (approval_job_id, goal_id, record) VALUES (?, ?, ?)")
|
|
2251
|
+
.run(jobId, decoded.goalId, JSON.stringify(result));
|
|
2252
|
+
this.#database.exec("COMMIT");
|
|
2253
|
+
transaction = false;
|
|
2254
|
+
return { _tag: "abandoned", result };
|
|
2255
|
+
}
|
|
2256
|
+
catch (cause) {
|
|
2257
|
+
if (transaction)
|
|
2258
|
+
this.#database.exec("ROLLBACK");
|
|
2259
|
+
throw cause;
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
catch: storeError("abandon.transaction")
|
|
2263
|
+
});
|
|
2264
|
+
if (decision._tag === "rejected")
|
|
2265
|
+
return yield* decision.error;
|
|
2266
|
+
return decision.result;
|
|
2267
|
+
});
|
|
1872
2268
|
admissionPreflight = Effect.fn("WorkStore.admissionPreflight")(function* (target) {
|
|
1873
2269
|
const decoded = yield* Schema.decodeUnknownEffect(WorkAdmissionTarget)(target).pipe(Effect.mapError(storeError("admission.preflight.decode")));
|
|
1874
2270
|
const state = yield* Effect.try({
|
|
@@ -1881,8 +2277,27 @@ export class WorkStore {
|
|
|
1881
2277
|
const preflight = { _tag: "prospective", target: state.target, absenceToken };
|
|
1882
2278
|
return preflight;
|
|
1883
2279
|
});
|
|
2280
|
+
/**
|
|
2281
|
+
* Admits an existing owner after an approved Fleet job. Its absence token is
|
|
2282
|
+
* re-checked inside the write; an exact replay returns the stored link.
|
|
2283
|
+
*/
|
|
1884
2284
|
admitExistingOwner = Effect.fn("WorkStore.admitExistingOwner")(function* (request) {
|
|
1885
2285
|
const decoded = yield* Schema.decodeUnknownEffect(WorkProspectiveAdmission)(request).pipe(Effect.mapError(storeError("admission.decode")));
|
|
2286
|
+
return yield* this.#admit(decoded, {
|
|
2287
|
+
_tag: "approved",
|
|
2288
|
+
approvalActor: decoded.approvalActor,
|
|
2289
|
+
approvalJobId: decoded.approvalJobId
|
|
2290
|
+
});
|
|
2291
|
+
});
|
|
2292
|
+
/**
|
|
2293
|
+
* Admits a worker the reconciler observed, without an approval: the same
|
|
2294
|
+
* write and checks as `admitExistingOwner`, credited to the observation.
|
|
2295
|
+
*/
|
|
2296
|
+
admitObserved = Effect.fn("WorkStore.admitObserved")(function* (request) {
|
|
2297
|
+
const decoded = yield* Schema.decodeUnknownEffect(WorkObservedAdmission)(request).pipe(Effect.mapError(storeError("admission.decode")));
|
|
2298
|
+
return yield* this.#admit(decoded, { _tag: "observed", observationId: decoded.observationId });
|
|
2299
|
+
});
|
|
2300
|
+
#admit = Effect.fn("WorkStore.admit")(function* (decoded, provenance) {
|
|
1886
2301
|
const target = Schema.decodeUnknownSync(WorkAdmissionTarget)(decoded);
|
|
1887
2302
|
const now = yield* Clock.currentTimeMillis;
|
|
1888
2303
|
yield* this.secureFiles();
|
|
@@ -1906,13 +2321,10 @@ export class WorkStore {
|
|
|
1906
2321
|
const state = admissionState(this.#database, target);
|
|
1907
2322
|
if (state._tag === "existing") {
|
|
1908
2323
|
const existing = state.link;
|
|
1909
|
-
const provenance = existing.binding.request.prospectiveAdmission;
|
|
1910
2324
|
this.#database.exec("ROLLBACK");
|
|
1911
2325
|
transaction = false;
|
|
1912
2326
|
return existing.binding.request.dispatchRequestId === decoded.operationId &&
|
|
1913
|
-
|
|
1914
|
-
provenance?.approvalJobId === decoded.approvalJobId &&
|
|
1915
|
-
provenance.approvalActor === decoded.approvalActor &&
|
|
2327
|
+
sameAdmission(existing.binding.request, decoded.expectedAbsenceToken, provenance) &&
|
|
1916
2328
|
existing.goal.title === decoded.title &&
|
|
1917
2329
|
existing.goal.summary === decoded.summary &&
|
|
1918
2330
|
existing.goal.detail === decoded.detail
|
|
@@ -1955,7 +2367,7 @@ export class WorkStore {
|
|
|
1955
2367
|
activity: [{
|
|
1956
2368
|
id: `${decoded.operationId}.admission`,
|
|
1957
2369
|
kind: "status",
|
|
1958
|
-
summary:
|
|
2370
|
+
summary: admissionSummary(provenance),
|
|
1959
2371
|
occurredAt: createdAt
|
|
1960
2372
|
}],
|
|
1961
2373
|
review: { state: "requested", summary: null, updatedAt: createdAt, url: decoded.reviewUrl },
|
|
@@ -1989,14 +2401,12 @@ export class WorkStore {
|
|
|
1989
2401
|
laneId: decoded.laneId,
|
|
1990
2402
|
expectedRevision: 0,
|
|
1991
2403
|
worker: decoded.worker,
|
|
1992
|
-
|
|
2404
|
+
...admissionRecord(provenance, {
|
|
1993
2405
|
sessionId: decoded.sessionId,
|
|
1994
2406
|
workAssignment: decoded.expectedWork,
|
|
1995
2407
|
baseHead: decoded.baseHead,
|
|
1996
|
-
expectedAbsenceToken: decoded.expectedAbsenceToken
|
|
1997
|
-
|
|
1998
|
-
approvalActor: decoded.approvalActor
|
|
1999
|
-
}
|
|
2408
|
+
expectedAbsenceToken: decoded.expectedAbsenceToken
|
|
2409
|
+
})
|
|
2000
2410
|
},
|
|
2001
2411
|
lane,
|
|
2002
2412
|
checkpoint: {
|
|
@@ -2043,7 +2453,7 @@ export class WorkStore {
|
|
|
2043
2453
|
_tag: "rejected",
|
|
2044
2454
|
error: capacity ?? new WorkProjectionError({
|
|
2045
2455
|
cause: decoded,
|
|
2046
|
-
detail: "
|
|
2456
|
+
detail: "admission exceeds Work capacity",
|
|
2047
2457
|
reason: "capacity_exceeded"
|
|
2048
2458
|
})
|
|
2049
2459
|
};
|
|
@@ -2190,7 +2600,7 @@ export class WorkStore {
|
|
|
2190
2600
|
if (currentDecision._tag === "invalid")
|
|
2191
2601
|
return reject(currentDecision.error);
|
|
2192
2602
|
const current = currentDecision.checkpoint;
|
|
2193
|
-
if (current.goal.state === "completed") {
|
|
2603
|
+
if (current.goal.state === "completed" || current.goal.state === "abandoned") {
|
|
2194
2604
|
return reject(new WorkAgentBindingAuthorityError({
|
|
2195
2605
|
actualRevision: lane.revision,
|
|
2196
2606
|
expectedRevision: decoded.expectedRevision,
|
|
@@ -2327,8 +2737,26 @@ export class WorkStore {
|
|
|
2327
2737
|
const entries = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ dispatchRequestId: WorkAgentBindingRequest.fields.dispatchRequestId })))(rows).pipe(Effect.mapError(storeError("agent-binding.lane.rows")));
|
|
2328
2738
|
return entries.map(({ dispatchRequestId }) => dispatchRequestId);
|
|
2329
2739
|
});
|
|
2330
|
-
append =
|
|
2740
|
+
append = (event) => this.appendAt(event, null).pipe(
|
|
2741
|
+
// Only an expected head can raise it; a plain append never checks one.
|
|
2742
|
+
Effect.catchTag("WorkGoalRevisionConflictError", Effect.die));
|
|
2743
|
+
/**
|
|
2744
|
+
* Appends one checkpoint. With `expectedHead`, the goal's latest checkpoint
|
|
2745
|
+
* must still be that one when the write commits (an exact replay always
|
|
2746
|
+
* succeeds), so a writer that planned from the head cannot bury a newer
|
|
2747
|
+
* owner checkpoint.
|
|
2748
|
+
*/
|
|
2749
|
+
appendAt = Effect.fn("WorkStore.append")(function* (event, guard) {
|
|
2331
2750
|
const decoded = yield* Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("append.decode")));
|
|
2751
|
+
// The reconciler's event and activity ids are its provenance; no other
|
|
2752
|
+
// writer may use them.
|
|
2753
|
+
if (guard === null && decoded.eventId.startsWith(reconcilerEventPrefix)) {
|
|
2754
|
+
return yield* new WorkProjectionError({
|
|
2755
|
+
cause: decoded.eventId,
|
|
2756
|
+
detail: `event ids starting with ${reconcilerEventPrefix} are reserved for the reconciler`,
|
|
2757
|
+
reason: "malformed"
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2332
2760
|
yield* this.secureFiles();
|
|
2333
2761
|
const decision = yield* Effect.try({
|
|
2334
2762
|
try: () => {
|
|
@@ -2345,12 +2773,37 @@ export class WorkStore {
|
|
|
2345
2773
|
.prepare(`SELECT record FROM work_goal_events
|
|
2346
2774
|
WHERE event_id = ? OR (goal_id = ? AND occurred_at = ?)`)
|
|
2347
2775
|
.all(decoded.eventId, decoded.goal.id, decoded.occurredAt)).map(({ record }) => Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(record)));
|
|
2348
|
-
if (collisions.length > 0) {
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2776
|
+
if (collisions.length > 0 && collisions.every((existing) => Equal.equals(existing, decoded))) {
|
|
2777
|
+
this.#database.exec("ROLLBACK");
|
|
2778
|
+
transaction = false;
|
|
2779
|
+
return { _tag: "replayed", event: decoded };
|
|
2780
|
+
}
|
|
2781
|
+
if (guard !== null) {
|
|
2782
|
+
// Planned from this head and this fact: if either moved, the plan is stale.
|
|
2783
|
+
const headRow = this.#database.prepare(`SELECT record FROM work_goal_events WHERE goal_id = ?
|
|
2784
|
+
ORDER BY occurred_at DESC, event_id DESC LIMIT 1`).get(decoded.goal.id);
|
|
2785
|
+
const head = headRow === undefined ? null : Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(Schema.decodeUnknownSync(StoredEventRow)(headRow).record));
|
|
2786
|
+
const factRow = this.#database.prepare("SELECT observation_id AS observationId FROM work_observed_facts WHERE subject = ?").get(guard.fact.subject);
|
|
2787
|
+
const fact = factRow === undefined ? null : Schema.decodeUnknownSync(ObservationIdRow)(factRow);
|
|
2788
|
+
if (head?.eventId !== guard.head.eventId || fact?.observationId !== guard.fact.observationId) {
|
|
2789
|
+
return reject(new WorkGoalRevisionConflictError({
|
|
2790
|
+
actualEventId: head?.eventId ?? null,
|
|
2791
|
+
actualUpdatedAt: head?.goal.updatedAt ?? null,
|
|
2792
|
+
expectedEventId: guard.head.eventId,
|
|
2793
|
+
expectedUpdatedAt: guard.head.goal.updatedAt,
|
|
2794
|
+
goalId: decoded.goal.id
|
|
2795
|
+
}));
|
|
2796
|
+
}
|
|
2797
|
+
const count = Schema.decodeUnknownSync(CountRow)(this.#database.prepare("SELECT COUNT(*) AS count FROM work_goal_events").get()).count;
|
|
2798
|
+
if (count + 1 > workHistoryMaxEvents - guard.reserve) {
|
|
2799
|
+
return reject(new WorkProjectionError({
|
|
2800
|
+
cause: count,
|
|
2801
|
+
detail: `the reconciler leaves ${guard.reserve} of ${workHistoryMaxEvents} checkpoints free`,
|
|
2802
|
+
reason: "capacity_exceeded"
|
|
2803
|
+
}));
|
|
2353
2804
|
}
|
|
2805
|
+
}
|
|
2806
|
+
if (collisions.length > 0) {
|
|
2354
2807
|
return reject(new WorkCheckpointConflictError({
|
|
2355
2808
|
eventId: decoded.eventId,
|
|
2356
2809
|
goalId: decoded.goal.id,
|
|
@@ -2405,6 +2858,11 @@ export class WorkStore {
|
|
|
2405
2858
|
}
|
|
2406
2859
|
}
|
|
2407
2860
|
const result = this.#database.prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)").run(decoded.eventId, decoded.goal.id, decoded.occurredAt, JSON.stringify(decoded));
|
|
2861
|
+
// The reconciler's own record of what it wrote: its authorship and its
|
|
2862
|
+
// once-per-goal rule come from this table, never from an id's spelling.
|
|
2863
|
+
if (guard !== null) {
|
|
2864
|
+
this.#database.prepare("INSERT INTO work_reconciler_events (event_id, goal_id) VALUES (?, ?)").run(decoded.eventId, decoded.goal.id);
|
|
2865
|
+
}
|
|
2408
2866
|
this.#database.exec("COMMIT");
|
|
2409
2867
|
transaction = false;
|
|
2410
2868
|
return { _tag: "inserted", changes: result.changes };
|
|
@@ -2436,6 +2894,14 @@ export class WorkStore {
|
|
|
2436
2894
|
}
|
|
2437
2895
|
const transaction = yield* Schema.decodeUnknownEffect(TransactionId)(transactionId).pipe(Effect.mapError(storeError("appendMany.decode.transaction")));
|
|
2438
2896
|
const decoded = yield* Effect.forEach(events, (event) => Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("appendMany.decode.event"))));
|
|
2897
|
+
const reserved = decoded.find(({ eventId }) => eventId.startsWith(reconcilerEventPrefix));
|
|
2898
|
+
if (reserved !== undefined) {
|
|
2899
|
+
return yield* new WorkProjectionError({
|
|
2900
|
+
cause: reserved.eventId,
|
|
2901
|
+
detail: `event ids starting with ${reconcilerEventPrefix} are reserved for the reconciler`,
|
|
2902
|
+
reason: "malformed"
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2439
2905
|
if (decoded.length === 0) {
|
|
2440
2906
|
return yield* new WorkProjectionError({
|
|
2441
2907
|
cause: events,
|
|
@@ -2937,7 +3403,7 @@ export class WorkStore {
|
|
|
2937
3403
|
goal.goalFamily?.role !== "canonical" ||
|
|
2938
3404
|
goal.goalFamily.canonicalGoalId !== goal.id)
|
|
2939
3405
|
return rejectLink("missing_provenance");
|
|
2940
|
-
if (goal.state
|
|
3406
|
+
if (isTerminalWorkState(goal.state))
|
|
2941
3407
|
return rejectLink("terminal_goal");
|
|
2942
3408
|
if (!Equal.equals(goal.owner, reconciliation.expectedOwner))
|
|
2943
3409
|
return rejectLink("owner_mismatch");
|
|
@@ -3353,9 +3819,18 @@ export class WorkStore {
|
|
|
3353
3819
|
phase, revision, record
|
|
3354
3820
|
FROM work_lane_operations ORDER BY operation_id ASC
|
|
3355
3821
|
LIMIT ?`).all(workLaneOperationMaxRecords + 1);
|
|
3822
|
+
const facts = this.#database.prepare(`SELECT subject, observation_id AS observationId, observed_at AS observedAt,
|
|
3823
|
+
confirmed_at AS confirmedAt, record
|
|
3824
|
+
FROM work_observed_facts ORDER BY subject ASC
|
|
3825
|
+
LIMIT ?`).all(workObservedFactMaxRecords + 1);
|
|
3826
|
+
const failures = this.#database.prepare(`SELECT subject, source, reason, since, last_at AS lastAt
|
|
3827
|
+
FROM work_observed_failures ORDER BY subject ASC LIMIT ?`).all(workObservedFactMaxRecords + 1);
|
|
3828
|
+
const reconcilerRows = this.#database.prepare("SELECT event_id AS eventId, goal_id AS goalId FROM work_reconciler_events ORDER BY event_id ASC LIMIT ?").all(workHistoryMaxEvents + 1);
|
|
3829
|
+
const reassignments = this.#database.prepare(`SELECT approval_job_id AS approvalJobId, goal_id AS goalId FROM work_goal_reassignments
|
|
3830
|
+
UNION ALL SELECT approval_job_id AS approvalJobId, goal_id AS goalId FROM work_goal_abandonments`).all();
|
|
3356
3831
|
this.#database.exec("COMMIT");
|
|
3357
3832
|
inTransaction = false;
|
|
3358
|
-
return { bindings, events, laneOperations };
|
|
3833
|
+
return { bindings, events, facts, failures, laneOperations, reassignments, reconcilerRows };
|
|
3359
3834
|
}
|
|
3360
3835
|
catch (cause) {
|
|
3361
3836
|
if (inTransaction)
|
|
@@ -3380,6 +3855,23 @@ export class WorkStore {
|
|
|
3380
3855
|
const eventById = new Map(eventRows.map((row) => [row.eventId, row]));
|
|
3381
3856
|
const laneByOperation = new Map(laneRows.map((row) => [row.operationId, row]));
|
|
3382
3857
|
let logicalObservedAt = null;
|
|
3858
|
+
// An approved job's activity id is its operation (recovery), the operation
|
|
3859
|
+
// plus `.admission` (prospective admission), or the job id (reassignment).
|
|
3860
|
+
const reassignmentRows = yield* Schema.decodeUnknownEffect(Schema.Array(ReassignmentApprovalRow))(source.reassignments).pipe(Effect.mapError(storeError("snapshot-input.decode-reassignments")));
|
|
3861
|
+
// An approved reassignment or abandonment may be stamped past the clock
|
|
3862
|
+
// (one past a future-dated head); like a binding, it moves logical time so
|
|
3863
|
+
// a default snapshot shows what the approved job wrote.
|
|
3864
|
+
for (const { approvalJobId } of reassignmentRows) {
|
|
3865
|
+
const written = eventById.get(approvalJobId);
|
|
3866
|
+
if (written !== undefined)
|
|
3867
|
+
logicalObservedAt = Math.max(logicalObservedAt ?? 0, written.occurredAt);
|
|
3868
|
+
}
|
|
3869
|
+
const approvals = reassignmentRows.map(({ approvalJobId, goalId }) => ({
|
|
3870
|
+
activityId: approvalJobId,
|
|
3871
|
+
approvalJobId,
|
|
3872
|
+
goalId
|
|
3873
|
+
}));
|
|
3874
|
+
const observedAdmissions = [];
|
|
3383
3875
|
for (const row of bindingRows) {
|
|
3384
3876
|
const binding = yield* Effect.try({
|
|
3385
3877
|
try: () => Schema.decodeUnknownSync(WorkAgentBinding)(JSON.parse(row.record)),
|
|
@@ -3400,8 +3892,173 @@ export class WorkStore {
|
|
|
3400
3892
|
if (readbackError !== undefined)
|
|
3401
3893
|
return yield* readbackError;
|
|
3402
3894
|
logicalObservedAt = Math.max(logicalObservedAt ?? 0, binding.checkpoint.occurredAt);
|
|
3895
|
+
const goalId = binding.checkpoint.goal.id;
|
|
3896
|
+
const { dispatchRequestId, existingGoalRecovery, observedAdmission, ownerReassignment, prospectiveAdmission } = binding.request;
|
|
3897
|
+
if (observedAdmission !== undefined) {
|
|
3898
|
+
observedAdmissions.push({ eventId: `${dispatchRequestId}.admission`, goalId });
|
|
3899
|
+
}
|
|
3900
|
+
if (prospectiveAdmission !== undefined) {
|
|
3901
|
+
approvals.push({
|
|
3902
|
+
activityId: `${dispatchRequestId}.admission`,
|
|
3903
|
+
approvalJobId: prospectiveAdmission.approvalJobId,
|
|
3904
|
+
goalId
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
if (existingGoalRecovery !== undefined) {
|
|
3908
|
+
approvals.push({ activityId: dispatchRequestId, approvalJobId: existingGoalRecovery.approvalJobId, goalId });
|
|
3909
|
+
}
|
|
3910
|
+
if (ownerReassignment !== undefined) {
|
|
3911
|
+
approvals.push({
|
|
3912
|
+
activityId: ownerReassignment.approvalJobId,
|
|
3913
|
+
approvalJobId: ownerReassignment.approvalJobId,
|
|
3914
|
+
goalId
|
|
3915
|
+
});
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
const factRows = yield* Schema.decodeUnknownEffect(Schema.Array(ObservedFactRow))(source.facts).pipe(Effect.mapError(storeError("snapshot-input.decode-fact-rows")));
|
|
3919
|
+
const failures = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservedFailure))(source.failures).pipe(Effect.mapError(storeError("snapshot-input.decode-failures")));
|
|
3920
|
+
if (factRows.length > workObservedFactMaxRecords || failures.length > workObservedFactMaxRecords) {
|
|
3921
|
+
return yield* new WorkStoreError({ cause: factRows.length, operation: "snapshot-input.fact-capacity" });
|
|
3922
|
+
}
|
|
3923
|
+
const facts = yield* Effect.forEach(factRows, (row) => Effect.try({
|
|
3924
|
+
try: () => Schema.decodeUnknownSync(WorkObservedFact)({
|
|
3925
|
+
confirmedAt: row.confirmedAt,
|
|
3926
|
+
observation: JSON.parse(row.record),
|
|
3927
|
+
observationId: row.observationId,
|
|
3928
|
+
observedAt: row.observedAt,
|
|
3929
|
+
subject: row.subject
|
|
3930
|
+
}),
|
|
3931
|
+
catch: storeError("snapshot-input.decode-fact")
|
|
3932
|
+
}));
|
|
3933
|
+
// A row is filed under its own facts' subject; anything else is a corrupt row.
|
|
3934
|
+
const misfiled = facts.find((fact) => fact.subject !== observationSubject(fact.observation));
|
|
3935
|
+
if (misfiled !== undefined) {
|
|
3936
|
+
return yield* new WorkStoreError({ cause: misfiled.subject, operation: "snapshot-input.fact-subject" });
|
|
3937
|
+
}
|
|
3938
|
+
const reconcilerEvents = yield* Schema.decodeUnknownEffect(Schema.Array(ReconcilerEventRow))(source.reconcilerRows)
|
|
3939
|
+
.pipe(Effect.mapError(storeError("snapshot-input.decode-reconciler-events")));
|
|
3940
|
+
// Each current activity's origin: the checkpoint that started its current
|
|
3941
|
+
// unchanged run. An activity that disappears, or reads differently, starts
|
|
3942
|
+
// a new run, so a later owner rewrite or re-creation is never credited to
|
|
3943
|
+
// whoever wrote an earlier version.
|
|
3944
|
+
const activityOrigins = new Map();
|
|
3945
|
+
const previousByGoal = new Map();
|
|
3946
|
+
const ordered = events.toSorted((left, right) => left.occurredAt - right.occurredAt || (left.eventId < right.eventId ? -1 : left.eventId > right.eventId ? 1 : 0));
|
|
3947
|
+
for (const event of ordered) {
|
|
3948
|
+
const previous = previousByGoal.get(event.goal.id) ?? new Map();
|
|
3949
|
+
const current = new Map((event.goal.activity ?? []).map((activity) => [activity.id, activity]));
|
|
3950
|
+
for (const [id, activity] of current) {
|
|
3951
|
+
const before = previous.get(id);
|
|
3952
|
+
const unchanged = before !== undefined && before.kind === activity.kind &&
|
|
3953
|
+
before.summary === activity.summary &&
|
|
3954
|
+
before.occurredAt === activity.occurredAt;
|
|
3955
|
+
if (!unchanged)
|
|
3956
|
+
activityOrigins.set(`${event.goal.id}\u0000${id}`, { activity, eventId: event.eventId });
|
|
3957
|
+
}
|
|
3958
|
+
previousByGoal.set(event.goal.id, current);
|
|
3403
3959
|
}
|
|
3404
|
-
return {
|
|
3960
|
+
return {
|
|
3961
|
+
activityOrigins,
|
|
3962
|
+
approvals,
|
|
3963
|
+
events,
|
|
3964
|
+
facts,
|
|
3965
|
+
failures,
|
|
3966
|
+
logicalObservedAt,
|
|
3967
|
+
observedAdmissions,
|
|
3968
|
+
reconcilerEvents
|
|
3969
|
+
};
|
|
3970
|
+
});
|
|
3971
|
+
reconcile = Effect.fn("WorkStore.reconcile")(function* () {
|
|
3972
|
+
const source = yield* this.snapshotInput();
|
|
3973
|
+
if (workHistoryMaxEvents - source.events.length < workReconcilerHeadroom) {
|
|
3974
|
+
return yield* new WorkProjectionError({
|
|
3975
|
+
cause: source.events.length,
|
|
3976
|
+
detail: `the reconciler leaves ${workReconcilerHeadroom} of ${workHistoryMaxEvents} checkpoints free`,
|
|
3977
|
+
reason: "capacity_exceeded"
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
// A goal is stamped by the reconciler at most once, whatever its facts
|
|
3981
|
+
// look like later: a reopened goal stays the owner's.
|
|
3982
|
+
const stamped = new Map(source.reconcilerEvents.map(({ eventId, goalId }) => [goalId, eventId]));
|
|
3983
|
+
const cryptoService = this.#cryptoService;
|
|
3984
|
+
const now = yield* Clock.currentTimeMillis;
|
|
3985
|
+
const appendAt = (checkpoint, guard) => this.appendAt(checkpoint, guard);
|
|
3986
|
+
return yield* Effect.forEach(terminalCandidates(source.events, source.facts), Effect.fnUntraced(function* (candidate) {
|
|
3987
|
+
const goalId = candidate.head.goal.id;
|
|
3988
|
+
const previous = stamped.get(goalId);
|
|
3989
|
+
if (previous !== undefined)
|
|
3990
|
+
return { _tag: "recorded", eventId: previous, goalId };
|
|
3991
|
+
const digest = yield* cryptoService.digest("SHA-256", utf8.encode(`${candidate.fact.observationId}\u0000${goalId}`)).pipe(Effect.mapError(storeError("reconcile.digest")));
|
|
3992
|
+
const eventId = `${reconcilerEventPrefix}${Hex.encode(digest)}`;
|
|
3993
|
+
const checkpoint = terminalCheckpoint(candidate, eventId, now);
|
|
3994
|
+
// A goal that cannot take its terminal checkpoint (an activity already
|
|
3995
|
+
// holds the reconciler's id, a timestamp past the bound) is left for
|
|
3996
|
+
// its owner: reported as a conflict, never failing the other goals.
|
|
3997
|
+
if (Result.isFailure(checkpoint))
|
|
3998
|
+
return { _tag: "conflict", goalId };
|
|
3999
|
+
return yield* appendAt(checkpoint.success, {
|
|
4000
|
+
fact: { observationId: candidate.fact.observationId, subject: candidate.fact.subject },
|
|
4001
|
+
head: candidate.head,
|
|
4002
|
+
reserve: workReconcilerHeadroom
|
|
4003
|
+
}).pipe(Effect.as({
|
|
4004
|
+
_tag: "applied",
|
|
4005
|
+
eventId,
|
|
4006
|
+
goalId,
|
|
4007
|
+
state: candidate.pullRequest.state === "merged" ? "completed" : "abandoned"
|
|
4008
|
+
}), Effect.catchTag("WorkGoalRevisionConflictError", () => Effect.succeed({ _tag: "conflict", goalId })));
|
|
4009
|
+
}));
|
|
4010
|
+
});
|
|
4011
|
+
observe = Effect.fn("WorkStore.observe")(function* (envelopes) {
|
|
4012
|
+
const decoded = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservationEnvelope).check(Schema.isMaxLength(workObservedFactMaxRecords)))(envelopes).pipe(Effect.mapError(storeError("observe.decode")));
|
|
4013
|
+
const cryptoService = this.#cryptoService;
|
|
4014
|
+
// An observation from further ahead than clock skew allows would outrank
|
|
4015
|
+
// every real reading until wall time caught up, so it is skipped as stale.
|
|
4016
|
+
const clockNow = yield* Clock.currentTimeMillis;
|
|
4017
|
+
const latestAllowed = clockNow + workObservationMaxSkewMillis;
|
|
4018
|
+
const prepared = yield* Effect.forEach(decoded, Effect.fnUntraced(function* (envelope) {
|
|
4019
|
+
// Composite subjects are bounded on their own; check before anything is written.
|
|
4020
|
+
const subject = yield* Schema.decodeUnknownEffect(WorkObservationSubject)(observationSubject(envelope.observation)).pipe(Effect.mapError(storeError("observe.subject")));
|
|
4021
|
+
if (envelope.observedAt > latestAllowed)
|
|
4022
|
+
return { _tag: "future", subject };
|
|
4023
|
+
if (envelope.observation._tag === "unknown") {
|
|
4024
|
+
if (canonicalSubject(envelope.observation.source, envelope.observation.subject) === null) {
|
|
4025
|
+
return yield* new WorkStoreError({ cause: envelope.observation, operation: "observe.subject" });
|
|
4026
|
+
}
|
|
4027
|
+
return {
|
|
4028
|
+
_tag: "unknown",
|
|
4029
|
+
// Within the skew allowance, a reading from slightly ahead counts
|
|
4030
|
+
// as now, so it is visible to a snapshot taken now.
|
|
4031
|
+
observedAt: Math.min(envelope.observedAt, clockNow),
|
|
4032
|
+
reason: envelope.observation.reason,
|
|
4033
|
+
source: envelope.observation.source,
|
|
4034
|
+
subject
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
// Case-insensitive identities are lowercased so a spelling change is the
|
|
4038
|
+
// same fact; encoding emits keys in schema field order, so equal facts
|
|
4039
|
+
// give equal text and one id.
|
|
4040
|
+
const record = JSON.stringify(Schema.encodeSync(WorkObservationEnvelope)({
|
|
4041
|
+
...envelope,
|
|
4042
|
+
observation: canonicalObservation(envelope.observation)
|
|
4043
|
+
})
|
|
4044
|
+
.observation);
|
|
4045
|
+
const digest = yield* cryptoService.digest("SHA-256", utf8.encode(record)).pipe(Effect.mapError(storeError("observe.digest")));
|
|
4046
|
+
return {
|
|
4047
|
+
_tag: "fact",
|
|
4048
|
+
observationId: Hex.encode(digest),
|
|
4049
|
+
observedAt: Math.min(envelope.observedAt, clockNow),
|
|
4050
|
+
record,
|
|
4051
|
+
subject
|
|
4052
|
+
};
|
|
4053
|
+
}));
|
|
4054
|
+
yield* this.secureFiles();
|
|
4055
|
+
const report = yield* Effect.try({
|
|
4056
|
+
try: () => writeObservations(this.#database, prepared),
|
|
4057
|
+
catch: storeError("observe.write")
|
|
4058
|
+
});
|
|
4059
|
+
// The write may have created the WAL siblings; they get the same private mode.
|
|
4060
|
+
yield* this.secureFiles();
|
|
4061
|
+
return report;
|
|
3405
4062
|
});
|
|
3406
4063
|
secureFiles() {
|
|
3407
4064
|
return this.#secureFiles;
|