mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
refactor: split db.ts under 400 lines
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
# Split `src/main/runtime/orchestration/db.ts`
|
||||
|
||||
Mechanical cut/paste. Zero intentional behavior change. No method renames. Keep every Why-comment. Never add `max-lines` disables or budget bumps.
|
||||
|
||||
## Constraints that size the cut
|
||||
|
||||
- User hard cap: every dest file including the leftover barrel is **≤ 400 physical lines** (leave ~20).
|
||||
- Repo oxlint (`**/*.ts`): **300 counted lines** (`skipBlankLines` + `skipComments`). After the file-level disable is removed, this is the real CI gate. SQL-heavy files have almost no comments, so counted ≈ physical — treat **~280 counted / ~320 physical** as the working budget.
|
||||
- Source leftover (`db.ts`) is a thin barrel only, ideally < 80, hard ≤ 400.
|
||||
- Remove `/* eslint-disable max-lines */` from `db.ts`.
|
||||
- Remove `inline src/main/runtime/orchestration/db.ts` from `config/max-lines-baseline.txt` (stale baseline entries fail the ratchet).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
### Layout
|
||||
|
||||
Keep the public path `src/main/runtime/orchestration/db.ts`. TypeScript resolves `from './db'` / `from '../orchestration/db'` to the **file** `db.ts`, not the folder. Implementation lives beside it:
|
||||
|
||||
```
|
||||
src/main/runtime/orchestration/db.ts # barrel only
|
||||
src/main/runtime/orchestration/db/ # domain modules
|
||||
```
|
||||
|
||||
Do **not** add `db/index.ts`. Consumers must keep importing `./db` or `../orchestration/db`.
|
||||
|
||||
### Class + prototype attach
|
||||
|
||||
The class cannot keep 150+ method signatures in `db.ts`. Use this-typed standalone functions, prototype attach, and interface merge.
|
||||
|
||||
**`db/orchestration-db.ts`** — class shell only:
|
||||
|
||||
```ts
|
||||
export class OrchestrationDb {
|
||||
// Why: sibling domain modules need the sqlite handle; this is a TS visibility
|
||||
// change only (was private). Runtime field access is unchanged.
|
||||
db: Database.Database
|
||||
hasAnyDispatchContextsCache: boolean | undefined
|
||||
|
||||
constructor(dbPath: (string & {}) | ':memory:') {
|
||||
this.db = new Database(dbPath)
|
||||
this.db.pragma('journal_mode = WAL')
|
||||
this.db.pragma('synchronous = NORMAL')
|
||||
this.db.pragma('busy_timeout = 5000')
|
||||
this.createTables()
|
||||
this.migrate()
|
||||
hardenOrchestrationDatabaseFiles(dbPath)
|
||||
}
|
||||
}
|
||||
|
||||
export interface OrchestrationDb extends
|
||||
SchemaCreateTablesApi,
|
||||
SchemaMigrateApi,
|
||||
/* one Api interface per domain file — see §2 */
|
||||
{}
|
||||
```
|
||||
|
||||
Each domain file:
|
||||
|
||||
```ts
|
||||
import { OrchestrationDb } from './orchestration-db'
|
||||
|
||||
export function insertMessage(this: OrchestrationDb, msg: { /* unchanged */ }): MessageRow {
|
||||
/* original body, unchanged */
|
||||
}
|
||||
|
||||
export interface MessageInsertApi {
|
||||
insertMessage(msg: { /* same signature */ }): MessageRow
|
||||
}
|
||||
|
||||
OrchestrationDb.prototype.insertMessage = insertMessage
|
||||
```
|
||||
|
||||
Overloads (only `getTask`) must be copied onto the extracted function **and** the Api interface:
|
||||
|
||||
```ts
|
||||
export function getTask(this: OrchestrationDb, id: string): TaskRow | undefined
|
||||
export function getTask(this: OrchestrationDb, id: string, dispatchRunId: string): TaskRuntimeLineageRow | undefined
|
||||
export function getTask(this: OrchestrationDb, id: string, dispatchRunId?: string): TaskRow | TaskRuntimeLineageRow | undefined
|
||||
```
|
||||
|
||||
Former `private` methods become ordinary prototype methods. They stay **off** the barrel public re-export list. They become visible on the `OrchestrationDb` type so `this.foo()` typechecks across files. That is a TypeScript visibility change, not a runtime change.
|
||||
|
||||
### Barrel (`db.ts`)
|
||||
|
||||
Side-effect-import every attach module **before** constructing is possible, then re-export the public API:
|
||||
|
||||
```ts
|
||||
import './db/schema-create-tables'
|
||||
import './db/schema-migrate'
|
||||
/* …every attach module, one import per file… */
|
||||
|
||||
export { OrchestrationDb } from './db/orchestration-db'
|
||||
export {
|
||||
LEGACY_RUN_ID,
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
CURRENT_CONTRACT_VERSION
|
||||
} from './db/contract-constants'
|
||||
export type { RunListPage, TaskRuntimeLineageRow } from './db/contract-constants'
|
||||
export type {
|
||||
MessageType,
|
||||
MessagePriority,
|
||||
MessageDeliveryContract,
|
||||
TaskStatus,
|
||||
DispatchStatus,
|
||||
GateStatus,
|
||||
CoordinatorStatus,
|
||||
MessageRow,
|
||||
TaskRow,
|
||||
DispatchContextRow,
|
||||
DecisionGateRow,
|
||||
CoordinatorRun,
|
||||
WorkerReportOutcome,
|
||||
WorkerReportSettlement,
|
||||
RunRow,
|
||||
DeliveryRow,
|
||||
DeliveryStatus,
|
||||
LegacyAdoptionRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole,
|
||||
LegacyOperationReceiptRow,
|
||||
LegacyMailReceiptRow,
|
||||
QuestionRow,
|
||||
QuestionStatus,
|
||||
MutationReceiptRow,
|
||||
MutationState,
|
||||
WorkerDispatchRow,
|
||||
WorkerDispatchState
|
||||
} from './types'
|
||||
```
|
||||
|
||||
Do not re-export pure helpers, `SCHEMA_VERSION`, former private methods, or `RunListCursor`.
|
||||
|
||||
### What implementers must not do
|
||||
|
||||
- Do not import `OrchestrationDb` from `./db/orchestration-db` outside `db/`. The class file does not attach methods; the barrel does.
|
||||
- Do not import `../db` from inside `db/` (cycle). Domain files import the class from `./orchestration-db` and pures from sibling files.
|
||||
- Do not change SQL, transaction boundaries, or `this.db.exec` / `prepare` call shapes.
|
||||
- Do not rename public methods.
|
||||
- Do not add `max-lines` disables.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dest file tree
|
||||
|
||||
LOC estimates include imports, `this: OrchestrationDb`, the Api interface, and the prototype assign. They are physical-line estimates. Keep each file ≤ 380 physical and ≤ 280 counted.
|
||||
|
||||
### 2.1 Pure / shared (no prototype attach)
|
||||
|
||||
| Dest | Symbols | ~LOC | Source lines |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/contract-constants.ts` | `LEGACY_RUN_ID`, `LEGACY_CONTRACT_VERSION`, `CURRENT_CONTRACT_VERSION`, `RunListPage`, `TaskRuntimeLineageRow` | 35 | 278–293 |
|
||||
| `db/schema-version.ts` | `SCHEMA_VERSION` + the existing schema-version Why/changelog comment | 12 | 300–301 |
|
||||
| `db/orchestration-id.ts` | `generateId` | 12 | 133–135 |
|
||||
| `db/pane-key-match.ts` | `isEquivalentPaneKey`, `parseWorkerTerminalPriorOwnerIds`, `RUN_PANE_KEY_MATCH_SUFFIX_SQL`, `DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL`, `paneKeyMatchSuffix` | 50 | 68–100 |
|
||||
| `db/dispatch-capability-hash.ts` | `hashDispatchCapability` | 12 | 137–139 |
|
||||
| `db/lifecycle-rejection-marker.ts` | `addLifecycleRejectionMarker`, `hasLifecycleRejectionMarker` | 45 | 141–174 |
|
||||
| `db/utc-timestamp.ts` | `SQLITE_UTC_TIMESTAMP_RE`, `exposeUtcTimestamp`, `exposeMessageTimestamps`, `exposeMessageListTimestamps`, `exposeRunTimestamps`, `exposeDeliveryTimestamps`, `exposeQuestionTimestamps` | 85 | 176–243 |
|
||||
| `db/run-list-cursor.ts` | `RunListCursor`, `encodeRunListCursor`, `decodeRunListCursor` | 45 | 206–226, 295–298 |
|
||||
| `db/legacy-question-identity.ts` | `normalizeLegacyQuestionText`, `normalizeLegacyQuestionOptions`, `legacyMessageMatchesQuestion` | 45 | 245–276 |
|
||||
| `db/database-file-permissions.ts` | `hardenOrchestrationDatabaseFiles` | 25 | 303–313 |
|
||||
| `db/federated-worker-report-outcome.ts` | `parseFederatedWorkerReportOutcome` | 25 | 7109–7122 |
|
||||
|
||||
These stay **folder-internal**. Tests that need them import the dest file, not `./db`.
|
||||
|
||||
### 2.2 Schema SQL + migrate
|
||||
|
||||
`createTables` (336–629, ~294) and `migrate` (631–1028, ~398) cannot live in one dest file.
|
||||
|
||||
Split the `createTables` template **by concatenating string constants**, then keep a **single** `this.db.exec(...)` so statement order and atomicity stay identical:
|
||||
|
||||
```ts
|
||||
this.db.exec(
|
||||
SCHEMA_CREATE_SQL_RUNS_MESSAGES +
|
||||
SCHEMA_CREATE_SQL_WORKERS +
|
||||
SCHEMA_CREATE_SQL_TASKS_DISPATCH
|
||||
)
|
||||
this.createUndeliveredInboxIndexIfPossible()
|
||||
```
|
||||
|
||||
Exact SQL text, interpolation (`${LEGACY_RUN_ID}`, `${CURRENT_CONTRACT_VERSION}`, `${RUN_PANE_KEY_MATCH_SUFFIX_SQL}`), and whitespace must be preserved. Build the three constants as template literals that already contain their trailing newlines so the concatenated string equals today’s exec body.
|
||||
|
||||
| Dest | Contents | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/schema-create-sql-runs-messages.ts` | `CREATE` runs, messages, deliveries, mutation_receipts + their indexes | 90 | 338–406 |
|
||||
| `db/schema-create-sql-workers.ts` | worker_dispatches, worker_terminal_resources/archives, federated_dispatches, remote_dispatch_attachments, federation_relay_items, remote_questions + indexes | 160 | 408–543 |
|
||||
| `db/schema-create-sql-tasks-dispatch.ts` | tasks, dispatch_contexts, decision_gates, coordinator_runs, run pane-leaf index | 100 | 545–625 |
|
||||
| `db/schema-create-tables.ts` | `createTables` + `SchemaCreateTablesApi` | 40 | 336–629 |
|
||||
| `db/schema-column-probes.ts` | `hasColumn`, `createUndeliveredInboxIndexIfPossible`, `messagesTypeCheckAllowsHeartbeat`, `messagesTypeCheckAllowsQuestion` | 50 | 1398–1426 |
|
||||
| `db/schema-migrate.ts` | `migrate` orchestrator: read `user_version`, `resolveOrchestrationMigrationStartVersion`, `BEGIN IMMEDIATE`, call version slices, leaf index, undelivered index, `user_version = SCHEMA_VERSION`, `COMMIT`/`ROLLBACK` | 70 | 630–643, 1015–1028 |
|
||||
| `db/schema-migrate-v2-v9.ts` | `migrateSchemaV2ThroughV9(this, current)` — heartbeat rebuild, delivered_at, task creator/title, pane keys, v7 run_id backfill, v8 deliveries/questions, v9 question CHECK rebuild | 200 | 644–821 |
|
||||
| `db/schema-migrate-v10-v18.ts` | `migrateSchemaV10ThroughV18(this, current)` — capabilities, mutation_receipts, worker_dispatches, runtime_epoch, federated/remote tables, relay, remote_questions, protocol_version | 160 | 822–967 |
|
||||
| `db/schema-migrate-v19-v27.ts` | `migrateSchemaV19ThroughV27(this, current)` — calls legacy contract / question / scheduler / v22 index / `backfillWorkerTerminalResources` / v24 task creator / v25 active assignee / `migrateMutationReceiptCapacity` / v27 ack column | 80 | 968–1014 |
|
||||
|
||||
Version-slice functions are this-typed and prototype-attached (former private helpers). `migrate()` stays one transaction: slices only run `this.db.exec` / `this.hasColumn` / existing private calls; they must **not** open their own txn.
|
||||
|
||||
### 2.3 Legacy schema (1030–1396)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/schema-legacy-contract.ts` | `migrateLegacyContractStorage`, `classifyLegacyMessageContracts` | 130 | 1030–1136 |
|
||||
| `db/schema-legacy-scheduler-loss.ts` | `migrateLegacySchedulerLossProvenance`, `ensureLegacySchedulerLossColumn` | 30 | 1138–1151 |
|
||||
| `db/schema-legacy-question-backfill.ts` | `backfillLegacyQuestionThreads` | 140 | 1153–1268 |
|
||||
| `db/schema-legacy-run-adoption.ts` | `adoptLegacyRunIfNeeded` | 150 | 1270–1396 |
|
||||
|
||||
### 2.4 Mutation receipts (1430–1516)
|
||||
|
||||
| Dest | Methods | ~LOC |
|
||||
| --- | --- | --- |
|
||||
| `db/mutation-receipts.ts` | `beginMutationReceipt`, `completeMutationReceipt`, `discardPendingMutationReceipt`, `getMutationReceipt` | 110 |
|
||||
|
||||
### 2.5 Legacy compatibility (1520–2230)
|
||||
|
||||
713 lines → 5 files.
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/legacy-adoption.ts` | `getLegacyAdoption` | 20 | 1520–1524 |
|
||||
| `db/legacy-compatibility-principals.ts` | `commitLegacyCompatibilityPrincipal`, `getLegacyCompatibilityPrincipal`, `listLegacyCompatibilityPrincipals`, `getLegacyCoordinatorPrincipal` | 170 | 1526–1667 |
|
||||
| `db/legacy-compatibility-resolve.ts` | `resolveLegacyCompatibilityPrincipalByIdentity`, `resolveLegacyWorkerCandidate`, `resolveLegacyCoordinatorCandidate`, `isLegacyCoordinatorHandle` | 150 | 1669–1793 |
|
||||
| `db/legacy-operation-receipts.ts` | `findLegacyWorkerCompletion`, `hasPendingCurrentDelivery`, `setLegacyCompatibilityPrincipalStatus`, `getLegacyOperationReceipt`, `requireCommittedLegacyPrincipal`, `requireLegacyMailPrincipal`, `initializeLegacyRecoveryCohort` | 200 | 1795–1963 |
|
||||
| `db/legacy-mail.ts` | `getLegacyMailPage`, `getLegacyMailHistory`, `acknowledgeLegacyMail` | 210 | 1965–2147 |
|
||||
| `db/legacy-question-answer-ack.ts` | `acknowledgeLegacyQuestionAnswer` | 100 | 2149–2230 |
|
||||
|
||||
### 2.6 Runs + deliveries (2234–2833)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/runs.ts` | `createRun`, `getRun`, `listRuns`, `getCurrentRunForPane`, `getRunRaw`, `requireRun` | 130 | 2234–2257, 2404–2468, 2487–2491 |
|
||||
| `db/run-bind.ts` | `bindRun`, `runsBoundToPane`, `unbindOtherRunsForPane`, `fenceOutstandingDelivery` | 200 | 2259–2402, 2448–2485, 2493–2499 |
|
||||
| `db/legacy-coordinator-mail-takeover.ts` | `promoteLegacyCoordinatorMailForTakeover`, `getUniqueLegacyCoordinatorHandle` | 170 | 2501–2651 |
|
||||
| `db/run-deliveries.ts` | `requireCurrentConsumer`, `getDeliveryRaw`, `getDeliveryMessages`, `getOrCreateRunDelivery`, `acknowledgeRunDelivery`, `getRunMailboxHistory` | 210 | 2653–2833 |
|
||||
|
||||
### 2.7 Messages + legacy lifecycle (2837–3627)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/message-insert.ts` | `insertMessage` | 60 | 2837–2879 |
|
||||
| `db/legacy-lifecycle-operation.ts` | `commitLegacyLifecycleOperation` | 190 | 2881–3051 |
|
||||
| `db/legacy-ask-operation.ts` | `commitLegacyAskOperation` | 150 | 3053–3183 |
|
||||
| `db/legacy-question-find.ts` | `findPendingLegacyQuestions`, `findLegacyQuestionsBySemanticIdentity`, `resolveLegacyWorkerCoordinatorDelivery` | 130 | 3185–3282 |
|
||||
| `db/legacy-reply-operation.ts` | `commitLegacyReplyOperation`, `requireMatchingLegacyOperationReceipt`, `insertLegacyOperationReceipt` | 180 | 3284–3446 |
|
||||
| `db/message-inbox.ts` | `getUnreadMessages`, `convertLifecycleMessageToRejection`, `getUndeliveredUnreadMessages`, `getUndeliveredUnreadMailboxHandles`, `getAllMessages`, `getMessageById`, `markAsRead`, `markAsDelivered`, `markAsReadAndDelivered`, `getInbox`, `getAllMessagesForHandle`, `getThreadMessagesFor` | 220 | 3448–3627 |
|
||||
|
||||
### 2.8 Questions + tasks (3629–3983)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/questions.ts` | `createQuestion`, `getQuestion`, `getQuestionRaw`, `answerQuestion`, `closeQuestionsForDispatch` | 170 | 3629–3777 |
|
||||
| `db/tasks.ts` | `createTask`, `getTask` (overloads), `listTasks`, `listTasksWithDispatch`, `updateTaskStatus`, `promoteReadyTasks` | 230 | 3781–3983 |
|
||||
|
||||
### 2.9 Worker dispatch start / stage / fail (3987–4576)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/worker-dispatch-start.ts` | `createStartingWorkerDispatch` | 150 | 3987–4117 |
|
||||
| `db/worker-dispatch-stage.ts` | `recordWorkerStage`, `updateWorkerSetupEvidence`, `prepareStartingWorkerAuthority` | 220 | 4119–4312 |
|
||||
| `db/worker-dispatch-ready-fail.ts` | `markWorkerDispatchReady`, `failWorkerStart`, `markWorkerStartUnknown` | 110 | 4314–4406 |
|
||||
| `db/federated-worker-start.ts` | `reconcileFederatedWorkerStart` | 110 | 4408–4501 |
|
||||
| `db/worker-dispatch-lookup.ts` | `getWorkerDispatch`, `listLegacyWorkerTerminalRecoveryRows`, `reconcileMissingWorkerTerminal` | 90 | 4503–4576 |
|
||||
|
||||
`prepareStartingWorkerAuthority` is ~123 lines. If `worker-dispatch-stage.ts` goes over 280 counted after imports, move `prepareStartingWorkerAuthority` to `db/worker-dispatch-authority.ts` (~140) and leave stage/evidence in `worker-dispatch-stage.ts` (~90).
|
||||
|
||||
### 2.10 Federation / remote attachment / relay (4578–5567)
|
||||
|
||||
990 lines → 8 files.
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/federated-dispatch.ts` | `getFederatedDispatch`, `listActiveFederatedDispatches`, `findNextTerminalFederatedDispatchPendingAcknowledgment`, `isFederatedDispatchRelayEligible`, `updateFederatedDispatchResources` | 100 | 4578–4656 |
|
||||
| `db/remote-attachment-create.ts` | `createRemoteDispatchAttachment`, `getRemoteDispatchAttachment` | 90 | 4658–4731 |
|
||||
| `db/remote-attachment-stage.ts` | `recordRemoteAttachmentStage`, `updateRemoteAttachmentSetupEvidence`, `prepareRemoteAttachmentAuthority` | 140 | 4733–4851 |
|
||||
| `db/remote-attachment-lifecycle.ts` | `markRemoteAttachmentReady`, `failRemoteAttachment`, `verifyRemoteAttachmentAuthority`, `isRemoteAttachmentProcessCurrent`, `beginRemoteAttachmentStop`, `settleRemoteAttachmentStop`, `markRemoteAttachmentStopUnknown`, `findActiveRemoteAttachmentForPane` | 170 | 4853–4993 |
|
||||
| `db/federation-relay-enqueue.ts` | `enqueueFederationRelay` | 190 | 4995–5160 |
|
||||
| `db/federation-relay-ack.ts` | `listFederationRelay`, `listPendingFederationRelay`, `acknowledgeFederationRelay`, `setFederatedHomeImportSequence`, `recordFederatedHomeAcknowledgment` | 190 | 5162–5322 |
|
||||
| `db/federation-relay-import.ts` | `importFederatedRelayItem` | 140 | 5324–5440 |
|
||||
| `db/federation-remote-questions.ts` | `getRemoteQuestion`, `answerRemoteQuestion`, `setRemoteWorkerImportSequence`, `registerFederatedQuestion`, `getFederationRelayItem`, `settleRemoteAttachmentInRelayTransaction` | 150 | 5442–5567 |
|
||||
|
||||
### 2.11 Worker stop (5569–5828)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/worker-process-currency.ts` | `isDispatchProcessCurrent` | 25 | 5569–5582 |
|
||||
| `db/worker-stop.ts` | `beginWorkerStop`, `settleWorkerStop`, `markWorkerStopUnknown` | 140 | 5584–5646, 5648–5676, 5749–5759 |
|
||||
| `db/federated-worker-stop.ts` | `reconcileFederatedWorkerStop`, `resumeFederatedWorkerForTerminalRelay` | 90 | 5678–5747 |
|
||||
| `db/worker-abandon.ts` | `abandonWorkerDispatch` | 90 | 5761–5828 |
|
||||
|
||||
### 2.12 Worker terminal resources (5833–6454)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/worker-terminal-resource-create.ts` | `backfillWorkerTerminalResources`, `createWorkerTerminalResourceStatement`, `getWorkerTerminalResource`, `getWorkerTerminalResourceByOwner`, `getWorkerTerminalResourceFormerlyOwnedBy` | 120 | 5833–5931 |
|
||||
| `db/worker-terminal-resource-transfer.ts` | `transferWorkerTerminalResourceStatement`, `findTransferableWorkerTerminalResource`, `workerTerminalResourceHasIdentityConflict` | 130 | 5933–6042 |
|
||||
| `db/worker-terminal-release-request.ts` | `requestWorkerTerminalRelease`, `settleDeadWorkerTerminalRelease` | 170 | 6044–6188 |
|
||||
| `db/worker-terminal-archive.ts` | `storeWorkerTerminalArchive`, `commitWorkerTerminalArchiveForRelease`, `getWorkerTerminalArchive`, `settleWorkerTerminalRelease`, `markWorkerTerminalReleaseUnknown`, `revertWorkerTerminalReleaseToRetained` | 130 | 6190–6298 |
|
||||
| `db/worker-terminal-retain.ts` | `retainWorkerTerminalResource`, `markWorkerTerminalUserOwned`, `listWorkerTerminalReleaseBacklog` | 120 | 6300–6398 |
|
||||
| `db/worker-terminal-list.ts` | `listWorkerTerminalResources` | 70 | 6400–6454 |
|
||||
|
||||
### 2.13 Dispatch contexts + settlement (6456–6882)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/dispatch-context-create.ts` | `createDispatchContext`, `getDispatchContext`, `getDispatchContextById`, `commitDispatchLaunchTokenHash` | 120 | 6456–6553 |
|
||||
| `db/dispatch-capability.ts` | `mintDispatchCapability`, `verifyDispatchCapability`, `revokeDispatchCapability` | 100 | 6555–6633 |
|
||||
| `db/dispatch-active-lookup.ts` | `getActiveDispatchForTerminal`, `hasAnyDispatchContexts`, `getActiveDispatchForIdentity`, `findActiveDispatchForAssignee`, `getLatestDispatchForTerminal` | 90 | 6635–6697 |
|
||||
| `db/worker-report-settlement.ts` | `completeDispatch`, `completeActiveDispatchForTask`, `settleWorkerReport`, `settleWorkerReportInTransaction` | 150 | 6699–6821 |
|
||||
| `db/dispatch-fail-heartbeat.ts` | `failActiveDispatchForTask`, `recordHeartbeat`, `getStaleDispatches`, `failDispatch` | 80 | 6823–6882 |
|
||||
|
||||
### 2.14 Gates, coordinator, reset (6885–7106)
|
||||
|
||||
| Dest | Methods | ~LOC | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `db/decision-gates.ts` | `createGate`, `resolveGate`, `timeoutGate`, `listGates`, `getGate` | 100 | 6885–6967 |
|
||||
| `db/coordinator-runs.ts` | `createCoordinatorRun`, `getCoordinatorRun`, `updateCoordinatorRun`, `getActiveCoordinatorRun`, `getIdleTerminals` | 80 | 6971–7029 |
|
||||
| `db/database-reset.ts` | `runResetTransaction`, `resetAll`, `resetTasks`, `resetMessages`, `close` | 100 | 7033–7106 |
|
||||
|
||||
### 2.15 Class shell + leftover barrel
|
||||
|
||||
| Dest | Contents | ~LOC |
|
||||
| --- | --- | --- |
|
||||
| `db/orchestration-db.ts` | class fields, constructor, `export interface OrchestrationDb extends …` | 90 |
|
||||
| `db.ts` | side-effect imports + public re-exports only | 70 |
|
||||
|
||||
### File count
|
||||
|
||||
- **70 dest files** under `db/` (11 pures + 9 schema/migrate + 4 legacy-schema + 46 method modules + class shell)
|
||||
- **1 leftover barrel** `db.ts`
|
||||
- **5 characterization tests** (see §6) — only if those pures stay unasserted after extract
|
||||
|
||||
If any dest exceeds 280 counted while implementing, split at the next method boundary already listed as a fallback (do not invent new behavior to shrink a file).
|
||||
|
||||
---
|
||||
|
||||
## 3. Visibility changes (`private` → accessible)
|
||||
|
||||
TypeScript only. Runtime field/method names stay the same.
|
||||
|
||||
### Fields on `OrchestrationDb`
|
||||
|
||||
| Was | Becomes | Why |
|
||||
| --- | --- | --- |
|
||||
| `private db` | `db` | Every extracted method reads `this.db`. Tests already reach it via `(db as unknown as { db }).db` — leave those casts; do not “clean up” test access in this split. |
|
||||
| `private hasAnyDispatchContextsCache` | `hasAnyDispatchContextsCache` | `createDispatchContext`, `hasAnyDispatchContexts`, `resetAll`, `resetTasks` live in different files. |
|
||||
|
||||
Do not use `#private` fields. Do not add getters.
|
||||
|
||||
### Former private methods (prototype-attached, not barrel-exported)
|
||||
|
||||
`createTables`, `migrate`, `migrateSchemaV2ThroughV9`, `migrateSchemaV10ThroughV18`, `migrateSchemaV19ThroughV27`, `migrateLegacyContractStorage`, `classifyLegacyMessageContracts`, `migrateLegacySchedulerLossProvenance`, `ensureLegacySchedulerLossColumn`, `backfillLegacyQuestionThreads`, `adoptLegacyRunIfNeeded`, `hasColumn`, `createUndeliveredInboxIndexIfPossible`, `messagesTypeCheckAllowsHeartbeat`, `messagesTypeCheckAllowsQuestion`, `requireCommittedLegacyPrincipal`, `requireLegacyMailPrincipal`, `initializeLegacyRecoveryCohort`, `runsBoundToPane`, `getRunRaw`, `unbindOtherRunsForPane`, `requireRun`, `fenceOutstandingDelivery`, `promoteLegacyCoordinatorMailForTakeover`, `getUniqueLegacyCoordinatorHandle`, `requireCurrentConsumer`, `getDeliveryRaw`, `getDeliveryMessages`, `resolveLegacyWorkerCoordinatorDelivery`, `requireMatchingLegacyOperationReceipt`, `insertLegacyOperationReceipt`, `getQuestionRaw`, `promoteReadyTasks`, `getFederationRelayItem`, `settleRemoteAttachmentInRelayTransaction`, `backfillWorkerTerminalResources`, `findActiveDispatchForAssignee`, `settleWorkerReportInTransaction`, `runResetTransaction`.
|
||||
|
||||
The three `migrateSchemaV*` names are new only as extracted slice functions that already ran inline in `migrate()`. Bodies stay byte-for-byte the existing `if (current < N)` blocks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Circular-import strategy
|
||||
|
||||
```
|
||||
db.ts --value--> every db/*.ts attach module --value--> ./orchestration-db
|
||||
db.ts --value--> ./db/orchestration-db (re-export)
|
||||
db.ts --value--> ./db/contract-constants, ./types (public constants/types)
|
||||
|
||||
orchestration-db.ts --type-only--> each *Api interface
|
||||
orchestration-db.ts --value--> ./database-file-permissions (constructor)
|
||||
|
||||
domain.ts --value--> ./orchestration-db
|
||||
domain.ts --value--> sibling pures (pane-key-match, utc-timestamp, …)
|
||||
domain.ts --value--> ../types, ../orchestration-error, … (existing deps)
|
||||
|
||||
FORBIDDEN: db/*.ts import from '../db'
|
||||
FORBIDDEN: outside code import from './db/orchestration-db'
|
||||
```
|
||||
|
||||
`import type` in `orchestration-db.ts` is erased, so the class module does not load domain modules at runtime. Domain modules load the class, attach methods, then the barrel finishes evaluating. `new OrchestrationDb()` only happens after the barrel’s side-effect imports, so `this.createTables()` / `this.migrate()` exist.
|
||||
|
||||
Do not attach methods from `orchestration-db.ts` (that would force a runtime cycle).
|
||||
|
||||
Existing external imports (`./orchestration/db`, `../orchestration/db`, `./db`) stay valid because they hit the barrel file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Extraction order
|
||||
|
||||
Work in this order so the constructor keeps compiling after each step. Cut/paste; do not rewrite bodies.
|
||||
|
||||
1. Create `src/main/runtime/orchestration/db/`.
|
||||
2. Move pures + constants (`contract-constants`, `schema-version`, `orchestration-id`, `pane-key-match`, `dispatch-capability-hash`, `lifecycle-rejection-marker`, `utc-timestamp`, `run-list-cursor`, `legacy-question-identity`, `database-file-permissions`, `federated-worker-report-outcome`). Leave `import` stubs in `db.ts` temporarily if needed.
|
||||
3. Add `orchestration-db.ts` with fields + constructor. Temporarily keep methods on the original class until they move, **or** do the remaining steps in one commit so there is never a second `export class OrchestrationDb`.
|
||||
4. Extract schema SQL constants, then `schema-create-tables`, `schema-column-probes`, migrate slices, legacy schema modules. Attach immediately. Constructor can now live in the shell.
|
||||
5. Extract remaining method groups in source order (mutation → legacy → runs → messages → questions/tasks → worker start → federation → stop → terminal → dispatch context → gates/reset). After each file: add the `*Api` interface to the `export interface OrchestrationDb extends …` list and a barrel `import './db/<file>'`.
|
||||
6. Replace `db.ts` with the barrel. Delete leftover method bodies and the file-level `eslint-disable max-lines`.
|
||||
7. Delete `inline src/main/runtime/orchestration/db.ts` from `config/max-lines-baseline.txt`.
|
||||
8. Add characterization tests only for uncovered pures (§6).
|
||||
9. Run verification (§7).
|
||||
|
||||
Per-file cut/paste checklist:
|
||||
|
||||
- Copy the method body unchanged, including Why-comments and SQL.
|
||||
- Change `methodName(` to `export function methodName(this: OrchestrationDb,`.
|
||||
- Drop `private`.
|
||||
- Add the Api interface + `OrchestrationDb.prototype.methodName = methodName`.
|
||||
- Import sibling pures used by the body (`generateId`, `isEquivalentPaneKey`, `exposeUtcTimestamp`, …).
|
||||
- Import types from `../types` (same set the method already needs). Keep existing imports of `OrchestrationError`, `ensureMutationReceiptCapacity`, `releaseContextOnlyDispatch`, `deriveWorkerTerminalListState`, `parsePaneKey`, crypto, etc., pointing at their current modules.
|
||||
- Do not reformat SQL.
|
||||
|
||||
`createTables` SQL concat must be checked by comparing the concatenated template to the original exec string (whitespace-sensitive). If concat is risky, keep one `this.db.exec(\`...\`)` whose body is three `${SCHEMA_CREATE_SQL_*}` interpolations with no extra characters.
|
||||
|
||||
---
|
||||
|
||||
## 6. Characterization tests (uncovered pures only)
|
||||
|
||||
Existing suites already cover `exposeUtcTimestamp` (`db-message-timestamp.test.ts`), `hardenOrchestrationDatabaseFiles` (`orchestration-db-permissions.test.ts`), run-list cursors (`orchestration-db-retention-pagination.test.ts`), pane-key equivalence via `getCurrentRunForPane` (`orchestration-adopted-run-binding.test.ts`), and lifecycle rejection markers via payload assertions (`lifecycle-reconciliation.test.ts`). **Do not add tests for those.**
|
||||
|
||||
Add tests only for pures that have no current assertion after extract. Import from the dest file, not the barrel. Pin current behavior; do not invent cases the old private functions did not handle.
|
||||
|
||||
| Test file | Symbol | Pins |
|
||||
| --- | --- | --- |
|
||||
| `db/orchestration-id.test.ts` | `generateId` | prefix + `_` + 12 hex chars (`randomBytes(6)`) |
|
||||
| `db/pane-key-match.test.ts` | `paneKeyMatchSuffix`, `parseWorkerTerminalPriorOwnerIds` | suffix after first `:`; no-colon returns whole string; JSON string array vs invalid/non-array/throw → `null` |
|
||||
| `db/dispatch-capability-hash.test.ts` | `hashDispatchCapability` | SHA-256 hex of a fixed string |
|
||||
| `db/federated-worker-report-outcome.test.ts` | `parseFederatedWorkerReportOutcome` | nested `{ payload: '{"outcome":"succeeded"}' }` / `failed`; missing/invalid/non-json → `undefined` |
|
||||
|
||||
No new tests that construct `OrchestrationDb` and re-exercise already-covered public methods.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification
|
||||
|
||||
```bash
|
||||
pnpm test -- src/main/runtime/orchestration
|
||||
pnpm run typecheck:node
|
||||
pnpm run check:max-lines-ratchet
|
||||
wc -l src/main/runtime/orchestration/db.ts src/main/runtime/orchestration/db/**/*.ts
|
||||
```
|
||||
|
||||
Ignore unrelated suite / node-pty load failures.
|
||||
|
||||
`wc -l` must show `db.ts` ≤ 400 and every dest ≤ 400. If oxlint `max-lines` fires on a dest, split at the next method listed above — do not disable the rule.
|
||||
|
||||
Do not push. Do not open a PR.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks
|
||||
|
||||
- **`db.ts` + `db/` coexistence.** TS resolves `from './db'` to the file. Do not add `db/index.ts`. Internal imports must be `./db/<file>` from the barrel and `./<file>` from siblings.
|
||||
- **Constructor before attach.** Anyone importing `./db/orchestration-db` and calling `new OrchestrationDb()` gets missing methods. Public path is the barrel only.
|
||||
- **`createTables` SQL concat.** Extra/missing newlines change the executed SQL. Prefer `${A}${B}${C}` with constants that already include the original interstitial whitespace.
|
||||
- **`migrate` transaction.** Slices must not `BEGIN`/`COMMIT`. Only `migrate()` owns the txn.
|
||||
- **Interface merge drift.** Forgetting a method on `export interface OrchestrationDb extends …` makes `this.other()` fail typecheck. Add the Api to the merge in the same edit as the attach.
|
||||
- **`getTask` overloads.** Losing the two-call-site overloads is a type-level break. Copy all three signatures.
|
||||
- **Oxlint 300 vs user 400.** A 380-line SQL file will fail oxlint even if it meets the user cap. Stay under ~280 counted.
|
||||
- **Ratchet stale entry.** Leaving `inline src/main/runtime/orchestration/db.ts` in the baseline after removing the disable fails `check:max-lines-ratchet`.
|
||||
- **Test `db` casts.** Keep `(db as unknown as { db }).db`. Changing tests is out of scope.
|
||||
- **SSH / folder workspaces / remote wire / GitLab.** No logic change. Federation relay, pane-key leaf match, and contract versions stay byte-identical so mixed client/host versions keep working.
|
||||
- **Cross-file `this`.** `commitLegacyLifecycleOperation` calls `this.settleWorkerReportInTransaction`; `migrate` calls `this.backfillWorkerTerminalResources`; `createDispatchContext` writes `this.hasAnyDispatchContextsCache`. Prototype attach preserves those calls. Do not rewrite them as free-function calls.
|
||||
|
||||
---
|
||||
|
||||
## Public API (must remain importable from `./db`)
|
||||
|
||||
- `export class OrchestrationDb`
|
||||
- `export const LEGACY_RUN_ID`, `LEGACY_CONTRACT_VERSION`, `CURRENT_CONTRACT_VERSION`
|
||||
- `export type RunListPage`, `TaskRuntimeLineageRow`
|
||||
- type re-exports currently at lines 102–131 (from `./types`)
|
||||
@@ -65,7 +65,6 @@ inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
import { attachCoordinatorRunStore } from './coordinator-runs/coordinator-run-store'
|
||||
import { attachDecisionGateStore } from './decision-gates/decision-gate-store'
|
||||
import { attachDispatchCapability } from './dispatch-context/dispatch-capability'
|
||||
import { attachDispatchCompletion } from './dispatch-context/dispatch-completion'
|
||||
import { attachDispatchContextStore } from './dispatch-context/dispatch-context-store'
|
||||
import { attachDispatchLookup } from './dispatch-context/dispatch-lookup'
|
||||
import { attachWorkerReportSettlement } from './dispatch-context/worker-report-settlement'
|
||||
import { attachFederatedDispatchStore } from './federation/federated-dispatch-store'
|
||||
import { attachFederationRelayAck } from './federation/federation-relay-ack'
|
||||
import { attachFederationRelayEnqueue } from './federation/federation-relay-enqueue'
|
||||
import { attachFederationRelayImport } from './federation/federation-relay-import'
|
||||
import { attachFederationRelayItem } from './federation/federation-relay-item'
|
||||
import { attachRemoteDispatchAttachmentAuthority } from './federation/remote-dispatch-attachment-authority'
|
||||
import { attachRemoteDispatchAttachmentCreate } from './federation/remote-dispatch-attachment-create'
|
||||
import { attachRemoteDispatchAttachmentStop } from './federation/remote-dispatch-attachment-stop'
|
||||
import { attachRemoteQuestionStore } from './federation/remote-question-store'
|
||||
import { attachLegacyAskOperation } from './legacy/legacy-ask-operation'
|
||||
import { attachLegacyCompatibilityCandidates } from './legacy/legacy-compatibility-candidates'
|
||||
import { attachLegacyCompatibilityPrincipals } from './legacy/legacy-compatibility-principals'
|
||||
import { attachLegacyLifecycleOperation } from './legacy/legacy-lifecycle-operation'
|
||||
import { attachLegacyMailAcknowledge } from './legacy/legacy-mail-acknowledge'
|
||||
import { attachLegacyQuestionAcknowledge } from './legacy/legacy-question-acknowledge'
|
||||
import { attachLegacyQuestionLookup } from './legacy/legacy-question-lookup'
|
||||
import { attachLegacyRecoveryCohort } from './legacy/legacy-recovery-cohort'
|
||||
import { attachLegacyReplyOperation } from './legacy/legacy-reply-operation'
|
||||
import { attachLegacyWorkerCompletion } from './legacy/legacy-worker-completion'
|
||||
import { attachDirectMailboxRouting } from './messages/direct-mailbox-routing'
|
||||
import { attachForeignDirectMailboxRouting } from './messages/foreign-direct-mailbox-routing'
|
||||
import { attachMessageInbox } from './messages/message-inbox'
|
||||
import { attachMessageInsert } from './messages/message-insert'
|
||||
import { attachMutationReceiptStore } from './mutation-receipts/mutation-receipt-store'
|
||||
import { attachQuestionThreads } from './questions/question-threads'
|
||||
import { attachOrchestrationReset } from './reset/orchestration-reset'
|
||||
import { attachRunBinding } from './runs/run-binding'
|
||||
import { attachRunCoordinatorMailRouting } from './runs/run-coordinator-mail-routing'
|
||||
import { attachRunCreate } from './runs/run-create'
|
||||
import { attachRunDelivery } from './runs/run-delivery'
|
||||
import { attachRunLookup } from './runs/run-lookup'
|
||||
import { attachLegacyCoordinatorMailTakeover } from './runs/legacy-coordinator-mail-takeover'
|
||||
import { attachAdoptLegacyRun } from './schema/adopt-legacy-run'
|
||||
import { attachBackfillLegacyQuestionThreads } from './schema/backfill-legacy-question-threads'
|
||||
import { attachCreateTables } from './schema/create-tables'
|
||||
import { attachMigrateLegacyContractStorage } from './schema/migrate-legacy-contract-storage'
|
||||
import { attachSchemaMigrate } from './schema/migrate'
|
||||
import { attachSchemaColumnProbes } from './schema/schema-column-probes'
|
||||
import { attachTaskStore } from './tasks/task-store'
|
||||
import { attachFederatedWorkerStartReconcile } from './worker-dispatch/federated-worker-start-reconcile'
|
||||
import { attachWorkerDispatchAbandon } from './worker-dispatch/worker-dispatch-abandon'
|
||||
import { attachWorkerDispatchAuthority } from './worker-dispatch/worker-dispatch-authority'
|
||||
import { attachWorkerDispatchOutcome } from './worker-dispatch/worker-dispatch-outcome'
|
||||
import { attachWorkerDispatchStage } from './worker-dispatch/worker-dispatch-stage'
|
||||
import { attachWorkerDispatchStart } from './worker-dispatch/worker-dispatch-start'
|
||||
import { attachWorkerDispatchStop } from './worker-dispatch/worker-dispatch-stop'
|
||||
import { attachWorkerTerminalRecovery } from './worker-dispatch/worker-terminal-recovery'
|
||||
import { attachWorkerTerminalArchive } from './worker-terminal/worker-terminal-archive'
|
||||
import { attachWorkerTerminalListing } from './worker-terminal/worker-terminal-listing'
|
||||
import { attachWorkerTerminalRelease } from './worker-terminal/worker-terminal-release'
|
||||
import { attachWorkerTerminalResourceStore } from './worker-terminal/worker-terminal-resource-store'
|
||||
import { attachWorkerTerminalTransfer } from './worker-terminal/worker-terminal-transfer'
|
||||
|
||||
export function attachOrchestrationDbMethods(ctor: { prototype: object }): void {
|
||||
attachCreateTables(ctor)
|
||||
attachSchemaMigrate(ctor)
|
||||
attachSchemaColumnProbes(ctor)
|
||||
attachMigrateLegacyContractStorage(ctor)
|
||||
attachBackfillLegacyQuestionThreads(ctor)
|
||||
attachAdoptLegacyRun(ctor)
|
||||
attachMutationReceiptStore(ctor)
|
||||
attachLegacyCompatibilityPrincipals(ctor)
|
||||
attachLegacyCompatibilityCandidates(ctor)
|
||||
attachLegacyWorkerCompletion(ctor)
|
||||
attachLegacyRecoveryCohort(ctor)
|
||||
attachLegacyMailAcknowledge(ctor)
|
||||
attachLegacyQuestionAcknowledge(ctor)
|
||||
attachLegacyLifecycleOperation(ctor)
|
||||
attachLegacyAskOperation(ctor)
|
||||
attachLegacyQuestionLookup(ctor)
|
||||
attachLegacyReplyOperation(ctor)
|
||||
attachRunCreate(ctor)
|
||||
attachRunBinding(ctor)
|
||||
attachRunLookup(ctor)
|
||||
attachRunCoordinatorMailRouting(ctor)
|
||||
attachLegacyCoordinatorMailTakeover(ctor)
|
||||
attachRunDelivery(ctor)
|
||||
attachMessageInsert(ctor)
|
||||
attachMessageInbox(ctor)
|
||||
attachDirectMailboxRouting(ctor)
|
||||
attachForeignDirectMailboxRouting(ctor)
|
||||
attachQuestionThreads(ctor)
|
||||
attachTaskStore(ctor)
|
||||
attachWorkerDispatchStart(ctor)
|
||||
attachWorkerDispatchStage(ctor)
|
||||
attachWorkerDispatchAuthority(ctor)
|
||||
attachWorkerDispatchOutcome(ctor)
|
||||
attachFederatedWorkerStartReconcile(ctor)
|
||||
attachWorkerTerminalRecovery(ctor)
|
||||
attachWorkerDispatchStop(ctor)
|
||||
attachWorkerDispatchAbandon(ctor)
|
||||
attachFederatedDispatchStore(ctor)
|
||||
attachRemoteDispatchAttachmentCreate(ctor)
|
||||
attachRemoteDispatchAttachmentAuthority(ctor)
|
||||
attachRemoteDispatchAttachmentStop(ctor)
|
||||
attachFederationRelayEnqueue(ctor)
|
||||
attachFederationRelayAck(ctor)
|
||||
attachFederationRelayImport(ctor)
|
||||
attachRemoteQuestionStore(ctor)
|
||||
attachFederationRelayItem(ctor)
|
||||
attachWorkerTerminalResourceStore(ctor)
|
||||
attachWorkerTerminalTransfer(ctor)
|
||||
attachWorkerTerminalRelease(ctor)
|
||||
attachWorkerTerminalArchive(ctor)
|
||||
attachWorkerTerminalListing(ctor)
|
||||
attachDispatchContextStore(ctor)
|
||||
attachDispatchCapability(ctor)
|
||||
attachDispatchLookup(ctor)
|
||||
attachDispatchCompletion(ctor)
|
||||
attachWorkerReportSettlement(ctor)
|
||||
attachDecisionGateStore(ctor)
|
||||
attachCoordinatorRunStore(ctor)
|
||||
attachOrchestrationReset(ctor)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
|
||||
|
||||
export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID
|
||||
|
||||
export const LEGACY_CONTRACT_VERSION = 0
|
||||
export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity.
|
||||
export const SCHEMA_VERSION = 28
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CoordinatorStatus, CoordinatorRun } from '../../types'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Coordinator Runs ──
|
||||
|
||||
export function createCoordinatorRun(
|
||||
this: OrchestrationDb,
|
||||
run: {
|
||||
spec: string
|
||||
coordinatorHandle: string
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
): CoordinatorRun {
|
||||
const id = generateId('run')
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)"
|
||||
)
|
||||
.run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000)
|
||||
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun
|
||||
}
|
||||
|
||||
export function getCoordinatorRun(this: OrchestrationDb, id: string): CoordinatorRun | undefined {
|
||||
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as
|
||||
| CoordinatorRun
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function updateCoordinatorRun(
|
||||
this: OrchestrationDb,
|
||||
id: string,
|
||||
status: CoordinatorStatus
|
||||
): CoordinatorRun | undefined {
|
||||
const completedAt =
|
||||
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE coordinator_runs SET status = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?'
|
||||
)
|
||||
.run(status, completedAt, id)
|
||||
return this.getCoordinatorRun(id)
|
||||
}
|
||||
|
||||
export function getActiveCoordinatorRun(this: OrchestrationDb): CoordinatorRun | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
"SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.get() as CoordinatorRun | undefined
|
||||
}
|
||||
|
||||
// ── Queries for Coordinator ──
|
||||
|
||||
export function getIdleTerminals(this: OrchestrationDb, excludeHandles: string[] = []): string[] {
|
||||
const active = this.db
|
||||
.prepare(
|
||||
"SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')"
|
||||
)
|
||||
.all() as { assignee_handle: string }[]
|
||||
const busyHandles = new Set(active.map((r) => r.assignee_handle))
|
||||
for (const h of excludeHandles) {
|
||||
busyHandles.add(h)
|
||||
}
|
||||
// Return handles from message history that aren't busy
|
||||
const allHandles = this.db
|
||||
.prepare(
|
||||
'SELECT DISTINCT to_handle FROM messages UNION SELECT DISTINCT from_handle FROM messages'
|
||||
)
|
||||
.all() as { to_handle: string }[]
|
||||
return [...new Set(allHandles.map((r) => r.to_handle))].filter((h) => !busyHandles.has(h))
|
||||
}
|
||||
|
||||
export type CoordinatorRunStoreMethods = {
|
||||
createCoordinatorRun: typeof createCoordinatorRun
|
||||
getCoordinatorRun: typeof getCoordinatorRun
|
||||
updateCoordinatorRun: typeof updateCoordinatorRun
|
||||
getActiveCoordinatorRun: typeof getActiveCoordinatorRun
|
||||
getIdleTerminals: typeof getIdleTerminals
|
||||
}
|
||||
|
||||
export function attachCoordinatorRunStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createCoordinatorRun,
|
||||
getCoordinatorRun,
|
||||
updateCoordinatorRun,
|
||||
getActiveCoordinatorRun,
|
||||
getIdleTerminals
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { chmodSync, existsSync } from 'node:fs'
|
||||
|
||||
export function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void {
|
||||
if (dbPath === ':memory:' || process.platform === 'win32') {
|
||||
// Why: Windows protects these files through Orca's current-user-only userData DACL; POSIX mode bits are inert there.
|
||||
return
|
||||
}
|
||||
for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
||||
if (existsSync(path)) {
|
||||
chmodSync(path, 0o600)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { GateStatus, DecisionGateRow } from '../../types'
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Decision Gates ──
|
||||
|
||||
export function createGate(
|
||||
this: OrchestrationDb,
|
||||
gate: { taskId: string; question: string; options?: string[] }
|
||||
): DecisionGateRow {
|
||||
const id = generateId('gate')
|
||||
const optionsJson = JSON.stringify(gate.options ?? [])
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO decision_gates (id, run_id, task_id, question, options) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
this.getTask(gate.taskId)?.run_id ?? LEGACY_RUN_ID,
|
||||
gate.taskId,
|
||||
gate.question,
|
||||
optionsJson
|
||||
)
|
||||
|
||||
this.completeActiveDispatchForTask(gate.taskId)
|
||||
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId)
|
||||
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow
|
||||
}
|
||||
|
||||
export function resolveGate(
|
||||
this: OrchestrationDb,
|
||||
gateId: string,
|
||||
resolution: string
|
||||
): DecisionGateRow | undefined {
|
||||
const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
if (!gate) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.run(resolution, gateId)
|
||||
|
||||
// Why: set to 'ready' (not the previous status) so the coordinator re-dispatches the worker with the resolution context.
|
||||
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id)
|
||||
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function timeoutGate(this: OrchestrationDb, gateId: string): DecisionGateRow | undefined {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.run(gateId)
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function listGates(
|
||||
this: OrchestrationDb,
|
||||
filter?: { taskId?: string; status?: GateStatus }
|
||||
): DecisionGateRow[] {
|
||||
if (filter?.taskId && filter?.status) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at')
|
||||
.all(filter.taskId, filter.status) as DecisionGateRow[]
|
||||
}
|
||||
if (filter?.taskId) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at')
|
||||
.all(filter.taskId) as DecisionGateRow[]
|
||||
}
|
||||
if (filter?.status) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at')
|
||||
.all(filter.status) as DecisionGateRow[]
|
||||
}
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates ORDER BY created_at')
|
||||
.all() as DecisionGateRow[]
|
||||
}
|
||||
|
||||
export function getGate(this: OrchestrationDb, id: string): DecisionGateRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type DecisionGateStoreMethods = {
|
||||
createGate: typeof createGate
|
||||
resolveGate: typeof resolveGate
|
||||
timeoutGate: typeof timeoutGate
|
||||
listGates: typeof listGates
|
||||
getGate: typeof getGate
|
||||
}
|
||||
|
||||
export function attachDecisionGateStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createGate,
|
||||
resolveGate,
|
||||
timeoutGate,
|
||||
listGates,
|
||||
getGate
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export function hashDispatchCapability(capability: string): string {
|
||||
return createHash('sha256').update(capability).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { hashDispatchCapability } from '../dispatch-capability-hash'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function mintDispatchCapability(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
}
|
||||
): string {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (!dispatch || (dispatch.status !== 'pending' && dispatch.status !== 'dispatched')) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${params.dispatchId} is not active.`
|
||||
)
|
||||
}
|
||||
const capability = `dcap_${randomBytes(32).toString('base64url')}`
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET capability_hash = ?, assignee_pane_key = ?, process_incarnation = ?,
|
||||
capability_revoked_at = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
hashDispatchCapability(capability),
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
params.dispatchId
|
||||
)
|
||||
return capability
|
||||
}
|
||||
|
||||
export function verifyDispatchCapability(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
capability: string | undefined
|
||||
paneKey: string | undefined
|
||||
processIncarnation: string | undefined
|
||||
}
|
||||
): { valid: true } | { valid: false; reason: string } {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (!dispatch) {
|
||||
return { valid: false, reason: `Dispatch ${params.dispatchId} was not found.` }
|
||||
}
|
||||
if (!dispatch.capability_hash) {
|
||||
return { valid: false, reason: `Dispatch ${params.dispatchId} has no lifecycle capability.` }
|
||||
}
|
||||
if (dispatch.capability_revoked_at) {
|
||||
return { valid: false, reason: `Dispatch ${params.dispatchId} capability is revoked.` }
|
||||
}
|
||||
if (!params.capability) {
|
||||
return { valid: false, reason: 'The Dispatch capability is missing.' }
|
||||
}
|
||||
const expected = Buffer.from(dispatch.capability_hash, 'hex')
|
||||
const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex')
|
||||
if (expected.length !== observed.length || !timingSafeEqual(expected, observed)) {
|
||||
return { valid: false, reason: 'The Dispatch capability is invalid.' }
|
||||
}
|
||||
if (
|
||||
!dispatch.assignee_pane_key ||
|
||||
!params.paneKey ||
|
||||
!isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey)
|
||||
) {
|
||||
return { valid: false, reason: 'The caller is not the Dispatch pane.' }
|
||||
}
|
||||
if (
|
||||
!dispatch.process_incarnation ||
|
||||
!params.processIncarnation ||
|
||||
dispatch.process_incarnation !== params.processIncarnation
|
||||
) {
|
||||
return { valid: false, reason: 'The Dispatch process incarnation changed.' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function revokeDispatchCapability(this: OrchestrationDb, dispatchId: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatchId)
|
||||
}
|
||||
|
||||
export type DispatchCapabilityMethods = {
|
||||
mintDispatchCapability: typeof mintDispatchCapability
|
||||
verifyDispatchCapability: typeof verifyDispatchCapability
|
||||
revokeDispatchCapability: typeof revokeDispatchCapability
|
||||
}
|
||||
|
||||
export function attachDispatchCapability(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
mintDispatchCapability,
|
||||
verifyDispatchCapability,
|
||||
revokeDispatchCapability
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { TaskStatus, DispatchStatus, DispatchContextRow } from '../../types'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function completeDispatch(this: OrchestrationDb, ctxId: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now'), capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) WHERE id = ?"
|
||||
)
|
||||
.run(ctxId)
|
||||
}
|
||||
|
||||
export function completeActiveDispatchForTask(this: OrchestrationDb, taskId: string): void {
|
||||
const active = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
|
||||
)
|
||||
.get(taskId) as DispatchContextRow | undefined
|
||||
if (active) {
|
||||
this.completeDispatch(active.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function failActiveDispatchForTask(
|
||||
this: OrchestrationDb,
|
||||
taskId: string,
|
||||
error: string
|
||||
): DispatchContextRow | undefined {
|
||||
const active = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
|
||||
)
|
||||
.get(taskId) as DispatchContextRow | undefined
|
||||
return active ? this.failDispatch(active.id, error) : undefined
|
||||
}
|
||||
|
||||
// Why: only bump status='dispatched' — a zombie heartbeat from a finished dispatch would mask a hung retry from the stale detector (§5.3.4).
|
||||
export function recordHeartbeat(this: OrchestrationDb, dispatchId: string, at: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'"
|
||||
)
|
||||
.run(at, dispatchId)
|
||||
}
|
||||
|
||||
// Why: dispatched_at grace skips workers still within their first heartbeat interval; julianday() vs raw-TEXT compare avoids misflagging space-format timestamps as stale (#8452).
|
||||
export function getStaleDispatches(
|
||||
this: OrchestrationDb,
|
||||
thresholdIso: string
|
||||
): DispatchContextRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE status = 'dispatched'
|
||||
AND dispatched_at IS NOT NULL
|
||||
AND julianday(dispatched_at) < julianday(?)
|
||||
AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))`
|
||||
)
|
||||
.all(thresholdIso, thresholdIso) as DispatchContextRow[]
|
||||
}
|
||||
|
||||
export function failDispatch(
|
||||
this: OrchestrationDb,
|
||||
ctxId: string,
|
||||
error: string
|
||||
): DispatchContextRow | undefined {
|
||||
const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
if (!ctx) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const newFailureCount = ctx.failure_count + 1
|
||||
const newStatus: DispatchStatus = newFailureCount >= 3 ? 'circuit_broken' : 'failed'
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = ?, failure_count = ?, last_failure = ?,
|
||||
completed_at = COALESCE(completed_at, datetime('now')),
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(newStatus, newFailureCount, error, ctxId)
|
||||
|
||||
// Why: back to 'ready' not 'pending' — 'pending' would strand it since promoteReadyTasks only runs when a dep completes.
|
||||
const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready'
|
||||
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id)
|
||||
|
||||
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type DispatchCompletionMethods = {
|
||||
completeDispatch: typeof completeDispatch
|
||||
completeActiveDispatchForTask: typeof completeActiveDispatchForTask
|
||||
failActiveDispatchForTask: typeof failActiveDispatchForTask
|
||||
recordHeartbeat: typeof recordHeartbeat
|
||||
getStaleDispatches: typeof getStaleDispatches
|
||||
failDispatch: typeof failDispatch
|
||||
}
|
||||
|
||||
export function attachDispatchCompletion(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
completeDispatch,
|
||||
completeActiveDispatchForTask,
|
||||
failActiveDispatchForTask,
|
||||
recordHeartbeat,
|
||||
getStaleDispatches,
|
||||
failDispatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DispatchContextRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { CURRENT_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function createDispatchContext(
|
||||
this: OrchestrationDb,
|
||||
taskId: string,
|
||||
assigneeHandle: string,
|
||||
// Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue.
|
||||
assigneePaneKey?: string,
|
||||
launchTokenHash?: string,
|
||||
processIncarnation?: string
|
||||
): DispatchContextRow {
|
||||
const task = this.getTask(taskId)
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`)
|
||||
}
|
||||
if (task.status !== 'ready') {
|
||||
throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`)
|
||||
}
|
||||
|
||||
// Why: lock on pane identity too, so a reminted handle can't open a second concurrent dispatch on the same pane.
|
||||
const existing = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey)
|
||||
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`Terminal ${assigneeHandle} already has an active dispatch (${existing.id} for task ${existing.task_id})`
|
||||
)
|
||||
}
|
||||
|
||||
// Carry forward failure_count so the circuit breaker accumulates across retries for the same task.
|
||||
const prior = this.db
|
||||
.prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?')
|
||||
.get(taskId) as { max_failures: number | null } | undefined
|
||||
const priorFailures = prior?.max_failures ?? 0
|
||||
|
||||
const id = generateId('ctx')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, launch_token_hash,
|
||||
assignee_handle, assignee_pane_key, process_incarnation,
|
||||
status, failure_count, dispatched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
task.run_id,
|
||||
taskId,
|
||||
CURRENT_CONTRACT_VERSION,
|
||||
launchTokenHash ?? null,
|
||||
assigneeHandle,
|
||||
assigneePaneKey ?? null,
|
||||
processIncarnation ?? null,
|
||||
priorFailures
|
||||
)
|
||||
this.hasAnyDispatchContextsCache = true
|
||||
|
||||
this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId)
|
||||
|
||||
return this.db
|
||||
.prepare('SELECT * FROM dispatch_contexts WHERE id = ?')
|
||||
.get(id) as DispatchContextRow
|
||||
}
|
||||
|
||||
export function getDispatchContext(
|
||||
this: OrchestrationDb,
|
||||
taskId: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1')
|
||||
.get(taskId) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export function getDispatchContextById(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(dispatchId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function commitDispatchLaunchTokenHash(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
launchTokenHash: string
|
||||
): DispatchContextRow {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (!dispatch) {
|
||||
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
|
||||
}
|
||||
if (dispatch.contract_version !== CURRENT_CONTRACT_VERSION) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatchId} does not use the current contract.`
|
||||
)
|
||||
}
|
||||
if (dispatch.launch_token_hash && dispatch.launch_token_hash !== launchTokenHash) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatchId} already has a different launch-token commitment.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET launch_token_hash = COALESCE(launch_token_hash, ?)
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(launchTokenHash, dispatchId)
|
||||
return this.getDispatchContextById(dispatchId) as DispatchContextRow
|
||||
}
|
||||
|
||||
export type DispatchContextStoreMethods = {
|
||||
createDispatchContext: typeof createDispatchContext
|
||||
getDispatchContext: typeof getDispatchContext
|
||||
getDispatchContextById: typeof getDispatchContextById
|
||||
commitDispatchLaunchTokenHash: typeof commitDispatchLaunchTokenHash
|
||||
}
|
||||
|
||||
export function attachDispatchContextStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createDispatchContext,
|
||||
getDispatchContext,
|
||||
getDispatchContextById,
|
||||
commitDispatchLaunchTokenHash
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import type { DispatchContextRow } from '../../types'
|
||||
import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getActiveDispatchForTerminal(
|
||||
this: OrchestrationDb,
|
||||
handle: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.findActiveDispatchForAssignee(handle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap "are there any dispatch rows at all" probe. When false, no terminal
|
||||
* can have an active or recent-completed dispatch, so orchestration-context
|
||||
* builders can skip their per-terminal query fan-out entirely. Cached after
|
||||
* the first probe; createDispatchContext marks it true, resets clear it.
|
||||
*/
|
||||
|
||||
export function hasAnyDispatchContexts(this: OrchestrationDb): boolean {
|
||||
if (this.hasAnyDispatchContextsCache === undefined) {
|
||||
const row = this.db.prepare('SELECT 1 FROM dispatch_contexts LIMIT 1').get()
|
||||
this.hasAnyDispatchContextsCache = row !== undefined
|
||||
}
|
||||
return this.hasAnyDispatchContextsCache
|
||||
}
|
||||
|
||||
export function getActiveDispatchForIdentity(
|
||||
this: OrchestrationDb,
|
||||
handle: string,
|
||||
paneKey?: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.findActiveDispatchForAssignee(handle, paneKey)
|
||||
}
|
||||
|
||||
export function findActiveDispatchForAssignee(
|
||||
this: OrchestrationDb,
|
||||
assigneeHandle: string,
|
||||
assigneePaneKey?: string
|
||||
): DispatchContextRow | undefined {
|
||||
const byHandle = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1"
|
||||
)
|
||||
.get(assigneeHandle) as DispatchContextRow | undefined
|
||||
if (byHandle) {
|
||||
return byHandle
|
||||
}
|
||||
|
||||
if (!assigneePaneKey) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const exactPane = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(assigneePaneKey) as DispatchContextRow | undefined
|
||||
if (exactPane) {
|
||||
return exactPane
|
||||
}
|
||||
if (!parsePaneKey(assigneePaneKey)) {
|
||||
return undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export function getLatestDispatchForTerminal(
|
||||
this: OrchestrationDb,
|
||||
handle: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT * FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1'
|
||||
)
|
||||
.get(handle) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export type DispatchLookupMethods = {
|
||||
getActiveDispatchForTerminal: typeof getActiveDispatchForTerminal
|
||||
hasAnyDispatchContexts: typeof hasAnyDispatchContexts
|
||||
getActiveDispatchForIdentity: typeof getActiveDispatchForIdentity
|
||||
findActiveDispatchForAssignee: typeof findActiveDispatchForAssignee
|
||||
getLatestDispatchForTerminal: typeof getLatestDispatchForTerminal
|
||||
}
|
||||
|
||||
export function attachDispatchLookup(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getActiveDispatchForTerminal,
|
||||
hasAnyDispatchContexts,
|
||||
getActiveDispatchForIdentity,
|
||||
findActiveDispatchForAssignee,
|
||||
getLatestDispatchForTerminal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { WorkerReportOutcome, WorkerReportSettlement } from '../../types'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function settleWorkerReport(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
): WorkerReportSettlement {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const settlement = this.settleWorkerReportInTransaction(params)
|
||||
this.db.exec('COMMIT')
|
||||
return settlement
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function settleWorkerReportInTransaction(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
): WorkerReportSettlement {
|
||||
const task = this.getTask(params.taskId)
|
||||
if (!task) {
|
||||
return { action: 'rejected', code: 'unknown_task', reason: `Unknown task ${params.taskId}.` }
|
||||
}
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (!dispatch) {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'unknown_dispatch',
|
||||
reason: `Unknown dispatch ${params.dispatchId}.`
|
||||
}
|
||||
}
|
||||
if (dispatch.task_id !== params.taskId) {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'task_dispatch_mismatch',
|
||||
reason: `Dispatch ${params.dispatchId} belongs to task ${dispatch.task_id}, not ${params.taskId}.`
|
||||
}
|
||||
}
|
||||
|
||||
const expectedDispatchStatus = params.outcome === 'succeeded' ? 'completed' : 'failed'
|
||||
const expectedTaskStatus = params.outcome === 'succeeded' ? 'completed' : 'failed'
|
||||
if (dispatch.status === expectedDispatchStatus && task.status === expectedTaskStatus) {
|
||||
return { action: 'settled', outcome: params.outcome, duplicate: true }
|
||||
}
|
||||
if (dispatch.status !== 'dispatched' || task.status !== 'dispatched') {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'inactive_dispatch',
|
||||
reason: `inactive dispatch ${params.dispatchId}: it or task ${params.taskId} is already settled.`
|
||||
}
|
||||
}
|
||||
const latest = this.getDispatchContext(params.taskId)
|
||||
if (latest?.id !== params.dispatchId) {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'stale_dispatch',
|
||||
reason: `Dispatch ${params.dispatchId} is not the current dispatch for task ${params.taskId}.`
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec('SAVEPOINT settle_worker_report')
|
||||
const dispatchUpdate = this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = ?, completed_at = datetime('now'),
|
||||
last_failure = CASE WHEN ? = 'failed' THEN ? ELSE last_failure END,
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ? AND status = 'dispatched'`
|
||||
)
|
||||
.run(expectedDispatchStatus, expectedDispatchStatus, params.result, params.dispatchId)
|
||||
const taskUpdate = this.db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = ?, result = ?, completed_at = datetime('now')
|
||||
WHERE id = ? AND status = 'dispatched'`
|
||||
)
|
||||
.run(expectedTaskStatus, params.result, params.taskId)
|
||||
if (dispatchUpdate.changes !== 1 || taskUpdate.changes !== 1) {
|
||||
this.db.exec('ROLLBACK TO settle_worker_report')
|
||||
this.db.exec('RELEASE settle_worker_report')
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'inactive_dispatch',
|
||||
reason: `Dispatch ${params.dispatchId} changed while its worker report was settling.`
|
||||
}
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = ?, stage = 'settled', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'ready'`
|
||||
)
|
||||
.run(params.outcome === 'succeeded' ? 'succeeded' : 'failed', params.dispatchId)
|
||||
this.closeQuestionsForDispatch(params.dispatchId)
|
||||
if (params.outcome === 'succeeded') {
|
||||
this.promoteReadyTasks(params.taskId)
|
||||
}
|
||||
this.db.exec('RELEASE settle_worker_report')
|
||||
return { action: 'settled', outcome: params.outcome, duplicate: false }
|
||||
}
|
||||
|
||||
export type WorkerReportSettlementMethods = {
|
||||
settleWorkerReport: typeof settleWorkerReport
|
||||
settleWorkerReportInTransaction: typeof settleWorkerReportInTransaction
|
||||
}
|
||||
|
||||
export function attachWorkerReportSettlement(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
settleWorkerReport,
|
||||
settleWorkerReportInTransaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseFederatedWorkerReportOutcome } from './federated-worker-report-outcome'
|
||||
|
||||
describe('federated-worker-report-outcome', () => {
|
||||
it('reads succeeded or failed from a nested report payload', () => {
|
||||
const succeeded = JSON.stringify({ payload: JSON.stringify({ outcome: 'succeeded' }) })
|
||||
const failed = JSON.stringify({ payload: JSON.stringify({ outcome: 'failed' }) })
|
||||
expect(parseFederatedWorkerReportOutcome(succeeded)).toBe('succeeded')
|
||||
expect(parseFederatedWorkerReportOutcome(failed)).toBe('failed')
|
||||
})
|
||||
|
||||
it('returns undefined for malformed or non-terminal outcomes', () => {
|
||||
expect(parseFederatedWorkerReportOutcome('not-json')).toBeUndefined()
|
||||
expect(parseFederatedWorkerReportOutcome(JSON.stringify({ payload: {} }))).toBeUndefined()
|
||||
const other = JSON.stringify({ payload: JSON.stringify({ outcome: 'running' }) })
|
||||
expect(parseFederatedWorkerReportOutcome(other)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { WorkerReportOutcome } from '../types'
|
||||
|
||||
export function parseFederatedWorkerReportOutcome(
|
||||
payload: string
|
||||
): WorkerReportOutcome | undefined {
|
||||
try {
|
||||
const message = JSON.parse(payload) as { payload?: unknown }
|
||||
if (typeof message.payload !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const report = JSON.parse(message.payload) as { outcome?: unknown }
|
||||
return report.outcome === 'succeeded' || report.outcome === 'failed'
|
||||
? report.outcome
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { FederatedDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getFederatedDispatch(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): FederatedDispatchRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM federated_dispatches WHERE dispatch_id = ?')
|
||||
.get(dispatchId) as FederatedDispatchRow | undefined
|
||||
}
|
||||
|
||||
export function listActiveFederatedDispatches(
|
||||
this: OrchestrationDb,
|
||||
runId?: string
|
||||
): FederatedDispatchRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT fd.*
|
||||
FROM federated_dispatches fd
|
||||
INNER JOIN dispatch_contexts dc ON dc.id = fd.dispatch_id
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
|
||||
WHERE wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
|
||||
AND (? IS NULL OR dc.run_id = ?)
|
||||
ORDER BY fd.rowid`
|
||||
)
|
||||
.all(runId ?? null, runId ?? null) as FederatedDispatchRow[]
|
||||
}
|
||||
|
||||
export function findNextTerminalFederatedDispatchPendingAcknowledgment(
|
||||
this: OrchestrationDb,
|
||||
afterRowId: number
|
||||
): { dispatchId: string; rowId: number } | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT fd.dispatch_id AS dispatchId, fd.rowid AS rowId
|
||||
FROM federated_dispatches fd
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
|
||||
WHERE wd.state NOT IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
|
||||
AND fd.to_home_acknowledged_sequence < fd.to_home_imported_sequence
|
||||
AND fd.rowid > ?
|
||||
ORDER BY fd.rowid
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(afterRowId) as { dispatchId: string; rowId: number } | undefined
|
||||
}
|
||||
|
||||
export function isFederatedDispatchRelayEligible(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM federated_dispatches fd
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
|
||||
WHERE fd.dispatch_id = ?
|
||||
AND (
|
||||
wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
|
||||
OR (
|
||||
fd.to_home_acknowledged_sequence < fd.to_home_imported_sequence
|
||||
)
|
||||
)`
|
||||
)
|
||||
.get(dispatchId)
|
||||
)
|
||||
}
|
||||
|
||||
export function updateFederatedDispatchResources(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
remoteRuntimeEpoch: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
}
|
||||
): FederatedDispatchRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federated_dispatches
|
||||
SET remote_runtime_epoch = ?, remote_worktree_id = ?, remote_terminal_handle = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(params.remoteRuntimeEpoch, params.worktreeId, params.terminalHandle, params.dispatchId)
|
||||
const row = this.getFederatedDispatch(params.dispatchId)
|
||||
if (!row) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Federated Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
export type FederatedDispatchStoreMethods = {
|
||||
getFederatedDispatch: typeof getFederatedDispatch
|
||||
listActiveFederatedDispatches: typeof listActiveFederatedDispatches
|
||||
findNextTerminalFederatedDispatchPendingAcknowledgment: typeof findNextTerminalFederatedDispatchPendingAcknowledgment
|
||||
isFederatedDispatchRelayEligible: typeof isFederatedDispatchRelayEligible
|
||||
updateFederatedDispatchResources: typeof updateFederatedDispatchResources
|
||||
}
|
||||
|
||||
export function attachFederatedDispatchStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getFederatedDispatch,
|
||||
listActiveFederatedDispatches,
|
||||
findNextTerminalFederatedDispatchPendingAcknowledgment,
|
||||
isFederatedDispatchRelayEligible,
|
||||
updateFederatedDispatchResources
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
WorkerReportOutcome,
|
||||
FederationRelayDirection,
|
||||
FederationRelayItemRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../../shared/protocol-version'
|
||||
import { parseFederatedWorkerReportOutcome } from '../federated-worker-report-outcome'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function listFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
direction: FederationRelayDirection
|
||||
afterSequence: number
|
||||
limit?: number
|
||||
}
|
||||
): FederationRelayItemRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence > ?
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
params.afterSequence,
|
||||
Math.min(Math.max(params.limit ?? 50, 1), 50)
|
||||
) as FederationRelayItemRow[]
|
||||
}
|
||||
|
||||
export function listPendingFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
direction: FederationRelayDirection,
|
||||
limit = 50
|
||||
): FederationRelayItemRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(dispatchId, direction, Math.min(Math.max(limit, 1), 50)) as FederationRelayItemRow[]
|
||||
}
|
||||
|
||||
export function acknowledgeFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
direction: FederationRelayDirection
|
||||
throughSequence: number
|
||||
settleRemoteReports?: { sequence: number; outcome?: WorkerReportOutcome }[]
|
||||
}
|
||||
): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const settledReports = params.settleRemoteReports ?? []
|
||||
for (const settledReport of settledReports) {
|
||||
const report = this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
settledReport.sequence
|
||||
)
|
||||
if (
|
||||
params.direction !== 'to_home' ||
|
||||
settledReport.sequence > params.throughSequence ||
|
||||
report?.kind !== 'worker_done' ||
|
||||
(settledReport.outcome !== undefined &&
|
||||
parseFederatedWorkerReportOutcome(report.payload) !== settledReport.outcome)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} does not match its queued worker_done.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (
|
||||
params.direction === 'to_home' &&
|
||||
attachment !== undefined &&
|
||||
attachment.protocol_version >= ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION
|
||||
) {
|
||||
const acknowledgedReports = this.db
|
||||
.prepare(
|
||||
`SELECT sequence FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = 'to_home' AND kind = 'worker_done'
|
||||
AND acked_at IS NULL AND sequence <= ?`
|
||||
)
|
||||
.all(params.dispatchId, params.throughSequence) as { sequence: number }[]
|
||||
const settledSequences = new Set(settledReports.map((report) => report.sequence))
|
||||
if (acknowledgedReports.some((report) => !settledSequences.has(report.sequence))) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} omits a worker_done settlement.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const terminalOutcomes = new Set(
|
||||
settledReports.flatMap((report) => (report.outcome ? [report.outcome] : []))
|
||||
)
|
||||
if (terminalOutcomes.size > 1) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} contains conflicting settlements.`
|
||||
)
|
||||
}
|
||||
const terminalOutcome = settledReports.find((report) => report.outcome)?.outcome
|
||||
if (terminalOutcome) {
|
||||
this.settleRemoteAttachmentInRelayTransaction(
|
||||
params.dispatchId,
|
||||
terminalOutcome,
|
||||
'worker_report_settled'
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federation_relay_items SET acked_at = COALESCE(acked_at, datetime('now'))
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence <= ?`
|
||||
)
|
||||
.run(params.dispatchId, params.direction, params.throughSequence)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function setFederatedHomeImportSequence(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
sequence: number
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federated_dispatches
|
||||
SET to_home_imported_sequence = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND to_home_imported_sequence < ?`
|
||||
)
|
||||
.run(sequence, dispatchId, sequence)
|
||||
}
|
||||
|
||||
export function recordFederatedHomeAcknowledgment(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
remoteRuntimeEpoch: string
|
||||
sequence: number
|
||||
}
|
||||
): void {
|
||||
const federated = this.getFederatedDispatch(params.dispatchId)
|
||||
if (
|
||||
!federated ||
|
||||
!Number.isInteger(params.sequence) ||
|
||||
params.sequence < 0 ||
|
||||
params.sequence > federated.to_home_imported_sequence
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} exceeds imported relay state.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federated_dispatches
|
||||
SET remote_runtime_epoch = ?,
|
||||
to_home_acknowledged_sequence = CASE
|
||||
WHEN remote_runtime_epoch = ?
|
||||
THEN MAX(to_home_acknowledged_sequence, ?)
|
||||
ELSE ?
|
||||
END,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(
|
||||
params.remoteRuntimeEpoch,
|
||||
params.remoteRuntimeEpoch,
|
||||
params.sequence,
|
||||
params.sequence,
|
||||
params.dispatchId
|
||||
)
|
||||
}
|
||||
|
||||
export type FederationRelayAckMethods = {
|
||||
listFederationRelay: typeof listFederationRelay
|
||||
listPendingFederationRelay: typeof listPendingFederationRelay
|
||||
acknowledgeFederationRelay: typeof acknowledgeFederationRelay
|
||||
setFederatedHomeImportSequence: typeof setFederatedHomeImportSequence
|
||||
recordFederatedHomeAcknowledgment: typeof recordFederatedHomeAcknowledgment
|
||||
}
|
||||
|
||||
export function attachFederationRelayAck(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
listFederationRelay,
|
||||
listPendingFederationRelay,
|
||||
acknowledgeFederationRelay,
|
||||
setFederatedHomeImportSequence,
|
||||
recordFederatedHomeAcknowledgment
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
WorkerReportOutcome,
|
||||
FederationRelayDirection,
|
||||
FederationRelayItemRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function enqueueFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
direction: FederationRelayDirection
|
||||
kind: string
|
||||
payload: string
|
||||
messageId?: string
|
||||
settleRemoteOutcome?: WorkerReportOutcome
|
||||
remoteQuestion?: true
|
||||
}
|
||||
): FederationRelayItemRow {
|
||||
const byteCount = Buffer.byteLength(params.payload, 'utf8')
|
||||
const messageId = params.messageId ?? generateId('relay')
|
||||
if (byteCount > 64 * 1024) {
|
||||
throw new OrchestrationError(
|
||||
'relay_quota_exceeded',
|
||||
'A federated orchestration message cannot exceed 64 KiB.'
|
||||
)
|
||||
}
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (params.settleRemoteOutcome) {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!attachment || attachment.state !== 'ready') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${params.dispatchId} is not active.`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (params.kind === 'heartbeat') {
|
||||
const heartbeat = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat'
|
||||
AND acked_at IS NULL
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined
|
||||
if (heartbeat) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federation_relay_items
|
||||
SET payload = ?, byte_count = ?, created_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
|
||||
)
|
||||
.run(params.payload, byteCount, params.dispatchId, params.direction, heartbeat.sequence)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
heartbeat.sequence
|
||||
) as FederationRelayItemRow
|
||||
}
|
||||
}
|
||||
if (params.kind === 'worker_done') {
|
||||
const identicalReport = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND kind = 'worker_done'
|
||||
AND payload = ? AND acked_at IS NULL
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(params.dispatchId, params.direction, params.payload) as
|
||||
| FederationRelayItemRow
|
||||
| undefined
|
||||
if (identicalReport) {
|
||||
this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome)
|
||||
this.db.exec('COMMIT')
|
||||
return identicalReport
|
||||
}
|
||||
}
|
||||
const quota = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count, COALESCE(SUM(byte_count), 0) AS bytes
|
||||
FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as { count: number; bytes: number }
|
||||
if (quota.count >= 256 || quota.bytes + byteCount > 1024 * 1024) {
|
||||
if (params.kind === 'worker_done') {
|
||||
const heartbeat = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat'
|
||||
AND acked_at IS NULL
|
||||
ORDER BY sequence LIMIT 1`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined
|
||||
if (heartbeat) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federation_relay_items
|
||||
SET message_id = ?, kind = ?, payload = ?, byte_count = ?,
|
||||
created_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
|
||||
)
|
||||
.run(
|
||||
messageId,
|
||||
params.kind,
|
||||
params.payload,
|
||||
byteCount,
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
heartbeat.sequence
|
||||
)
|
||||
this.settleRemoteAttachmentInRelayTransaction(
|
||||
params.dispatchId,
|
||||
params.settleRemoteOutcome
|
||||
)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
heartbeat.sequence
|
||||
) as FederationRelayItemRow
|
||||
}
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'relay_quota_exceeded',
|
||||
`Federated Dispatch ${params.dispatchId} has no relay capacity.`
|
||||
)
|
||||
}
|
||||
const latest = this.db
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(sequence), 0) AS sequence
|
||||
FROM federation_relay_items WHERE dispatch_id = ? AND direction = ?`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as { sequence: number }
|
||||
const sequence = latest.sequence + 1
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO federation_relay_items (
|
||||
dispatch_id, direction, sequence, message_id, kind, payload, byte_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
sequence,
|
||||
messageId,
|
||||
params.kind,
|
||||
params.payload,
|
||||
byteCount
|
||||
)
|
||||
if (params.remoteQuestion) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO remote_questions (message_id, dispatch_id)
|
||||
VALUES (?, ?)`
|
||||
)
|
||||
.run(messageId, params.dispatchId)
|
||||
}
|
||||
this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
sequence
|
||||
) as FederationRelayItemRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type FederationRelayEnqueueMethods = {
|
||||
enqueueFederationRelay: typeof enqueueFederationRelay
|
||||
}
|
||||
|
||||
export function attachFederationRelayEnqueue(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
enqueueFederationRelay
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
MessageType,
|
||||
MessagePriority,
|
||||
MessageRow,
|
||||
WorkerReportOutcome,
|
||||
WorkerReportSettlement
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function importFederatedRelayItem(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
sequence: number
|
||||
message: {
|
||||
id: string
|
||||
runId: string
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
body: string
|
||||
type: MessageType
|
||||
priority: MessagePriority
|
||||
threadId?: string
|
||||
payload?: string
|
||||
}
|
||||
lifecycle:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'heartbeat'; at: string }
|
||||
| {
|
||||
kind: 'worker_report'
|
||||
taskId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
| { kind: 'rejected'; code: string; reason: string }
|
||||
}
|
||||
): {
|
||||
message: MessageRow
|
||||
duplicate: boolean
|
||||
lifecycle?: WorkerReportSettlement | { action: 'rejected'; code: string; reason: string }
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const federated = this.getFederatedDispatch(params.dispatchId)
|
||||
if (!federated) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Federated Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
const duplicate = params.sequence <= federated.to_home_imported_sequence
|
||||
if (duplicate && !this.getMessageById(params.message.id)) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Federated relay sequence ${params.sequence} was committed without its message.`
|
||||
)
|
||||
}
|
||||
if (!duplicate && params.sequence !== federated.to_home_imported_sequence + 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Federated relay for ${params.dispatchId} is not contiguous after sequence ${federated.to_home_imported_sequence}.`
|
||||
)
|
||||
}
|
||||
|
||||
let message = this.getMessageById(params.message.id)
|
||||
if (!message) {
|
||||
message = this.insertMessage(params.message)
|
||||
} else if (
|
||||
message.run_id !== params.message.runId ||
|
||||
message.to_handle !== params.message.to ||
|
||||
message.type !== params.message.type
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federated relay message ${params.message.id} conflicts with an existing message.`
|
||||
)
|
||||
}
|
||||
if (message.type === 'question') {
|
||||
this.registerFederatedQuestion({
|
||||
messageId: message.id,
|
||||
runId: params.message.runId,
|
||||
dispatchId: params.dispatchId
|
||||
})
|
||||
}
|
||||
let lifecycle:
|
||||
| WorkerReportSettlement
|
||||
| { action: 'rejected'; code: string; reason: string }
|
||||
| undefined
|
||||
if (params.lifecycle.kind === 'heartbeat' && !duplicate) {
|
||||
this.recordHeartbeat(params.dispatchId, params.lifecycle.at)
|
||||
} else if (params.lifecycle.kind === 'worker_report') {
|
||||
lifecycle = this.settleWorkerReportInTransaction({
|
||||
taskId: params.lifecycle.taskId,
|
||||
dispatchId: params.dispatchId,
|
||||
outcome: params.lifecycle.outcome,
|
||||
result: params.lifecycle.result
|
||||
})
|
||||
if (lifecycle.action === 'rejected' && !duplicate) {
|
||||
message = this.convertLifecycleMessageToRejection(
|
||||
message.id,
|
||||
lifecycle.code,
|
||||
lifecycle.reason
|
||||
) as MessageRow
|
||||
}
|
||||
} else if (params.lifecycle.kind === 'rejected') {
|
||||
lifecycle = {
|
||||
action: 'rejected',
|
||||
code: params.lifecycle.code,
|
||||
reason: params.lifecycle.reason
|
||||
}
|
||||
if (!duplicate) {
|
||||
message = this.convertLifecycleMessageToRejection(
|
||||
message.id,
|
||||
params.lifecycle.code,
|
||||
params.lifecycle.reason
|
||||
) as MessageRow
|
||||
}
|
||||
}
|
||||
if (!duplicate) {
|
||||
this.setFederatedHomeImportSequence(params.dispatchId, params.sequence)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { message, duplicate, ...(lifecycle ? { lifecycle } : {}) }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type FederationRelayImportMethods = {
|
||||
importFederatedRelayItem: typeof importFederatedRelayItem
|
||||
}
|
||||
|
||||
export function attachFederationRelayImport(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
importFederatedRelayItem
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
WorkerReportOutcome,
|
||||
FederationRelayDirection,
|
||||
FederationRelayItemRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getFederationRelayItem(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
direction: FederationRelayDirection,
|
||||
sequence: number
|
||||
): FederationRelayItemRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
|
||||
)
|
||||
.get(dispatchId, direction, sequence) as FederationRelayItemRow | undefined
|
||||
}
|
||||
|
||||
export function settleRemoteAttachmentInRelayTransaction(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
outcome: WorkerReportOutcome | undefined,
|
||||
stage = 'worker_report_queued'
|
||||
): void {
|
||||
if (!outcome) {
|
||||
return
|
||||
}
|
||||
const attachment = this.getRemoteDispatchAttachment(dispatchId)
|
||||
const state = outcome === 'succeeded' ? 'succeeded' : 'failed'
|
||||
if (!attachment) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
if (attachment.state === state) {
|
||||
return
|
||||
}
|
||||
if (attachment.state !== 'ready') {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Remote Dispatch ${dispatchId} cannot settle as ${state} from ${attachment.state}.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = ?, stage = ?, capability_hash = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'ready'`
|
||||
)
|
||||
.run(state, stage, dispatchId)
|
||||
}
|
||||
|
||||
export type FederationRelayItemMethods = {
|
||||
getFederationRelayItem: typeof getFederationRelayItem
|
||||
settleRemoteAttachmentInRelayTransaction: typeof settleRemoteAttachmentInRelayTransaction
|
||||
}
|
||||
|
||||
export function attachFederationRelayItem(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getFederationRelayItem,
|
||||
settleRemoteAttachmentInRelayTransaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { RemoteDispatchAttachmentRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { hashDispatchCapability } from '../dispatch-capability-hash'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function prepareRemoteAttachmentAuthority(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
setupState: string
|
||||
effects: unknown[]
|
||||
}
|
||||
): string {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!attachment || attachment.state !== 'starting') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${params.dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
const capability = `dcap_${randomBytes(32).toString('base64url')}`
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET stage = 'authority_attached', capability_hash = ?, pane_key = ?,
|
||||
process_incarnation = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?,
|
||||
effects = ?, residual_resources = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(
|
||||
hashDispatchCapability(capability),
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
params.worktreeId,
|
||||
params.terminalHandle,
|
||||
params.setupState,
|
||||
JSON.stringify(params.effects),
|
||||
JSON.stringify(
|
||||
params.effects.filter((effect) =>
|
||||
Boolean(
|
||||
effect &&
|
||||
typeof effect === 'object' &&
|
||||
((effect as { action?: string }).action?.startsWith('created') ||
|
||||
(effect as { action?: string }).action === 'reused_agent_terminal')
|
||||
)
|
||||
)
|
||||
),
|
||||
params.dispatchId
|
||||
)
|
||||
return capability
|
||||
}
|
||||
|
||||
export function markRemoteAttachmentReady(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
effects?: unknown[]
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'ready', stage = 'input_accepted',
|
||||
effects = COALESCE(?, effects), updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(effects ? JSON.stringify(effects) : null, dispatchId)
|
||||
if (result.changes !== 1) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function failRemoteAttachment(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
stage: string,
|
||||
reason: string,
|
||||
unknown: boolean
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const state = unknown ? 'start_unknown' : 'failed'
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = ?, stage = ?, last_error = ?, capability_hash = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(state, stage, reason, dispatchId)
|
||||
if (result.changes !== 1) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function verifyRemoteAttachmentAuthority(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
capability: string | undefined
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
}
|
||||
): boolean {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (
|
||||
!attachment?.capability_hash ||
|
||||
!params.capability ||
|
||||
!attachment.pane_key ||
|
||||
!params.paneKey ||
|
||||
!isEquivalentPaneKey(attachment.pane_key, params.paneKey) ||
|
||||
!attachment.process_incarnation ||
|
||||
attachment.process_incarnation !== params.processIncarnation
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const expected = Buffer.from(attachment.capability_hash, 'hex')
|
||||
const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex')
|
||||
return expected.length === observed.length && timingSafeEqual(expected, observed)
|
||||
}
|
||||
|
||||
export function isRemoteAttachmentProcessCurrent(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
}
|
||||
): boolean {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
return Boolean(
|
||||
attachment?.pane_key &&
|
||||
params.paneKey &&
|
||||
isEquivalentPaneKey(attachment.pane_key, params.paneKey) &&
|
||||
attachment.process_incarnation &&
|
||||
attachment.process_incarnation === params.processIncarnation
|
||||
)
|
||||
}
|
||||
|
||||
export type RemoteDispatchAttachmentAuthorityMethods = {
|
||||
prepareRemoteAttachmentAuthority: typeof prepareRemoteAttachmentAuthority
|
||||
markRemoteAttachmentReady: typeof markRemoteAttachmentReady
|
||||
failRemoteAttachment: typeof failRemoteAttachment
|
||||
verifyRemoteAttachmentAuthority: typeof verifyRemoteAttachmentAuthority
|
||||
isRemoteAttachmentProcessCurrent: typeof isRemoteAttachmentProcessCurrent
|
||||
}
|
||||
|
||||
export function attachRemoteDispatchAttachmentAuthority(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
prepareRemoteAttachmentAuthority,
|
||||
markRemoteAttachmentReady,
|
||||
failRemoteAttachment,
|
||||
verifyRemoteAttachmentAuthority,
|
||||
isRemoteAttachmentProcessCurrent
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { WorkerDispatchState, RemoteDispatchAttachmentRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function createRemoteDispatchAttachment(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
taskId: string
|
||||
homePeerFingerprint: string
|
||||
protocolVersion: number
|
||||
runtimeEpoch: string
|
||||
mutationReceipt: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
}
|
||||
): RemoteDispatchAttachmentRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (params.homePeerFingerprint !== params.mutationReceipt.callerFingerprint) {
|
||||
throw new OrchestrationError(
|
||||
'resource_server_mismatch',
|
||||
'The authenticated Run-home peer does not match the attachment request.'
|
||||
)
|
||||
}
|
||||
const existingReceipt = this.getMutationReceipt(
|
||||
params.mutationReceipt.callerFingerprint,
|
||||
params.mutationReceipt.requestId
|
||||
)
|
||||
if (existingReceipt) {
|
||||
throw new OrchestrationError(
|
||||
existingReceipt.method === params.mutationReceipt.method &&
|
||||
existingReceipt.payload_hash === params.mutationReceipt.payloadHash
|
||||
? 'operation_unknown'
|
||||
: 'request_mismatch',
|
||||
`Remote attachment request ${params.mutationReceipt.requestId} already exists.`
|
||||
)
|
||||
}
|
||||
ensureMutationReceiptCapacity(this.db)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state, receipt
|
||||
) VALUES (?, ?, ?, ?, 'pending', ?)`
|
||||
)
|
||||
.run(
|
||||
params.mutationReceipt.callerFingerprint,
|
||||
params.mutationReceipt.requestId,
|
||||
params.mutationReceipt.method,
|
||||
params.mutationReceipt.payloadHash,
|
||||
JSON.stringify({ accepted: { dispatchId: params.dispatchId } })
|
||||
)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO remote_dispatch_attachments (
|
||||
dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch
|
||||
) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.dispatchId,
|
||||
params.taskId,
|
||||
params.homePeerFingerprint,
|
||||
params.protocolVersion,
|
||||
params.runtimeEpoch
|
||||
)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getRemoteDispatchAttachment(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): RemoteDispatchAttachmentRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM remote_dispatch_attachments WHERE dispatch_id = ?')
|
||||
.get(dispatchId) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
|
||||
export function recordRemoteAttachmentStage(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
stage: string
|
||||
state?: WorkerDispatchState
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setupState?: string
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
lastError?: string
|
||||
}
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const current = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!current) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET stage = ?, state = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?,
|
||||
effects = ?, residual_resources = ?, last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(
|
||||
params.stage,
|
||||
params.state ?? current.state,
|
||||
params.worktreeId ?? current.worktree_id,
|
||||
params.terminalHandle ?? current.terminal_handle,
|
||||
params.setupState ?? current.setup_state,
|
||||
params.effects ? JSON.stringify(params.effects) : current.effects,
|
||||
params.residualResources
|
||||
? JSON.stringify(params.residualResources)
|
||||
: current.residual_resources,
|
||||
params.lastError ?? current.last_error,
|
||||
params.dispatchId
|
||||
)
|
||||
return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function updateRemoteAttachmentSetupEvidence(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
setupState: string
|
||||
effects: unknown[]
|
||||
}
|
||||
): { attachment: RemoteDispatchAttachmentRow; changed: boolean } {
|
||||
const current = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!current) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
const effects = JSON.stringify(params.effects)
|
||||
if (current.setup_state === params.setupState && current.effects === effects) {
|
||||
return { attachment: current, changed: false }
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET setup_state = ?, effects = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(params.setupState, effects, params.dispatchId)
|
||||
return {
|
||||
attachment: this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow,
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
export type RemoteDispatchAttachmentCreateMethods = {
|
||||
createRemoteDispatchAttachment: typeof createRemoteDispatchAttachment
|
||||
getRemoteDispatchAttachment: typeof getRemoteDispatchAttachment
|
||||
recordRemoteAttachmentStage: typeof recordRemoteAttachmentStage
|
||||
updateRemoteAttachmentSetupEvidence: typeof updateRemoteAttachmentSetupEvidence
|
||||
}
|
||||
|
||||
export function attachRemoteDispatchAttachmentCreate(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createRemoteDispatchAttachment,
|
||||
getRemoteDispatchAttachment,
|
||||
recordRemoteAttachmentStage,
|
||||
updateRemoteAttachmentSetupEvidence
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import type { RemoteDispatchAttachmentRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { paneKeyMatchSuffix, REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function beginRemoteAttachmentStop(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const attachment = this.getRemoteDispatchAttachment(dispatchId)
|
||||
if (!attachment) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(attachment.state)) {
|
||||
return attachment
|
||||
}
|
||||
if (!['ready', 'start_unknown'].includes(attachment.state)) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${dispatchId} cannot stop from ${attachment.state}.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'stopping', stage = 'stop_requested', capability_hash = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('ready', 'start_unknown')`
|
||||
)
|
||||
.run(dispatchId)
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function settleRemoteAttachmentStop(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): RemoteDispatchAttachmentRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'stopped', stage = 'process_stopped', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(dispatchId)
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function markRemoteAttachmentStopUnknown(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
reason: string
|
||||
): RemoteDispatchAttachmentRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'stop_unknown', stage = 'stop_outcome_unknown', last_error = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(reason, dispatchId)
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function findActiveRemoteAttachmentForPane(
|
||||
this: OrchestrationDb,
|
||||
paneKey: string
|
||||
): RemoteDispatchAttachmentRow | undefined {
|
||||
if (!parsePaneKey(paneKey)) {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM remote_dispatch_attachments
|
||||
WHERE state IN ('starting', 'ready') AND pane_key = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKey) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM remote_dispatch_attachments
|
||||
WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL
|
||||
AND instr(pane_key, ':') > 1
|
||||
AND ${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKeyMatchSuffix(paneKey)) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
|
||||
export type RemoteDispatchAttachmentStopMethods = {
|
||||
beginRemoteAttachmentStop: typeof beginRemoteAttachmentStop
|
||||
settleRemoteAttachmentStop: typeof settleRemoteAttachmentStop
|
||||
markRemoteAttachmentStopUnknown: typeof markRemoteAttachmentStopUnknown
|
||||
findActiveRemoteAttachmentForPane: typeof findActiveRemoteAttachmentForPane
|
||||
}
|
||||
|
||||
export function attachRemoteDispatchAttachmentStop(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
beginRemoteAttachmentStop,
|
||||
settleRemoteAttachmentStop,
|
||||
markRemoteAttachmentStopUnknown,
|
||||
findActiveRemoteAttachmentForPane
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getRemoteQuestion(
|
||||
this: OrchestrationDb,
|
||||
messageId: string
|
||||
):
|
||||
| {
|
||||
message_id: string
|
||||
dispatch_id: string
|
||||
status: 'pending' | 'answered' | 'closed'
|
||||
answer_message_id: string | null
|
||||
answer_body: string | null
|
||||
}
|
||||
| undefined {
|
||||
return this.db.prepare('SELECT * FROM remote_questions WHERE message_id = ?').get(messageId) as
|
||||
| {
|
||||
message_id: string
|
||||
dispatch_id: string
|
||||
status: 'pending' | 'answered' | 'closed'
|
||||
answer_message_id: string | null
|
||||
answer_body: string | null
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function answerRemoteQuestion(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
messageId: string
|
||||
dispatchId: string
|
||||
answerMessageId: string
|
||||
body: string
|
||||
}
|
||||
): void {
|
||||
const question = this.getRemoteQuestion(params.messageId)
|
||||
if (!question || question.dispatch_id !== params.dispatchId) {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Remote Question ${params.messageId} was not found.`
|
||||
)
|
||||
}
|
||||
if (question.status === 'answered') {
|
||||
if (
|
||||
question.answer_message_id !== params.answerMessageId ||
|
||||
question.answer_body !== params.body
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'answer_conflict',
|
||||
`Remote Question ${params.messageId} already has a different answer.`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_questions
|
||||
SET status = 'answered', answer_message_id = ?, answer_body = ?,
|
||||
answered_at = datetime('now')
|
||||
WHERE message_id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(params.answerMessageId, params.body, params.messageId)
|
||||
}
|
||||
|
||||
export function setRemoteWorkerImportSequence(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
sequence: number
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET to_worker_imported_sequence = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND to_worker_imported_sequence < ?`
|
||||
)
|
||||
.run(sequence, dispatchId, sequence)
|
||||
}
|
||||
|
||||
export function registerFederatedQuestion(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
messageId: string
|
||||
runId: string
|
||||
dispatchId: string
|
||||
}
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO question_threads (
|
||||
message_id, run_id, dispatch_id, asker_handle
|
||||
) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(params.messageId, params.runId, params.dispatchId, `dispatch:${params.dispatchId}`)
|
||||
}
|
||||
|
||||
export type RemoteQuestionStoreMethods = {
|
||||
getRemoteQuestion: typeof getRemoteQuestion
|
||||
answerRemoteQuestion: typeof answerRemoteQuestion
|
||||
setRemoteWorkerImportSequence: typeof setRemoteWorkerImportSequence
|
||||
registerFederatedQuestion: typeof registerFederatedQuestion
|
||||
}
|
||||
|
||||
export function attachRemoteQuestionStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getRemoteQuestion,
|
||||
answerRemoteQuestion,
|
||||
setRemoteWorkerImportSequence,
|
||||
registerFederatedQuestion
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export function generateId(prefix: string): string {
|
||||
return `${prefix}_${randomBytes(6).toString('hex')}`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MessageRow } from '../types'
|
||||
import {
|
||||
legacyMessageMatchesQuestion,
|
||||
normalizeLegacyQuestionOptions,
|
||||
normalizeLegacyQuestionText
|
||||
} from './legacy-question-identity'
|
||||
|
||||
function message(overrides: Partial<MessageRow> = {}): MessageRow {
|
||||
return {
|
||||
id: 'msg_1',
|
||||
run_id: 'run_1',
|
||||
delivery_contract: 'legacy_direct',
|
||||
from_handle: 'from',
|
||||
to_handle: 'to',
|
||||
subject: 'q',
|
||||
body: 'Ask this?\r\n',
|
||||
type: 'question',
|
||||
priority: 'normal',
|
||||
thread_id: null,
|
||||
payload: '{"options":[" yes ","no"]}',
|
||||
read: 0,
|
||||
sequence: 1,
|
||||
created_at: '2026-01-01 00:00:00',
|
||||
delivered_at: null,
|
||||
sender_pane_key: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy-question-identity', () => {
|
||||
it('normalizes question text and option arrays', () => {
|
||||
expect(normalizeLegacyQuestionText(' a\r\nb ')).toBe('a\nb')
|
||||
expect(normalizeLegacyQuestionOptions([' yes ', 'no'])).toBe('["yes","no"]')
|
||||
expect(normalizeLegacyQuestionOptions(['yes', 1])).toBe('[]')
|
||||
})
|
||||
|
||||
it('matches a question only when recipient, body, and options agree', () => {
|
||||
expect(legacyMessageMatchesQuestion(message(), 'Ask this?', ['yes', 'no'], ['to'])).toBe(true)
|
||||
expect(legacyMessageMatchesQuestion(message(), 'Ask this?', ['yes', 'no'], ['other'])).toBe(
|
||||
false
|
||||
)
|
||||
expect(legacyMessageMatchesQuestion(message(), 'Different', ['yes', 'no'], ['to'])).toBe(false)
|
||||
expect(
|
||||
legacyMessageMatchesQuestion(message({ payload: '{' }), 'Ask this?', ['yes'], ['to'])
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MessageRow } from '../types'
|
||||
|
||||
export function normalizeLegacyQuestionText(value: string): string {
|
||||
return value.replace(/\r\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
export function normalizeLegacyQuestionOptions(options: unknown): string {
|
||||
if (!Array.isArray(options) || !options.every((option) => typeof option === 'string')) {
|
||||
return '[]'
|
||||
}
|
||||
return JSON.stringify(options.map((option) => option.trim()))
|
||||
}
|
||||
|
||||
export function legacyMessageMatchesQuestion(
|
||||
message: MessageRow,
|
||||
question: string,
|
||||
options: string[],
|
||||
recipientHandles: readonly string[]
|
||||
): boolean {
|
||||
if (
|
||||
!recipientHandles.includes(message.to_handle) ||
|
||||
normalizeLegacyQuestionText(message.body) !== normalizeLegacyQuestionText(question)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(message.payload ?? '{}') as { options?: unknown }
|
||||
return (
|
||||
normalizeLegacyQuestionOptions(payload.options) === normalizeLegacyQuestionOptions(options)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { MessageRow, LegacyOperationReceiptRow, QuestionRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { legacyMessageMatchesQuestion } from '../legacy-question-identity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function commitLegacyAskOperation(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
question: string
|
||||
options?: string[]
|
||||
recipientHandle: string
|
||||
existingQuestionId?: string
|
||||
}
|
||||
): {
|
||||
receipt: LegacyOperationReceiptRow
|
||||
question: QuestionRow
|
||||
message: MessageRow
|
||||
duplicate: boolean
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'worker')
|
||||
const receipt = this.requireMatchingLegacyOperationReceipt(params)
|
||||
if (receipt) {
|
||||
const response = JSON.parse(receipt.response_json) as { questionId: string }
|
||||
const question = this.getQuestion(response.questionId)
|
||||
const message = this.getMessageById(response.questionId)
|
||||
if (!question || !message) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Legacy ask ${params.operationKey} lost its durable question.`
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, question, message, duplicate: true }
|
||||
}
|
||||
|
||||
const dispatchId = principal.dispatch_id as string
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== principal.run_id ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION ||
|
||||
!['pending', 'dispatched'].includes(dispatch.status)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is not an active legacy attempt.`
|
||||
)
|
||||
}
|
||||
|
||||
const existingQuestionId =
|
||||
params.existingQuestionId &&
|
||||
!this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM legacy_operation_receipts
|
||||
WHERE principal_id = ? AND method = 'orchestration.ask' AND effect_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(principal.id, params.existingQuestionId)
|
||||
? params.existingQuestionId
|
||||
: undefined
|
||||
let question: QuestionRow
|
||||
let message: MessageRow
|
||||
const delivery = this.resolveLegacyWorkerCoordinatorDelivery(
|
||||
principal.run_id,
|
||||
params.recipientHandle
|
||||
)
|
||||
if (existingQuestionId) {
|
||||
const existingQuestion = this.getQuestion(existingQuestionId)
|
||||
const existingMessage = this.getMessageById(existingQuestionId)
|
||||
if (
|
||||
!existingQuestion ||
|
||||
!existingMessage ||
|
||||
existingQuestion.run_id !== principal.run_id ||
|
||||
existingQuestion.dispatch_id !== dispatchId ||
|
||||
existingQuestion.status !== 'pending' ||
|
||||
existingMessage.delivery_contract !== delivery.contract ||
|
||||
!legacyMessageMatchesQuestion(existingMessage, params.question, params.options ?? [], [
|
||||
delivery.to
|
||||
])
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Question ${params.existingQuestionId} is not a pending ask for this principal.`
|
||||
)
|
||||
}
|
||||
question = existingQuestion
|
||||
message = existingMessage
|
||||
} else {
|
||||
message = this.insertMessage({
|
||||
from: principal.terminal_handle,
|
||||
to: delivery.to,
|
||||
subject: 'Question',
|
||||
body: params.question,
|
||||
type: delivery.contract === 'legacy_direct' ? 'decision_gate' : 'question',
|
||||
payload: JSON.stringify({
|
||||
taskId: dispatch.task_id,
|
||||
dispatchId,
|
||||
question: params.question,
|
||||
options: params.options ?? []
|
||||
}),
|
||||
senderPaneKey: principal.pane_key,
|
||||
runId: principal.run_id,
|
||||
deliveryContract: delivery.contract
|
||||
})
|
||||
this.db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(message.id, message.id)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO question_threads (
|
||||
message_id, run_id, dispatch_id, asker_handle
|
||||
) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(message.id, principal.run_id, dispatchId, principal.terminal_handle)
|
||||
question = this.getQuestion(message.id) as QuestionRow
|
||||
message = this.getMessageById(message.id) as MessageRow
|
||||
}
|
||||
|
||||
const committedReceipt = this.insertLegacyOperationReceipt({
|
||||
principalId: principal.id,
|
||||
operationKey: params.operationKey,
|
||||
method: params.method,
|
||||
payloadHash: params.payloadHash,
|
||||
effectId: question.message_id,
|
||||
responseJson: JSON.stringify({ questionId: question.message_id })
|
||||
})
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt: committedReceipt, question, message, duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyAskOperationMethods = {
|
||||
commitLegacyAskOperation: typeof commitLegacyAskOperation
|
||||
}
|
||||
|
||||
export function attachLegacyAskOperation(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
commitLegacyAskOperation
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
DispatchContextRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function resolveLegacyCompatibilityPrincipalByIdentity(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
role: LegacyPrincipalRole
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
}
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
if (!params.terminalHandle && !params.paneKey) {
|
||||
return undefined
|
||||
}
|
||||
const rows = (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? AND role = ? AND status IN ('committed', 'settled')
|
||||
ORDER BY rowid`
|
||||
)
|
||||
.all(params.runId, params.role) as LegacyCompatibilityPrincipalRow[]
|
||||
).filter((principal) =>
|
||||
params.paneKey
|
||||
? isEquivalentPaneKey(principal.pane_key, params.paneKey)
|
||||
: principal.terminal_handle === params.terminalHandle
|
||||
)
|
||||
if (rows.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple legacy principals match this process identity.'
|
||||
)
|
||||
}
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkerCandidate(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId?: string
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
dispatchId?: string
|
||||
taskId?: string
|
||||
}
|
||||
): { dispatch: DispatchContextRow } | undefined {
|
||||
if (!params.runId || (!params.terminalHandle && !params.paneKey)) {
|
||||
return undefined
|
||||
}
|
||||
const rows = (
|
||||
params.dispatchId
|
||||
? [this.getDispatchContextById(params.dispatchId)].filter(
|
||||
(row): row is DispatchContextRow => row !== undefined
|
||||
)
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND contract_version = ?
|
||||
AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid`
|
||||
)
|
||||
.all(params.runId, LEGACY_CONTRACT_VERSION) as DispatchContextRow[])
|
||||
).filter(
|
||||
(dispatch) =>
|
||||
dispatch.run_id === params.runId &&
|
||||
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
|
||||
(!params.taskId || dispatch.task_id === params.taskId) &&
|
||||
(params.paneKey
|
||||
? Boolean(
|
||||
dispatch.assignee_pane_key &&
|
||||
isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey)
|
||||
)
|
||||
: dispatch.assignee_handle === params.terminalHandle)
|
||||
)
|
||||
if (rows.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple active legacy Dispatches match this process identity.'
|
||||
)
|
||||
}
|
||||
if (params.dispatchId && rows.length === 0) {
|
||||
const target = this.getDispatchContextById(params.dispatchId)
|
||||
if (target?.contract_version === LEGACY_CONTRACT_VERSION) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
`Dispatch ${params.dispatchId} is retained but this process cannot prove ownership.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows[0] ? { dispatch: rows[0] } : undefined
|
||||
}
|
||||
|
||||
export function resolveLegacyCoordinatorCandidate(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
}
|
||||
): { terminalHandle: string; paneKey: string } | undefined {
|
||||
if (!params.terminalHandle || !params.paneKey) {
|
||||
return undefined
|
||||
}
|
||||
const run = this.getRunRaw(params.runId)
|
||||
const principal = this.getLegacyCoordinatorPrincipal(params.runId)
|
||||
if (principal) {
|
||||
if (
|
||||
principal.status !== 'committed' ||
|
||||
principal.terminal_handle !== params.terminalHandle ||
|
||||
!isEquivalentPaneKey(principal.pane_key, params.paneKey) ||
|
||||
(run?.coordinator_pane_key !== null &&
|
||||
(run?.coordinator_handle !== principal.terminal_handle ||
|
||||
!isEquivalentPaneKey(run.coordinator_pane_key, principal.pane_key)))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
|
||||
}
|
||||
// Why: the first current binding durably fences uncommitted legacy processes.
|
||||
if (
|
||||
!run ||
|
||||
run.coordinator_pane_key !== null ||
|
||||
this.getUniqueLegacyCoordinatorHandle(params.runId) !== params.terminalHandle
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
|
||||
}
|
||||
|
||||
// Why: caller-side fence jurisdiction. Widening this fences more callers, so keep it narrow.
|
||||
export function isLegacyCoordinatorHandle(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
terminalHandle: string
|
||||
): boolean {
|
||||
const principal = this.getLegacyCoordinatorPrincipal(runId)
|
||||
if (principal) {
|
||||
return principal.terminal_handle === terminalHandle
|
||||
}
|
||||
return this.getUniqueLegacyCoordinatorHandle(runId) === terminalHandle
|
||||
}
|
||||
|
||||
// Why: recipient-side permit for legacy lifecycle mail. After a takeover revokes the old principal
|
||||
// the replacement coordinator is only reachable through the Run binding.
|
||||
export function isLegacyCoordinatorDeliveryTarget(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
terminalHandle: string
|
||||
): boolean {
|
||||
if (this.isLegacyCoordinatorHandle(runId, terminalHandle)) {
|
||||
return true
|
||||
}
|
||||
if (this.getRunRaw(runId)?.coordinator_handle !== terminalHandle) {
|
||||
return false
|
||||
}
|
||||
// Why: must match resolveLegacyWorkerCoordinatorDelivery's takeover test. A still-committed
|
||||
// principal routes legacy_direct to this handle, and no reader can see that mailbox.
|
||||
return this.getLegacyCoordinatorPrincipal(runId)?.status !== 'committed'
|
||||
}
|
||||
|
||||
export type LegacyCompatibilityCandidatesMethods = {
|
||||
resolveLegacyCompatibilityPrincipalByIdentity: typeof resolveLegacyCompatibilityPrincipalByIdentity
|
||||
resolveLegacyWorkerCandidate: typeof resolveLegacyWorkerCandidate
|
||||
resolveLegacyCoordinatorCandidate: typeof resolveLegacyCoordinatorCandidate
|
||||
isLegacyCoordinatorHandle: typeof isLegacyCoordinatorHandle
|
||||
isLegacyCoordinatorDeliveryTarget: typeof isLegacyCoordinatorDeliveryTarget
|
||||
}
|
||||
|
||||
export function attachLegacyCompatibilityCandidates(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
resolveLegacyCompatibilityPrincipalByIdentity,
|
||||
resolveLegacyWorkerCandidate,
|
||||
resolveLegacyCoordinatorCandidate,
|
||||
isLegacyCoordinatorHandle,
|
||||
isLegacyCoordinatorDeliveryTarget
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
LegacyAdoptionRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_RUN_ID, LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Legacy adoption and compatibility principals ──
|
||||
|
||||
export function getLegacyAdoption(this: OrchestrationDb): LegacyAdoptionRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM legacy_adoptions WHERE source_run_id = ?')
|
||||
.get(LEGACY_RUN_ID) as LegacyAdoptionRow | undefined
|
||||
}
|
||||
|
||||
export function commitLegacyCompatibilityPrincipal(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
dispatchId?: string
|
||||
role: LegacyPrincipalRole
|
||||
hostScope: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
processIncarnation?: string
|
||||
}
|
||||
): { principal: LegacyCompatibilityPrincipalRow; duplicate: boolean } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const adoption = this.getLegacyAdoption()
|
||||
if (!adoption || adoption.adopted_run_id !== params.runId) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Run ${params.runId} is not the adopted legacy Run.`
|
||||
)
|
||||
}
|
||||
const dispatchId = params.role === 'worker' ? (params.dispatchId ?? null) : null
|
||||
let initialStatus: 'committed' | 'settled' = 'committed'
|
||||
if (params.role === 'worker') {
|
||||
const dispatch = dispatchId ? this.getDispatchContextById(dispatchId) : undefined
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== params.runId ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatchId ?? '(missing)'} is not a legacy attempt in this Run.`
|
||||
)
|
||||
}
|
||||
initialStatus = ['pending', 'dispatched'].includes(dispatch.status) ? 'committed' : 'settled'
|
||||
} else if (params.dispatchId) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'A coordinator compatibility principal cannot name a Dispatch.'
|
||||
)
|
||||
}
|
||||
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE role = ? AND run_id = ? AND dispatch_id IS ?`
|
||||
)
|
||||
.get(params.role, params.runId, dispatchId) as LegacyCompatibilityPrincipalRow | undefined
|
||||
if (existing) {
|
||||
const same =
|
||||
existing.host_scope === params.hostScope &&
|
||||
existing.terminal_handle === params.terminalHandle &&
|
||||
existing.pane_key === params.paneKey &&
|
||||
existing.launch_token_hash === params.launchTokenHash &&
|
||||
existing.process_incarnation === (params.processIncarnation ?? null)
|
||||
if (!same) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`The ${params.role} compatibility principal is already committed to different proof.`
|
||||
)
|
||||
}
|
||||
if (existing.status === 'revoked') {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
`The ${params.role} compatibility principal has been revoked. No effects were applied.`,
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { principal: existing, duplicate: true }
|
||||
}
|
||||
if (
|
||||
params.role === 'coordinator' &&
|
||||
!this.resolveLegacyCoordinatorCandidate({
|
||||
runId: params.runId,
|
||||
terminalHandle: params.terminalHandle,
|
||||
paneKey: params.paneKey
|
||||
})
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator no longer has lifecycle authority. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
|
||||
const id = generateId('legacy_principal')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO legacy_compatibility_principals (
|
||||
id, run_id, dispatch_id, role, host_scope, terminal_handle,
|
||||
pane_key, launch_token_hash, process_incarnation, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
params.runId,
|
||||
dispatchId,
|
||||
params.role,
|
||||
params.hostScope,
|
||||
params.terminalHandle,
|
||||
params.paneKey,
|
||||
params.launchTokenHash,
|
||||
params.processIncarnation ?? null,
|
||||
initialStatus
|
||||
)
|
||||
const principal = this.getLegacyCompatibilityPrincipal(id) as LegacyCompatibilityPrincipalRow
|
||||
if (principal.status === 'committed') {
|
||||
this.initializeLegacyRecoveryCohort(principal)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { principal, duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getLegacyCompatibilityPrincipal(
|
||||
this: OrchestrationDb,
|
||||
id: string
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM legacy_compatibility_principals WHERE id = ?').get(id) as
|
||||
| LegacyCompatibilityPrincipalRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function listLegacyCompatibilityPrincipals(
|
||||
this: OrchestrationDb,
|
||||
runId: string
|
||||
): LegacyCompatibilityPrincipalRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? ORDER BY rowid`
|
||||
)
|
||||
.all(runId) as LegacyCompatibilityPrincipalRow[]
|
||||
}
|
||||
|
||||
export function getLegacyCoordinatorPrincipal(
|
||||
this: OrchestrationDb,
|
||||
runId: string
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? AND role = 'coordinator'`
|
||||
)
|
||||
.get(runId) as LegacyCompatibilityPrincipalRow | undefined
|
||||
}
|
||||
|
||||
export type LegacyCompatibilityPrincipalsMethods = {
|
||||
getLegacyAdoption: typeof getLegacyAdoption
|
||||
commitLegacyCompatibilityPrincipal: typeof commitLegacyCompatibilityPrincipal
|
||||
getLegacyCompatibilityPrincipal: typeof getLegacyCompatibilityPrincipal
|
||||
listLegacyCompatibilityPrincipals: typeof listLegacyCompatibilityPrincipals
|
||||
getLegacyCoordinatorPrincipal: typeof getLegacyCoordinatorPrincipal
|
||||
}
|
||||
|
||||
export function attachLegacyCompatibilityPrincipals(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getLegacyAdoption,
|
||||
commitLegacyCompatibilityPrincipal,
|
||||
getLegacyCompatibilityPrincipal,
|
||||
listLegacyCompatibilityPrincipals,
|
||||
getLegacyCoordinatorPrincipal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
MessageType,
|
||||
MessagePriority,
|
||||
MessageRow,
|
||||
WorkerReportOutcome,
|
||||
WorkerReportSettlement,
|
||||
LegacyOperationReceiptRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function commitLegacyLifecycleOperation(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
message: {
|
||||
existingId?: string
|
||||
to: string
|
||||
subject: string
|
||||
body?: string
|
||||
type: MessageType
|
||||
priority?: MessagePriority
|
||||
payload?: string
|
||||
}
|
||||
lifecycle:
|
||||
| { kind: 'message_only' }
|
||||
| { kind: 'heartbeat'; at: string }
|
||||
| {
|
||||
kind: 'worker_report'
|
||||
taskId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
}
|
||||
): {
|
||||
receipt: LegacyOperationReceiptRow
|
||||
message: MessageRow
|
||||
settlement?: WorkerReportSettlement
|
||||
duplicate: boolean
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.getLegacyCompatibilityPrincipal(params.principalId)
|
||||
if (
|
||||
!principal ||
|
||||
principal.role !== 'worker' ||
|
||||
!['committed', 'settled'].includes(principal.status)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Legacy compatibility principal ${params.principalId} cannot send lifecycle work.`
|
||||
)
|
||||
}
|
||||
const dispatchId = principal.dispatch_id as string
|
||||
const existingReceipt = this.requireMatchingLegacyOperationReceipt(params)
|
||||
if (existingReceipt) {
|
||||
const response = JSON.parse(existingReceipt.response_json) as {
|
||||
messageId: string
|
||||
settlement?: WorkerReportSettlement
|
||||
}
|
||||
const message = this.getMessageById(response.messageId)
|
||||
if (!message) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Legacy operation ${params.operationKey} lost its recorded message.`
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
receipt: existingReceipt,
|
||||
message,
|
||||
settlement: response.settlement,
|
||||
duplicate: true
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== principal.run_id ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is not this principal's legacy attempt.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
(principal.status === 'settled' || !['pending', 'dispatched'].includes(dispatch.status)) &&
|
||||
(!params.message.existingId || params.lifecycle.kind !== 'worker_report')
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is settled and only matching completion reconstruction is allowed.`
|
||||
)
|
||||
}
|
||||
let message = params.message.existingId
|
||||
? this.getMessageById(params.message.existingId)
|
||||
: undefined
|
||||
const delivery = this.resolveLegacyWorkerCoordinatorDelivery(
|
||||
principal.run_id,
|
||||
params.message.to
|
||||
)
|
||||
if (params.message.existingId) {
|
||||
const matchesOriginalLegacyRoute =
|
||||
message?.delivery_contract === 'legacy_direct' && message.to_handle === params.message.to
|
||||
const matchesCurrentRoute =
|
||||
message?.delivery_contract === delivery.contract && message.to_handle === delivery.to
|
||||
if (
|
||||
!message ||
|
||||
message.run_id !== principal.run_id ||
|
||||
message.from_handle !== principal.terminal_handle ||
|
||||
(!matchesOriginalLegacyRoute && !matchesCurrentRoute)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Existing legacy message ${params.message.existingId} does not match this principal.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
message = this.insertMessage({
|
||||
from: principal.terminal_handle,
|
||||
to: delivery.to,
|
||||
subject: params.message.subject,
|
||||
body: params.message.body,
|
||||
type: params.message.type,
|
||||
priority: params.message.priority,
|
||||
payload: params.message.payload,
|
||||
senderPaneKey: principal.pane_key,
|
||||
runId: principal.run_id,
|
||||
deliveryContract: delivery.contract
|
||||
})
|
||||
}
|
||||
|
||||
let settlement: WorkerReportSettlement | undefined
|
||||
if (params.lifecycle.kind === 'heartbeat') {
|
||||
this.recordHeartbeat(dispatchId, params.lifecycle.at)
|
||||
} else if (params.lifecycle.kind === 'worker_report') {
|
||||
const persistedOutcome =
|
||||
params.message.existingId &&
|
||||
dispatch.task_id === params.lifecycle.taskId &&
|
||||
dispatch.status === 'completed'
|
||||
? 'succeeded'
|
||||
: params.message.existingId &&
|
||||
dispatch.task_id === params.lifecycle.taskId &&
|
||||
dispatch.status === 'failed'
|
||||
? 'failed'
|
||||
: undefined
|
||||
settlement = persistedOutcome
|
||||
? { action: 'settled', outcome: persistedOutcome, duplicate: true }
|
||||
: this.settleWorkerReportInTransaction({
|
||||
taskId: params.lifecycle.taskId,
|
||||
dispatchId,
|
||||
outcome: params.lifecycle.outcome,
|
||||
result: params.lifecycle.result
|
||||
})
|
||||
if (settlement.action === 'rejected') {
|
||||
throw new OrchestrationError(settlement.code, settlement.reason)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE legacy_compatibility_principals
|
||||
SET status = 'settled' WHERE id = ? AND status = 'committed'`
|
||||
)
|
||||
.run(principal.id)
|
||||
}
|
||||
const responseJson = JSON.stringify({ messageId: message.id, settlement })
|
||||
const receipt = this.insertLegacyOperationReceipt({
|
||||
principalId: principal.id,
|
||||
operationKey: params.operationKey,
|
||||
method: params.method,
|
||||
payloadHash: params.payloadHash,
|
||||
effectId: message.id,
|
||||
responseJson
|
||||
})
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, message, settlement, duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyLifecycleOperationMethods = {
|
||||
commitLegacyLifecycleOperation: typeof commitLegacyLifecycleOperation
|
||||
}
|
||||
|
||||
export function attachLegacyLifecycleOperation(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
commitLegacyLifecycleOperation
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { MessageType, MessageRow, LegacyMailReceiptRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function acknowledgeLegacyMail(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
messageIds: string[]
|
||||
types?: MessageType[]
|
||||
}
|
||||
): {
|
||||
receipts: LegacyMailReceiptRow[]
|
||||
duplicate: boolean
|
||||
} {
|
||||
if (params.messageIds.length === 0) {
|
||||
return { receipts: [], duplicate: true }
|
||||
}
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const uniqueIds = [...new Set(params.messageIds)]
|
||||
const placeholders = uniqueIds.map(() => '?').join(',')
|
||||
const prior = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id IN (${placeholders})
|
||||
AND acknowledged_at IS NOT NULL`
|
||||
)
|
||||
.get(params.principalId, ...uniqueIds) as { count: number }
|
||||
if (prior.count !== uniqueIds.length) {
|
||||
const actionable = this.getLegacyMailPage({
|
||||
principalId: params.principalId,
|
||||
limit: uniqueIds.length,
|
||||
types: params.types
|
||||
}).messages
|
||||
if (
|
||||
actionable.length !== uniqueIds.length ||
|
||||
actionable.some((message, index) => message.id !== uniqueIds[index])
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'Legacy mail acknowledgment does not match the current replay page.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE id IN (${placeholders}) AND run_id = ?
|
||||
AND delivery_contract = 'legacy_direct'`
|
||||
)
|
||||
.all(...uniqueIds, principal.run_id) as MessageRow[]
|
||||
const validIds = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(message) =>
|
||||
message.to_handle === principal.terminal_handle ||
|
||||
(principal.role === 'worker' &&
|
||||
message.to_handle === `dispatch:${principal.dispatch_id}`)
|
||||
)
|
||||
.map((message) => message.id)
|
||||
)
|
||||
if (validIds.size !== uniqueIds.length || uniqueIds.some((id) => !validIds.has(id))) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'Legacy mail acknowledgment contains a message outside this principal inbox.'
|
||||
)
|
||||
}
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))
|
||||
WHERE id IN (${placeholders})`
|
||||
)
|
||||
.run(...uniqueIds)
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(principal_id, message_id)
|
||||
DO UPDATE SET acknowledged_at = COALESCE(
|
||||
legacy_mail_receipts.acknowledged_at, excluded.acknowledged_at
|
||||
)`
|
||||
)
|
||||
for (const messageId of uniqueIds) {
|
||||
insert.run(params.principalId, messageId)
|
||||
}
|
||||
const receipts = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id IN (${placeholders})
|
||||
ORDER BY message_id`
|
||||
)
|
||||
.all(params.principalId, ...uniqueIds) as LegacyMailReceiptRow[]
|
||||
this.db.exec('COMMIT')
|
||||
return { receipts, duplicate: prior.count === uniqueIds.length }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyMailAcknowledgeMethods = {
|
||||
acknowledgeLegacyMail: typeof acknowledgeLegacyMail
|
||||
}
|
||||
|
||||
export function attachLegacyMailAcknowledge(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
acknowledgeLegacyMail
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { LegacyMailReceiptRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function acknowledgeLegacyQuestionAnswer(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
questionId: string
|
||||
answerMessageId: string
|
||||
}
|
||||
): { receipt: LegacyMailReceiptRow; duplicate: boolean } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId, 'worker')
|
||||
const question = this.getQuestionRaw(params.questionId)
|
||||
const source = this.getMessageById(params.questionId)
|
||||
const answer = this.getMessageById(params.answerMessageId)
|
||||
const dispatch = principal.dispatch_id
|
||||
? this.getDispatchContextById(principal.dispatch_id)
|
||||
: undefined
|
||||
const exactLegacyAnswer =
|
||||
answer?.delivery_contract === 'legacy_direct' &&
|
||||
(answer.to_handle === principal.terminal_handle ||
|
||||
answer.to_handle === `dispatch:${principal.dispatch_id}`)
|
||||
const adoption = this.getLegacyAdoption()
|
||||
const exactTakenOverAnswer =
|
||||
adoption?.adopted_run_id === principal.run_id &&
|
||||
dispatch?.run_id === principal.run_id &&
|
||||
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
|
||||
source?.run_id === principal.run_id &&
|
||||
source.from_handle === principal.terminal_handle &&
|
||||
source.to_handle === `run:${principal.run_id}` &&
|
||||
source.delivery_contract === 'current_delivery' &&
|
||||
answer?.run_id === principal.run_id &&
|
||||
answer?.delivery_contract === 'current_delivery' &&
|
||||
answer.from_handle === `run:${principal.run_id}` &&
|
||||
answer.to_handle === `dispatch:${principal.dispatch_id}` &&
|
||||
answer.thread_id === question?.message_id
|
||||
if (
|
||||
!question ||
|
||||
!answer ||
|
||||
question.run_id !== principal.run_id ||
|
||||
question.dispatch_id !== principal.dispatch_id ||
|
||||
question.answer_message_id !== params.answerMessageId ||
|
||||
(!exactLegacyAnswer && !exactTakenOverAnswer)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'Legacy answer acknowledgment does not match this principal question.'
|
||||
)
|
||||
}
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id = ?`
|
||||
)
|
||||
.get(params.principalId, params.answerMessageId) as LegacyMailReceiptRow | undefined
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(params.answerMessageId)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(principal_id, message_id)
|
||||
DO UPDATE SET acknowledged_at = COALESCE(
|
||||
legacy_mail_receipts.acknowledged_at, excluded.acknowledged_at
|
||||
)`
|
||||
)
|
||||
.run(params.principalId, params.answerMessageId)
|
||||
const receipt = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id = ?`
|
||||
)
|
||||
.get(params.principalId, params.answerMessageId) as LegacyMailReceiptRow
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, duplicate: Boolean(existing?.acknowledged_at) }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyQuestionAcknowledgeMethods = {
|
||||
acknowledgeLegacyQuestionAnswer: typeof acknowledgeLegacyQuestionAnswer
|
||||
}
|
||||
|
||||
export function attachLegacyQuestionAcknowledge(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
acknowledgeLegacyQuestionAnswer
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { MessageDeliveryContract, MessageRow, QuestionRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { exposeQuestionTimestamps } from '../utc-timestamp'
|
||||
import { legacyMessageMatchesQuestion } from '../legacy-question-identity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function findPendingLegacyQuestions(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
question: string
|
||||
options?: string[]
|
||||
recipientHandle: string
|
||||
}
|
||||
): { question: QuestionRow; message: MessageRow }[] {
|
||||
return this.findLegacyQuestionsBySemanticIdentity(params)
|
||||
.filter((row) => row.question.status === 'pending')
|
||||
.map(({ question, message }) => ({ question, message }))
|
||||
}
|
||||
|
||||
export function findLegacyQuestionsBySemanticIdentity(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
question: string
|
||||
options?: string[]
|
||||
recipientHandle: string
|
||||
}
|
||||
): {
|
||||
question: QuestionRow
|
||||
message: MessageRow
|
||||
answerAcknowledged: boolean
|
||||
claimedByOperation: boolean
|
||||
}[] {
|
||||
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'worker')
|
||||
const runAddress = `run:${principal.run_id}`
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT q.*, m.id AS source_message_id,
|
||||
EXISTS(
|
||||
SELECT 1 FROM legacy_operation_receipts lor
|
||||
WHERE lor.principal_id = ? AND lor.method = 'orchestration.ask'
|
||||
AND lor.effect_id = q.message_id
|
||||
) AS claimed_by_operation
|
||||
FROM question_threads q
|
||||
INNER JOIN messages m ON m.id = q.message_id
|
||||
WHERE q.run_id = ? AND q.dispatch_id = ?
|
||||
AND (
|
||||
(m.delivery_contract = 'legacy_direct' AND m.to_handle = ?) OR
|
||||
(m.delivery_contract = 'current_delivery' AND m.to_handle = ?)
|
||||
)
|
||||
ORDER BY m.sequence
|
||||
LIMIT 501`
|
||||
)
|
||||
.all(
|
||||
principal.id,
|
||||
principal.run_id,
|
||||
principal.dispatch_id,
|
||||
params.recipientHandle,
|
||||
runAddress
|
||||
) as (QuestionRow & {
|
||||
source_message_id: string
|
||||
claimed_by_operation: number
|
||||
})[]
|
||||
if (rows.length > 500) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Legacy ask identity is too ambiguous to reconstruct safely.'
|
||||
)
|
||||
}
|
||||
return rows
|
||||
.filter((row) => {
|
||||
const message = this.getMessageById(row.source_message_id)
|
||||
return Boolean(
|
||||
message &&
|
||||
legacyMessageMatchesQuestion(message, params.question, params.options ?? [], [
|
||||
params.recipientHandle,
|
||||
runAddress
|
||||
])
|
||||
)
|
||||
})
|
||||
.map((row) => ({
|
||||
question: exposeQuestionTimestamps(row),
|
||||
message: this.getMessageById(row.message_id) as MessageRow,
|
||||
claimedByOperation: row.claimed_by_operation === 1,
|
||||
answerAcknowledged: row.answer_message_id
|
||||
? Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id = ?
|
||||
AND acknowledged_at IS NOT NULL`
|
||||
)
|
||||
.get(principal.id, row.answer_message_id)
|
||||
)
|
||||
: false
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkerCoordinatorDelivery(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
retainedCoordinatorHandle: string
|
||||
): { to: string; contract: MessageDeliveryContract } {
|
||||
const run = this.getRunRaw(runId)
|
||||
const principal = this.getLegacyCoordinatorPrincipal(runId)
|
||||
const takenOver = run?.coordinator_handle !== null && principal?.status !== 'committed'
|
||||
return takenOver
|
||||
? { to: `run:${runId}`, contract: 'current_delivery' }
|
||||
: { to: retainedCoordinatorHandle, contract: 'legacy_direct' }
|
||||
}
|
||||
|
||||
export type LegacyQuestionLookupMethods = {
|
||||
findPendingLegacyQuestions: typeof findPendingLegacyQuestions
|
||||
findLegacyQuestionsBySemanticIdentity: typeof findLegacyQuestionsBySemanticIdentity
|
||||
resolveLegacyWorkerCoordinatorDelivery: typeof resolveLegacyWorkerCoordinatorDelivery
|
||||
}
|
||||
|
||||
export function attachLegacyQuestionLookup(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
findPendingLegacyQuestions,
|
||||
findLegacyQuestionsBySemanticIdentity,
|
||||
resolveLegacyWorkerCoordinatorDelivery
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { MessageType, MessageRow, LegacyCompatibilityPrincipalRow } from '../../types'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from '../messages/mailbox-routing-page'
|
||||
import { exposeMessageListTimestamps } from '../utc-timestamp'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function initializeLegacyRecoveryCohort(
|
||||
this: OrchestrationDb,
|
||||
principal: LegacyCompatibilityPrincipalRow
|
||||
): void {
|
||||
if (principal.role === 'worker') {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
)
|
||||
SELECT ?, m.id, NULL
|
||||
FROM messages m
|
||||
INNER JOIN dispatch_contexts d ON d.id = ?
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct' AND m.read = 1
|
||||
AND d.status IN ('pending', 'dispatched')
|
||||
AND m.created_at >= d.created_at
|
||||
AND (m.to_handle = ? OR m.to_handle = ?)`
|
||||
)
|
||||
.run(
|
||||
principal.id,
|
||||
principal.dispatch_id,
|
||||
principal.run_id,
|
||||
principal.terminal_handle,
|
||||
`dispatch:${principal.dispatch_id}`
|
||||
)
|
||||
return
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
)
|
||||
SELECT ?, m.id, NULL
|
||||
FROM messages m
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct' AND m.read = 1
|
||||
AND m.to_handle = ?
|
||||
AND EXISTS(
|
||||
SELECT 1 FROM dispatch_contexts d
|
||||
WHERE d.run_id = m.run_id
|
||||
AND d.contract_version = ?
|
||||
AND d.status IN ('pending', 'dispatched')
|
||||
AND m.created_at >= d.created_at
|
||||
AND (m.from_handle = d.assignee_handle OR m.from_handle = 'dispatch:' || d.id)
|
||||
)`
|
||||
)
|
||||
.run(principal.id, principal.run_id, principal.terminal_handle, LEGACY_CONTRACT_VERSION)
|
||||
}
|
||||
|
||||
export function getLegacyMailPage(
|
||||
this: OrchestrationDb,
|
||||
params: { principalId: string; limit?: number; types?: MessageType[] }
|
||||
): {
|
||||
messages: MessageRow[]
|
||||
recovery: boolean
|
||||
} {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const limit = Math.min(
|
||||
Math.max(params.limit ?? ORCHESTRATION_DELIVERY_BATCH_LIMIT, 1),
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT
|
||||
)
|
||||
const addressSql =
|
||||
principal.role === 'worker' ? '(m.to_handle = ? OR m.to_handle = ?)' : 'm.to_handle = ?'
|
||||
const addressParams =
|
||||
principal.role === 'worker'
|
||||
? [principal.terminal_handle, `dispatch:${principal.dispatch_id}`]
|
||||
: [principal.terminal_handle]
|
||||
const typeSql =
|
||||
params.types && params.types.length > 0
|
||||
? `AND m.type IN (${params.types.map(() => '?').join(',')})`
|
||||
: ''
|
||||
const typeParams = params.types ?? []
|
||||
const recovery = this.db
|
||||
.prepare(
|
||||
`SELECT m.*
|
||||
FROM legacy_mail_receipts r
|
||||
INNER JOIN messages m ON m.id = r.message_id
|
||||
WHERE r.principal_id = ? AND r.acknowledged_at IS NULL
|
||||
AND m.run_id = ? AND m.delivery_contract = 'legacy_direct'
|
||||
AND ${addressSql}
|
||||
${typeSql}
|
||||
ORDER BY m.sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
params.principalId,
|
||||
principal.run_id,
|
||||
...addressParams,
|
||||
...typeParams,
|
||||
limit
|
||||
) as MessageRow[]
|
||||
if (recovery.length > 0) {
|
||||
return { messages: exposeMessageListTimestamps(recovery), recovery: true }
|
||||
}
|
||||
|
||||
const unread = this.db
|
||||
.prepare(
|
||||
`SELECT m.*
|
||||
FROM messages m
|
||||
LEFT JOIN legacy_mail_receipts r
|
||||
ON r.principal_id = ? AND r.message_id = m.id
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct'
|
||||
AND m.read = 0 AND r.message_id IS NULL AND ${addressSql}
|
||||
${typeSql}
|
||||
ORDER BY m.sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
params.principalId,
|
||||
principal.run_id,
|
||||
...addressParams,
|
||||
...typeParams,
|
||||
limit
|
||||
) as MessageRow[]
|
||||
return { messages: exposeMessageListTimestamps(unread), recovery: false }
|
||||
}
|
||||
|
||||
export function getLegacyMailHistory(
|
||||
this: OrchestrationDb,
|
||||
params: { principalId: string; limit?: number; types?: MessageType[] }
|
||||
): {
|
||||
messages: MessageRow[]
|
||||
recovery: false
|
||||
} {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const limit = Math.min(Math.max(params.limit ?? 100, 1), 100)
|
||||
const addressSql =
|
||||
principal.role === 'worker' ? '(to_handle = ? OR to_handle = ?)' : 'to_handle = ?'
|
||||
const addressParams =
|
||||
principal.role === 'worker'
|
||||
? [principal.terminal_handle, `dispatch:${principal.dispatch_id}`]
|
||||
: [principal.terminal_handle]
|
||||
const typeSql =
|
||||
params.types && params.types.length > 0
|
||||
? `AND type IN (${params.types.map(() => '?').join(',')})`
|
||||
: ''
|
||||
const messages = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE run_id = ? AND delivery_contract = 'legacy_direct'
|
||||
AND ${addressSql} ${typeSql}
|
||||
ORDER BY sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(principal.run_id, ...addressParams, ...(params.types ?? []), limit) as MessageRow[]
|
||||
return { messages: exposeMessageListTimestamps(messages), recovery: false }
|
||||
}
|
||||
|
||||
export type LegacyRecoveryCohortMethods = {
|
||||
initializeLegacyRecoveryCohort: typeof initializeLegacyRecoveryCohort
|
||||
getLegacyMailPage: typeof getLegacyMailPage
|
||||
getLegacyMailHistory: typeof getLegacyMailHistory
|
||||
}
|
||||
|
||||
export function attachLegacyRecoveryCohort(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
initializeLegacyRecoveryCohort,
|
||||
getLegacyMailPage,
|
||||
getLegacyMailHistory
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { MessageRow, LegacyOperationReceiptRow, QuestionRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function commitLegacyReplyOperation(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
questionId: string
|
||||
body: string
|
||||
}
|
||||
): {
|
||||
receipt: LegacyOperationReceiptRow
|
||||
question: QuestionRow
|
||||
message: MessageRow
|
||||
duplicate: boolean
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'coordinator')
|
||||
const receipt = this.requireMatchingLegacyOperationReceipt(params)
|
||||
if (receipt) {
|
||||
const response = JSON.parse(receipt.response_json) as {
|
||||
questionId: string
|
||||
messageId: string
|
||||
}
|
||||
const question = this.getQuestion(response.questionId)
|
||||
const message = this.getMessageById(response.messageId)
|
||||
if (!question || !message) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Legacy reply ${params.operationKey} lost its durable effect.`
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, question, message, duplicate: true }
|
||||
}
|
||||
|
||||
const question = this.getQuestionRaw(params.questionId)
|
||||
const sourceMessage = this.getMessageById(params.questionId)
|
||||
const dispatch = question ? this.getDispatchContextById(question.dispatch_id) : undefined
|
||||
if (
|
||||
!question ||
|
||||
!sourceMessage ||
|
||||
!dispatch ||
|
||||
question.run_id !== principal.run_id ||
|
||||
sourceMessage.delivery_contract !== 'legacy_direct' ||
|
||||
dispatch.run_id !== principal.run_id ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION ||
|
||||
question.status === 'closed'
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Question ${params.questionId} is not actionable in the adopted Run.`
|
||||
)
|
||||
}
|
||||
let message: MessageRow
|
||||
if (question.status === 'answered') {
|
||||
if (question.answer_body !== params.body || !question.answer_message_id) {
|
||||
throw new OrchestrationError(
|
||||
'answer_conflict',
|
||||
`Question ${params.questionId} already has a different answer.`
|
||||
)
|
||||
}
|
||||
message = this.getMessageById(question.answer_message_id) as MessageRow
|
||||
if (
|
||||
!message ||
|
||||
message.run_id !== principal.run_id ||
|
||||
message.delivery_contract !== 'legacy_direct'
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Question ${params.questionId} lost its recorded answer message.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
message = this.insertMessage({
|
||||
from: principal.terminal_handle,
|
||||
to: question.asker_handle,
|
||||
subject: 'Re: Question',
|
||||
body: params.body,
|
||||
threadId: question.message_id,
|
||||
runId: principal.run_id,
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
this.markAsRead([question.message_id])
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE question_threads
|
||||
SET status = 'answered', answer_message_id = ?, answer_body = ?,
|
||||
answered_at = datetime('now')
|
||||
WHERE message_id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(message.id, params.body, question.message_id)
|
||||
}
|
||||
|
||||
const answered = this.getQuestion(params.questionId) as QuestionRow
|
||||
const committedReceipt = this.insertLegacyOperationReceipt({
|
||||
principalId: principal.id,
|
||||
operationKey: params.operationKey,
|
||||
method: params.method,
|
||||
payloadHash: params.payloadHash,
|
||||
effectId: message.id,
|
||||
responseJson: JSON.stringify({
|
||||
questionId: answered.message_id,
|
||||
messageId: message.id
|
||||
})
|
||||
})
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
receipt: committedReceipt,
|
||||
question: answered,
|
||||
message,
|
||||
duplicate: question.status === 'answered'
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function requireMatchingLegacyOperationReceipt(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
): LegacyOperationReceiptRow | undefined {
|
||||
const receipt = this.getLegacyOperationReceipt(params.principalId, params.operationKey)
|
||||
if (
|
||||
receipt &&
|
||||
(receipt.method !== params.method || receipt.payload_hash !== params.payloadHash)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Legacy operation ${params.operationKey} was already used with different input.`
|
||||
)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
export function insertLegacyOperationReceipt(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
effectId: string
|
||||
responseJson: string
|
||||
}
|
||||
): LegacyOperationReceiptRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO legacy_operation_receipts (
|
||||
principal_id, operation_key, method, payload_hash, effect_id, response_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.principalId,
|
||||
params.operationKey,
|
||||
params.method,
|
||||
params.payloadHash,
|
||||
params.effectId,
|
||||
params.responseJson
|
||||
)
|
||||
return this.getLegacyOperationReceipt(
|
||||
params.principalId,
|
||||
params.operationKey
|
||||
) as LegacyOperationReceiptRow
|
||||
}
|
||||
|
||||
export type LegacyReplyOperationMethods = {
|
||||
commitLegacyReplyOperation: typeof commitLegacyReplyOperation
|
||||
requireMatchingLegacyOperationReceipt: typeof requireMatchingLegacyOperationReceipt
|
||||
insertLegacyOperationReceipt: typeof insertLegacyOperationReceipt
|
||||
}
|
||||
|
||||
export function attachLegacyReplyOperation(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
commitLegacyReplyOperation,
|
||||
requireMatchingLegacyOperationReceipt,
|
||||
insertLegacyOperationReceipt
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
MessageRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole,
|
||||
LegacyOperationReceiptRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { exposeMessageTimestamps } from '../utc-timestamp'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function findLegacyWorkerCompletion(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
taskId: string
|
||||
recipientHandle: string
|
||||
subject: string
|
||||
body: string
|
||||
payload: string | null
|
||||
}
|
||||
): MessageRow | undefined {
|
||||
const principal = this.getLegacyCompatibilityPrincipal(params.principalId)
|
||||
if (!principal || principal.role !== 'worker' || !principal.dispatch_id) {
|
||||
throw new OrchestrationError('request_mismatch', 'Legacy worker principal was not found.')
|
||||
}
|
||||
const runAddress = `run:${principal.run_id}`
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE run_id = ?
|
||||
AND (
|
||||
(delivery_contract = 'legacy_direct' AND to_handle = ?) OR
|
||||
(delivery_contract = 'current_delivery' AND to_handle = ?)
|
||||
)
|
||||
AND from_handle = ? AND type = 'worker_done'
|
||||
AND subject = ? AND body = ? AND payload IS ?
|
||||
ORDER BY sequence`
|
||||
)
|
||||
.all(
|
||||
principal.run_id,
|
||||
params.recipientHandle,
|
||||
runAddress,
|
||||
principal.terminal_handle,
|
||||
params.subject,
|
||||
params.body,
|
||||
params.payload
|
||||
) as MessageRow[]
|
||||
const matches = rows.filter((message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message.payload ?? '{}') as {
|
||||
taskId?: unknown
|
||||
dispatchId?: unknown
|
||||
}
|
||||
return payload.taskId === params.taskId && payload.dispatchId === principal.dispatch_id
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (matches.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple matching legacy worker completions exist.'
|
||||
)
|
||||
}
|
||||
return matches[0] ? exposeMessageTimestamps(matches[0]) : undefined
|
||||
}
|
||||
|
||||
export function hasPendingCurrentDelivery(this: OrchestrationDb, runId: string): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages
|
||||
WHERE run_id = ? AND to_handle = ?
|
||||
AND delivery_contract = 'current_delivery' AND read = 0
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(runId, `run:${runId}`)
|
||||
)
|
||||
}
|
||||
|
||||
export function setLegacyCompatibilityPrincipalStatus(
|
||||
this: OrchestrationDb,
|
||||
id: string,
|
||||
status: 'settled' | 'revoked'
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE legacy_compatibility_principals
|
||||
SET status = ?
|
||||
WHERE id = ? AND status = 'committed'`
|
||||
)
|
||||
.run(status, id)
|
||||
return this.getLegacyCompatibilityPrincipal(id)
|
||||
}
|
||||
|
||||
export function getLegacyOperationReceipt(
|
||||
this: OrchestrationDb,
|
||||
principalId: string,
|
||||
operationKey: string
|
||||
): LegacyOperationReceiptRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_operation_receipts
|
||||
WHERE principal_id = ? AND operation_key = ?`
|
||||
)
|
||||
.get(principalId, operationKey) as LegacyOperationReceiptRow | undefined
|
||||
}
|
||||
|
||||
export function requireCommittedLegacyPrincipal(
|
||||
this: OrchestrationDb,
|
||||
principalId: string,
|
||||
role?: LegacyPrincipalRole
|
||||
): LegacyCompatibilityPrincipalRow {
|
||||
const principal = this.getLegacyCompatibilityPrincipal(principalId)
|
||||
if (!principal || principal.status !== 'committed' || (role && principal.role !== role)) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Legacy compatibility principal ${principalId} is not committed for this operation.`
|
||||
)
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
export function requireLegacyMailPrincipal(
|
||||
this: OrchestrationDb,
|
||||
principalId: string,
|
||||
role?: LegacyPrincipalRole
|
||||
): LegacyCompatibilityPrincipalRow {
|
||||
const principal = this.getLegacyCompatibilityPrincipal(principalId)
|
||||
if (
|
||||
!principal ||
|
||||
!['committed', 'settled'].includes(principal.status) ||
|
||||
(role && principal.role !== role)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Legacy compatibility principal ${principalId} cannot access retained mail.`
|
||||
)
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
export type LegacyWorkerCompletionMethods = {
|
||||
findLegacyWorkerCompletion: typeof findLegacyWorkerCompletion
|
||||
hasPendingCurrentDelivery: typeof hasPendingCurrentDelivery
|
||||
setLegacyCompatibilityPrincipalStatus: typeof setLegacyCompatibilityPrincipalStatus
|
||||
getLegacyOperationReceipt: typeof getLegacyOperationReceipt
|
||||
requireCommittedLegacyPrincipal: typeof requireCommittedLegacyPrincipal
|
||||
requireLegacyMailPrincipal: typeof requireLegacyMailPrincipal
|
||||
}
|
||||
|
||||
export function attachLegacyWorkerCompletion(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
findLegacyWorkerCompletion,
|
||||
hasPendingCurrentDelivery,
|
||||
setLegacyCompatibilityPrincipalStatus,
|
||||
getLegacyOperationReceipt,
|
||||
requireCommittedLegacyPrincipal,
|
||||
requireLegacyMailPrincipal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addLifecycleRejectionMarker,
|
||||
hasLifecycleRejectionMarker
|
||||
} from './lifecycle-rejection-marker'
|
||||
|
||||
describe('lifecycle-rejection-marker', () => {
|
||||
it('adds a marker onto an object payload and recognizes it', () => {
|
||||
const marked = addLifecycleRejectionMarker('{"keep":true}', 'rejected', 'not current')
|
||||
expect(JSON.parse(marked)).toMatchObject({
|
||||
keep: true,
|
||||
_orcaLifecycleRejection: { code: 'rejected', reason: 'not current' }
|
||||
})
|
||||
expect(hasLifecycleRejectionMarker(marked)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats missing, non-object, and incomplete markers as absent', () => {
|
||||
expect(hasLifecycleRejectionMarker(null)).toBe(false)
|
||||
expect(hasLifecycleRejectionMarker('[]')).toBe(false)
|
||||
expect(hasLifecycleRejectionMarker('{"_orcaLifecycleRejection":{"code":1}}')).toBe(false)
|
||||
expect(hasLifecycleRejectionMarker('not-json')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
export function addLifecycleRejectionMarker(
|
||||
payload: string | null,
|
||||
code: string,
|
||||
reason: string
|
||||
): string {
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
const value: unknown = payload ? JSON.parse(payload) : {}
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
parsed = value as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Authority reconciliation only reaches this path with object payloads.
|
||||
}
|
||||
return JSON.stringify({
|
||||
...parsed,
|
||||
_orcaLifecycleRejection: { code, reason }
|
||||
})
|
||||
}
|
||||
|
||||
export function hasLifecycleRejectionMarker(payload: string | null): boolean {
|
||||
try {
|
||||
const value: unknown = JSON.parse(payload ?? 'null')
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const marker = (value as Record<string, unknown>)._orcaLifecycleRejection
|
||||
return Boolean(
|
||||
marker &&
|
||||
typeof marker === 'object' &&
|
||||
!Array.isArray(marker) &&
|
||||
typeof (marker as Record<string, unknown>).code === 'string' &&
|
||||
typeof (marker as Record<string, unknown>).reason === 'string'
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { MessageType } from '../../types'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type MailboxRoutingPage } from './mailbox-routing-page'
|
||||
|
||||
export function hasUndeliveredDirectMessageForRun(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
directHandle: string
|
||||
): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages INDEXED BY idx_messages_undelivered_direct_run
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(runId, directHandle)
|
||||
)
|
||||
}
|
||||
|
||||
export function getLatestUnreadDirectMessageSequenceForRun(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
directHandle: string
|
||||
): number | undefined {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT sequence FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(runId, directHandle) as { sequence: number } | undefined
|
||||
return row?.sequence
|
||||
}
|
||||
|
||||
// Why: change mailbox ownership without changing unread or acknowledgment state.
|
||||
export function routeDirectMessagePage(
|
||||
this: OrchestrationDb,
|
||||
mailboxHandle: string,
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
throughSequence?: number,
|
||||
preserveActiveDispatchOwnership = false
|
||||
): MailboxRoutingPage {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?'
|
||||
const dispatchOwnershipClause = preserveActiveDispatchOwnership
|
||||
? ` AND NOT EXISTS (
|
||||
SELECT 1 FROM dispatch_contexts
|
||||
WHERE dispatch_contexts.run_id = messages.run_id
|
||||
AND dispatch_contexts.assignee_handle = messages.to_handle
|
||||
AND dispatch_contexts.status IN ('pending', 'dispatched')
|
||||
)`
|
||||
: ''
|
||||
const params: (string | number)[] = [runId, directHandle]
|
||||
if (throughSequence !== undefined) {
|
||||
params.push(throughSequence)
|
||||
}
|
||||
params.push(ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1)
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, type FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'${throughClause}${dispatchOwnershipClause}
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(...params) as { id: string; type: MessageType }[]
|
||||
const page = rows.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (page.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return { routedCount: 0, hasMore: false, types: [] }
|
||||
}
|
||||
const placeholders = page.map(() => '?').join(',')
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE messages INDEXED BY idx_messages_id SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery' AND id IN (${placeholders})`
|
||||
)
|
||||
.run(mailboxHandle, runId, directHandle, ...page.map((row) => row.id))
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
routedCount: Number(result.changes),
|
||||
hasMore: rows.length > ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
types: [...new Set(page.map((row) => row.type))]
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function routeUnreadDirectMessagesToRunMailbox(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
throughSequence?: number
|
||||
): MailboxRoutingPage {
|
||||
return this.routeDirectMessagePage(`run:${runId}`, runId, directHandle, throughSequence, true)
|
||||
}
|
||||
|
||||
export function routeUnreadDirectMessagesToDispatchMailbox(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
throughSequence?: number
|
||||
): MailboxRoutingPage {
|
||||
return this.routeDirectMessagePage(`dispatch:${dispatchId}`, runId, directHandle, throughSequence)
|
||||
}
|
||||
|
||||
export function routeUnreadDispatchMailboxToRunMailbox(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
runId: string,
|
||||
throughSequence?: number
|
||||
): MailboxRoutingPage {
|
||||
const dispatchMailbox = `dispatch:${dispatchId}`
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?'
|
||||
const params: (string | number)[] = [runId, dispatchMailbox]
|
||||
if (throughSequence !== undefined) {
|
||||
params.push(throughSequence)
|
||||
}
|
||||
params.push(ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1)
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, type FROM messages INDEXED BY idx_messages_unread_current_inbox
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'${throughClause}
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(...params) as { id: string; type: MessageType }[]
|
||||
const page = rows.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (page.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return { routedCount: 0, hasMore: false, types: [] }
|
||||
}
|
||||
const placeholders = page.map(() => '?').join(',')
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE messages INDEXED BY idx_messages_id SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
AND id IN (${placeholders})`
|
||||
)
|
||||
.run(`run:${runId}`, runId, dispatchMailbox, ...page.map((row) => row.id))
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
routedCount: Number(result.changes),
|
||||
hasMore: rows.length > ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
types: [...new Set(page.map((row) => row.type))]
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getLatestUnreadMessageSequence(
|
||||
this: OrchestrationDb,
|
||||
mailboxHandle: string
|
||||
): number | undefined {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT sequence FROM messages INDEXED BY idx_messages_unread_current_inbox
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(mailboxHandle) as { sequence: number } | undefined
|
||||
return row?.sequence
|
||||
}
|
||||
|
||||
export type DirectMailboxRoutingMethods = {
|
||||
hasUndeliveredDirectMessageForRun: typeof hasUndeliveredDirectMessageForRun
|
||||
getLatestUnreadDirectMessageSequenceForRun: typeof getLatestUnreadDirectMessageSequenceForRun
|
||||
routeDirectMessagePage: typeof routeDirectMessagePage
|
||||
routeUnreadDirectMessagesToRunMailbox: typeof routeUnreadDirectMessagesToRunMailbox
|
||||
routeUnreadDirectMessagesToDispatchMailbox: typeof routeUnreadDirectMessagesToDispatchMailbox
|
||||
routeUnreadDispatchMailboxToRunMailbox: typeof routeUnreadDispatchMailboxToRunMailbox
|
||||
getLatestUnreadMessageSequence: typeof getLatestUnreadMessageSequence
|
||||
}
|
||||
|
||||
export function attachDirectMailboxRouting(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
hasUndeliveredDirectMessageForRun,
|
||||
getLatestUnreadDirectMessageSequenceForRun,
|
||||
routeDirectMessagePage,
|
||||
routeUnreadDirectMessagesToRunMailbox,
|
||||
routeUnreadDirectMessagesToDispatchMailbox,
|
||||
routeUnreadDispatchMailboxToRunMailbox,
|
||||
getLatestUnreadMessageSequence
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import type { DispatchContextRow, MessageType } from '../../types'
|
||||
import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
import {
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
type ForeignDirectMailboxRoutingPage
|
||||
} from './mailbox-routing-page'
|
||||
|
||||
export function findActiveDispatchForDirectMessageOwner(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
directHandle: string,
|
||||
paneKey?: string
|
||||
): DispatchContextRow | undefined {
|
||||
const exact = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND assignee_handle = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(runId, directHandle) as DispatchContextRow | undefined
|
||||
if (exact || !paneKey || !parsePaneKey(paneKey)) {
|
||||
return exact
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(runId, paneKeyMatchSuffix(paneKey)) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export function routeForeignDirectMessagesToOwnedMailboxes(
|
||||
this: OrchestrationDb,
|
||||
directHandle: string,
|
||||
currentRunId?: string,
|
||||
paneKey?: string
|
||||
): ForeignDirectMailboxRoutingPage {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const runExclusion = currentRunId === undefined ? '' : ' AND candidate.run_id <> ?'
|
||||
const paneSuffix = paneKey && parsePaneKey(paneKey) ? paneKeyMatchSuffix(paneKey) : undefined
|
||||
const exclusionParams = currentRunId === undefined ? [] : [currentRunId]
|
||||
const branches = [
|
||||
`SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence
|
||||
FROM run_coordinator_handles AS coordinator
|
||||
JOIN runs AS owner_run ON owner_run.id = coordinator.run_id AND owner_run.legacy = 0
|
||||
JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run
|
||||
ON candidate.run_id = coordinator.run_id
|
||||
AND candidate.to_handle = coordinator.terminal_handle
|
||||
WHERE coordinator.terminal_handle = ?${runExclusion}
|
||||
AND candidate.read = 0 AND candidate.delivered_at IS NULL
|
||||
AND candidate.delivery_contract = 'current_delivery'`,
|
||||
`SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence
|
||||
FROM (
|
||||
SELECT direct_dispatch.run_id
|
||||
FROM dispatch_contexts AS direct_dispatch
|
||||
INDEXED BY idx_dispatch_active_assignee_handle
|
||||
JOIN runs AS owner_run
|
||||
ON owner_run.id = direct_dispatch.run_id AND owner_run.legacy = 0
|
||||
WHERE direct_dispatch.assignee_handle = ?
|
||||
AND direct_dispatch.status IN ('pending', 'dispatched')
|
||||
GROUP BY direct_dispatch.run_id
|
||||
) AS dispatch_owner
|
||||
JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run
|
||||
ON candidate.run_id = dispatch_owner.run_id AND candidate.to_handle = ?
|
||||
WHERE 1 = 1${runExclusion}
|
||||
AND candidate.read = 0 AND candidate.delivered_at IS NULL
|
||||
AND candidate.delivery_contract = 'current_delivery'`
|
||||
]
|
||||
const branchParams: (string | number)[][] = [
|
||||
[directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1],
|
||||
[directHandle, directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1]
|
||||
]
|
||||
if (paneSuffix !== undefined) {
|
||||
branches.push(
|
||||
`SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence
|
||||
FROM (
|
||||
SELECT pane_dispatch.run_id
|
||||
FROM dispatch_contexts AS pane_dispatch INDEXED BY idx_dispatch_assignee_pane_leaf
|
||||
JOIN runs AS owner_run
|
||||
ON owner_run.id = pane_dispatch.run_id AND owner_run.legacy = 0
|
||||
WHERE pane_dispatch.assignee_pane_key IS NOT NULL
|
||||
AND pane_dispatch.status IN ('pending', 'dispatched')
|
||||
AND instr(pane_dispatch.assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
GROUP BY pane_dispatch.run_id
|
||||
) AS pane_owner
|
||||
JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run
|
||||
ON candidate.run_id = pane_owner.run_id AND candidate.to_handle = ?
|
||||
WHERE 1 = 1${runExclusion}
|
||||
AND candidate.read = 0 AND candidate.delivered_at IS NULL
|
||||
AND candidate.delivery_contract = 'current_delivery'`
|
||||
)
|
||||
branchParams.push([
|
||||
paneSuffix,
|
||||
directHandle,
|
||||
...exclusionParams,
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1
|
||||
])
|
||||
}
|
||||
const limitedBranches = branches.map(
|
||||
(branch) => `SELECT * FROM (${branch} ORDER BY candidate.sequence LIMIT ?)`
|
||||
)
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, run_id, type FROM (${limitedBranches.join(' UNION ')})
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(...branchParams.flat(), ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1) as {
|
||||
id: string
|
||||
run_id: string
|
||||
type: MessageType
|
||||
}[]
|
||||
const page = rows.slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT)
|
||||
if (page.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return { routedCount: 0, hasMore: false, types: [], mailboxes: [] }
|
||||
}
|
||||
const runIds = [...new Set(page.map((row) => row.run_id))]
|
||||
const dispatchByRun = new Map<string, DispatchContextRow>()
|
||||
for (const runId of runIds) {
|
||||
const dispatch = this.findActiveDispatchForDirectMessageOwner(runId, directHandle, paneKey)
|
||||
if (dispatch) {
|
||||
dispatchByRun.set(runId, dispatch)
|
||||
}
|
||||
}
|
||||
const idsByMailbox = new Map<string, string[]>()
|
||||
const byMailbox = new Map<string, Set<MessageType>>()
|
||||
for (const row of page) {
|
||||
const dispatch = dispatchByRun.get(row.run_id)
|
||||
const mailboxHandle = dispatch ? `dispatch:${dispatch.id}` : `run:${row.run_id}`
|
||||
const ids = idsByMailbox.get(mailboxHandle) ?? []
|
||||
ids.push(row.id)
|
||||
idsByMailbox.set(mailboxHandle, ids)
|
||||
const types = byMailbox.get(mailboxHandle) ?? new Set<MessageType>()
|
||||
types.add(row.type)
|
||||
byMailbox.set(mailboxHandle, types)
|
||||
}
|
||||
for (const [mailboxHandle, ids] of idsByMailbox) {
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages INDEXED BY idx_messages_id SET to_handle = ?
|
||||
WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery' AND id IN (${placeholders})`
|
||||
)
|
||||
.run(mailboxHandle, directHandle, ...ids)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
routedCount: page.length,
|
||||
hasMore: rows.length > ORCHESTRATION_DELIVERY_BATCH_LIMIT,
|
||||
types: [...new Set(page.map((row) => row.type))],
|
||||
mailboxes: [...byMailbox].map(([mailboxHandle, types]) => ({
|
||||
mailboxHandle,
|
||||
types: [...types]
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getUnreadDirectMessageTypes(
|
||||
this: OrchestrationDb,
|
||||
directHandle: string
|
||||
): MessageType[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT type FROM messages INDEXED BY idx_messages_unread_current_inbox_type
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
ORDER BY type`
|
||||
)
|
||||
.all(directHandle) as { type: MessageType }[]
|
||||
).map((row) => row.type)
|
||||
}
|
||||
|
||||
export type ForeignDirectMailboxRoutingMethods = {
|
||||
findActiveDispatchForDirectMessageOwner: typeof findActiveDispatchForDirectMessageOwner
|
||||
routeForeignDirectMessagesToOwnedMailboxes: typeof routeForeignDirectMessagesToOwnedMailboxes
|
||||
getUnreadDirectMessageTypes: typeof getUnreadDirectMessageTypes
|
||||
}
|
||||
|
||||
export function attachForeignDirectMailboxRouting(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
findActiveDispatchForDirectMessageOwner,
|
||||
routeForeignDirectMessagesToOwnedMailboxes,
|
||||
getUnreadDirectMessageTypes
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MessageType } from '../../types'
|
||||
|
||||
export const ORCHESTRATION_DELIVERY_BATCH_LIMIT = 50
|
||||
|
||||
export type MailboxRoutingPage = {
|
||||
routedCount: number
|
||||
hasMore: boolean
|
||||
types: MessageType[]
|
||||
}
|
||||
|
||||
export type ForeignDirectMailboxRoutingPage = MailboxRoutingPage & {
|
||||
mailboxes: { mailboxHandle: string; types: MessageType[] }[]
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { MessageType, MessageRow } from '../../types'
|
||||
import { exposeMessageTimestamps, exposeMessageListTimestamps } from '../utc-timestamp'
|
||||
import { addLifecycleRejectionMarker } from '../lifecycle-rejection-marker'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
const MESSAGE_ID_UPDATE_BATCH_SIZE = 500
|
||||
|
||||
export function getUnreadMessages(
|
||||
this: OrchestrationDb,
|
||||
toHandle: string,
|
||||
types?: MessageType[]
|
||||
): MessageRow[] {
|
||||
if (types && types.length > 0) {
|
||||
const placeholders = types.map(() => '?').join(',')
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
AND type IN (${placeholders}) ORDER BY sequence`
|
||||
)
|
||||
.all(toHandle, ...types) as MessageRow[]
|
||||
)
|
||||
}
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence`
|
||||
)
|
||||
.all(toHandle) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
export function convertLifecycleMessageToRejection(
|
||||
this: OrchestrationDb,
|
||||
messageId: string,
|
||||
code: string,
|
||||
reason: string
|
||||
): MessageRow | undefined {
|
||||
const message = this.getMessageById(messageId)
|
||||
if (!message || (message.type !== 'worker_done' && message.type !== 'heartbeat')) {
|
||||
return message
|
||||
}
|
||||
|
||||
const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : ''
|
||||
const body = `Orca rejected this ${message.type}: ${reason}${originalBody}`
|
||||
const payload = addLifecycleRejectionMarker(message.payload, code, reason)
|
||||
// Why: rejected lifecycle signals stay auditable but must not reach read paths as actionable completion/liveness events.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET priority = 'high', subject = ?, body = ?, payload = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(`Rejected ${message.type}: ${message.subject}`, body, payload, messageId)
|
||||
return this.getMessageById(messageId)
|
||||
}
|
||||
|
||||
// Why: delivered_at IS NULL filter — push-on-idle delivers each row at most once; read (set only by check) wouldn't prevent replay.
|
||||
export function getUndeliveredUnreadMessages(
|
||||
this: OrchestrationDb,
|
||||
toHandle: string,
|
||||
types?: MessageType[],
|
||||
options?: { excludeTypes?: readonly string[]; limit?: number }
|
||||
): MessageRow[] {
|
||||
const conditions = [
|
||||
'to_handle = ?',
|
||||
'read = 0',
|
||||
'delivered_at IS NULL',
|
||||
"delivery_contract = 'current_delivery'"
|
||||
]
|
||||
const params: (string | number)[] = [toHandle]
|
||||
if (types?.length) {
|
||||
conditions.push(`type IN (${types.map(() => '?').join(',')})`)
|
||||
params.push(...types)
|
||||
}
|
||||
if (options?.excludeTypes?.length) {
|
||||
conditions.push(`type NOT IN (${options.excludeTypes.map(() => '?').join(',')})`)
|
||||
params.push(...options.excludeTypes)
|
||||
}
|
||||
const limitSql = options?.limit === undefined ? '' : ' LIMIT ?'
|
||||
if (options?.limit !== undefined) {
|
||||
params.push(Math.max(1, Math.floor(options.limit)))
|
||||
}
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY sequence${limitSql}`
|
||||
)
|
||||
.all(...params) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
export function getUndeliveredUnreadMailboxHandles(this: OrchestrationDb): string[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT to_handle FROM messages
|
||||
WHERE read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery'`
|
||||
)
|
||||
.all() as { to_handle: string }[]
|
||||
).map((row) => row.to_handle)
|
||||
}
|
||||
|
||||
export function getAllMessages(this: OrchestrationDb, toHandle: string, limit = 20): MessageRow[] {
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
|
||||
.all(toHandle, limit) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
export function getMessageById(this: OrchestrationDb, id: string): MessageRow | undefined {
|
||||
const message = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as
|
||||
| MessageRow
|
||||
| undefined
|
||||
return message ? exposeMessageTimestamps(message) : undefined
|
||||
}
|
||||
|
||||
export function markAsRead(this: OrchestrationDb, ids: string[]): void {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids)
|
||||
}
|
||||
|
||||
// Why: use datetime('now') so delivered_at matches the space-format UTC shape of the table's other timestamps for correct ordering (§3.2).
|
||||
export function markAsDelivered(this: OrchestrationDb, ids: string[]): void {
|
||||
for (let offset = 0; offset < ids.length; offset += MESSAGE_ID_UPDATE_BATCH_SIZE) {
|
||||
const batch = ids.slice(offset, offset + MESSAGE_ID_UPDATE_BATCH_SIZE)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`)
|
||||
.run(...batch)
|
||||
}
|
||||
}
|
||||
|
||||
export function markAsUndelivered(this: OrchestrationDb, ids: string[]): void {
|
||||
for (let offset = 0; offset < ids.length; offset += MESSAGE_ID_UPDATE_BATCH_SIZE) {
|
||||
const batch = ids.slice(offset, offset + MESSAGE_ID_UPDATE_BATCH_SIZE)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages SET delivered_at = NULL
|
||||
WHERE read = 0 AND id IN (${placeholders})`
|
||||
)
|
||||
.run(...batch)
|
||||
}
|
||||
}
|
||||
|
||||
export function areUnreadMessages(this: OrchestrationDb, toHandle: string, ids: string[]): boolean {
|
||||
let matched = 0
|
||||
for (let offset = 0; offset < ids.length; offset += MESSAGE_ID_UPDATE_BATCH_SIZE) {
|
||||
const batch = ids.slice(offset, offset + MESSAGE_ID_UPDATE_BATCH_SIZE)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM messages INDEXED BY idx_messages_id
|
||||
WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'
|
||||
AND id IN (${placeholders})`
|
||||
)
|
||||
.get(toHandle, ...batch) as { count: number }
|
||||
matched += row.count
|
||||
}
|
||||
return matched === ids.length
|
||||
}
|
||||
|
||||
export function markAsReadAndDelivered(this: OrchestrationDb, ids: string[]): void {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
// Why: superseded lifecycle messages stay in history but must not be consumed or injected after their dispatch finished.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now')) WHERE id IN (${placeholders})`
|
||||
)
|
||||
.run(...ids)
|
||||
}
|
||||
|
||||
export function getInbox(this: OrchestrationDb, limit = 20): MessageRow[] {
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?')
|
||||
.all(limit) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
// Why: read-only history for a handle — returns every message regardless of read/delivered state, never flips the read bit (§3.3).
|
||||
export function getAllMessagesForHandle(
|
||||
this: OrchestrationDb,
|
||||
toHandle: string,
|
||||
limit = 100,
|
||||
types?: MessageType[]
|
||||
): MessageRow[] {
|
||||
if (types && types.length > 0) {
|
||||
const placeholders = types.map(() => '?').join(',')
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?`
|
||||
)
|
||||
.all(toHandle, ...types, limit) as MessageRow[]
|
||||
)
|
||||
}
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
|
||||
.all(toHandle, limit) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
// Why: ask wait-loop read — to_handle filter shows only replies to the worker; afterSequence resumes past its own outbound ask.
|
||||
export function getThreadMessagesFor(
|
||||
this: OrchestrationDb,
|
||||
threadId: string,
|
||||
toHandle: string,
|
||||
afterSequence?: number
|
||||
): MessageRow[] {
|
||||
if (afterSequence !== undefined) {
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC'
|
||||
)
|
||||
.all(threadId, toHandle, afterSequence) as MessageRow[]
|
||||
)
|
||||
}
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare('SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC')
|
||||
.all(threadId, toHandle) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageInboxMethods = {
|
||||
getUnreadMessages: typeof getUnreadMessages
|
||||
convertLifecycleMessageToRejection: typeof convertLifecycleMessageToRejection
|
||||
getUndeliveredUnreadMessages: typeof getUndeliveredUnreadMessages
|
||||
getUndeliveredUnreadMailboxHandles: typeof getUndeliveredUnreadMailboxHandles
|
||||
getAllMessages: typeof getAllMessages
|
||||
getMessageById: typeof getMessageById
|
||||
markAsRead: typeof markAsRead
|
||||
markAsDelivered: typeof markAsDelivered
|
||||
markAsUndelivered: typeof markAsUndelivered
|
||||
areUnreadMessages: typeof areUnreadMessages
|
||||
markAsReadAndDelivered: typeof markAsReadAndDelivered
|
||||
getInbox: typeof getInbox
|
||||
getAllMessagesForHandle: typeof getAllMessagesForHandle
|
||||
getThreadMessagesFor: typeof getThreadMessagesFor
|
||||
}
|
||||
|
||||
export function attachMessageInbox(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getUnreadMessages,
|
||||
convertLifecycleMessageToRejection,
|
||||
getUndeliveredUnreadMessages,
|
||||
getUndeliveredUnreadMailboxHandles,
|
||||
getAllMessages,
|
||||
getMessageById,
|
||||
markAsRead,
|
||||
markAsDelivered,
|
||||
markAsUndelivered,
|
||||
areUnreadMessages,
|
||||
markAsReadAndDelivered,
|
||||
getInbox,
|
||||
getAllMessagesForHandle,
|
||||
getThreadMessagesFor
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { MessageType, MessagePriority, MessageDeliveryContract, MessageRow } from '../../types'
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import { exposeMessageTimestamps } from '../utc-timestamp'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
export function insertMessage(
|
||||
this: OrchestrationDb,
|
||||
msg: {
|
||||
id?: string
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
body?: string
|
||||
type?: MessageType
|
||||
priority?: MessagePriority
|
||||
threadId?: string
|
||||
payload?: string
|
||||
senderPaneKey?: string
|
||||
runId?: string
|
||||
deliveryContract?: MessageDeliveryContract
|
||||
}
|
||||
): MessageRow {
|
||||
const runId = msg.runId ?? LEGACY_RUN_ID
|
||||
const deliveryContract = msg.deliveryContract ?? 'current_delivery'
|
||||
this.requireRun(runId)
|
||||
const id = msg.id ?? generateId('msg')
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle, subject, body,
|
||||
type, priority, thread_id, payload, sender_pane_key
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
runId,
|
||||
deliveryContract,
|
||||
msg.from,
|
||||
msg.to,
|
||||
msg.subject,
|
||||
msg.body ?? '',
|
||||
msg.type ?? 'status',
|
||||
msg.priority ?? 'normal',
|
||||
msg.threadId ?? null,
|
||||
msg.payload ?? null,
|
||||
msg.senderPaneKey ?? null
|
||||
)
|
||||
return exposeMessageTimestamps(
|
||||
this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageInsertMethods = {
|
||||
insertMessage: typeof insertMessage
|
||||
}
|
||||
|
||||
export function attachMessageInsert(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
insertMessage
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { MutationReceiptRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Durable mutation receipts ──
|
||||
|
||||
export function getOrCreateLocalMutationCallerFingerprint(this: OrchestrationDb): string {
|
||||
if (this.localMutationCallerFingerprint) {
|
||||
return this.localMutationCallerFingerprint
|
||||
}
|
||||
const transport = 'local_authenticated_transport'
|
||||
const existing = this.db
|
||||
.prepare('SELECT caller_fingerprint FROM mutation_caller_identities WHERE transport = ?')
|
||||
.get(transport) as { caller_fingerprint: string } | undefined
|
||||
if (existing) {
|
||||
this.localMutationCallerFingerprint = existing.caller_fingerprint
|
||||
return this.localMutationCallerFingerprint
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO mutation_caller_identities (transport, caller_fingerprint)
|
||||
VALUES (?, ?)`
|
||||
)
|
||||
.run(transport, randomBytes(32).toString('hex'))
|
||||
const created = this.db
|
||||
.prepare('SELECT caller_fingerprint FROM mutation_caller_identities WHERE transport = ?')
|
||||
.get(transport) as { caller_fingerprint: string } | undefined
|
||||
if (!created) {
|
||||
throw new Error('Failed to create the local orchestration mutation caller identity.')
|
||||
}
|
||||
this.localMutationCallerFingerprint = created.caller_fingerprint
|
||||
return this.localMutationCallerFingerprint
|
||||
}
|
||||
|
||||
export function beginMutationReceipt(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
):
|
||||
| { disposition: 'started'; row: MutationReceiptRow }
|
||||
| { disposition: 'pending'; row: MutationReceiptRow }
|
||||
| { disposition: 'completed'; row: MutationReceiptRow } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const existing = this.getMutationReceipt(params.callerFingerprint, params.requestId)
|
||||
if (existing) {
|
||||
if (existing.method !== params.method || existing.payload_hash !== params.payloadHash) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Mutation request ${params.requestId} was already used with different input.`
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: existing.state, row: existing }
|
||||
}
|
||||
ensureMutationReceiptCapacity(this.db)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state
|
||||
) VALUES (?, ?, ?, ?, 'pending')`
|
||||
)
|
||||
.run(params.callerFingerprint, params.requestId, params.method, params.payloadHash)
|
||||
const row = this.getMutationReceipt(params.callerFingerprint, params.requestId)
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'started', row: row as MutationReceiptRow }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function completeMutationReceipt(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
receipt: string
|
||||
}
|
||||
): MutationReceiptRow {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE mutation_receipts
|
||||
SET state = 'completed', receipt = ?, updated_at = datetime('now')
|
||||
WHERE caller_fingerprint = ? AND request_id = ? AND method = ?
|
||||
AND payload_hash = ?`
|
||||
)
|
||||
.run(
|
||||
params.receipt,
|
||||
params.callerFingerprint,
|
||||
params.requestId,
|
||||
params.method,
|
||||
params.payloadHash
|
||||
)
|
||||
const row = this.getMutationReceipt(params.callerFingerprint, params.requestId)
|
||||
if (result.changes !== 1 || !row) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Mutation request ${params.requestId} no longer matches its pending operation.`
|
||||
)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
export function discardPendingMutationReceipt(
|
||||
this: OrchestrationDb,
|
||||
callerFingerprint: string,
|
||||
requestId: string
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM mutation_receipts
|
||||
WHERE caller_fingerprint = ? AND request_id = ? AND state = 'pending'`
|
||||
)
|
||||
.run(callerFingerprint, requestId)
|
||||
}
|
||||
|
||||
export function getMutationReceipt(
|
||||
this: OrchestrationDb,
|
||||
callerFingerprint: string,
|
||||
requestId: string
|
||||
): MutationReceiptRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM mutation_receipts
|
||||
WHERE caller_fingerprint = ? AND request_id = ?`
|
||||
)
|
||||
.get(callerFingerprint, requestId) as MutationReceiptRow | undefined
|
||||
}
|
||||
|
||||
export type MutationReceiptStoreMethods = {
|
||||
getOrCreateLocalMutationCallerFingerprint: typeof getOrCreateLocalMutationCallerFingerprint
|
||||
beginMutationReceipt: typeof beginMutationReceipt
|
||||
completeMutationReceipt: typeof completeMutationReceipt
|
||||
discardPendingMutationReceipt: typeof discardPendingMutationReceipt
|
||||
getMutationReceipt: typeof getMutationReceipt
|
||||
}
|
||||
|
||||
export function attachMutationReceiptStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getOrCreateLocalMutationCallerFingerprint,
|
||||
beginMutationReceipt,
|
||||
completeMutationReceipt,
|
||||
discardPendingMutationReceipt,
|
||||
getMutationReceipt
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { CoordinatorRunStoreMethods } from './coordinator-runs/coordinator-run-store'
|
||||
import type { DecisionGateStoreMethods } from './decision-gates/decision-gate-store'
|
||||
import type { DispatchCapabilityMethods } from './dispatch-context/dispatch-capability'
|
||||
import type { DispatchCompletionMethods } from './dispatch-context/dispatch-completion'
|
||||
import type { DispatchContextStoreMethods } from './dispatch-context/dispatch-context-store'
|
||||
import type { DispatchLookupMethods } from './dispatch-context/dispatch-lookup'
|
||||
import type { WorkerReportSettlementMethods } from './dispatch-context/worker-report-settlement'
|
||||
import type { FederatedDispatchStoreMethods } from './federation/federated-dispatch-store'
|
||||
import type { FederationRelayAckMethods } from './federation/federation-relay-ack'
|
||||
import type { FederationRelayEnqueueMethods } from './federation/federation-relay-enqueue'
|
||||
import type { FederationRelayImportMethods } from './federation/federation-relay-import'
|
||||
import type { FederationRelayItemMethods } from './federation/federation-relay-item'
|
||||
import type { RemoteDispatchAttachmentAuthorityMethods } from './federation/remote-dispatch-attachment-authority'
|
||||
import type { RemoteDispatchAttachmentCreateMethods } from './federation/remote-dispatch-attachment-create'
|
||||
import type { RemoteDispatchAttachmentStopMethods } from './federation/remote-dispatch-attachment-stop'
|
||||
import type { RemoteQuestionStoreMethods } from './federation/remote-question-store'
|
||||
import type { LegacyAskOperationMethods } from './legacy/legacy-ask-operation'
|
||||
import type { LegacyCompatibilityCandidatesMethods } from './legacy/legacy-compatibility-candidates'
|
||||
import type { LegacyCompatibilityPrincipalsMethods } from './legacy/legacy-compatibility-principals'
|
||||
import type { LegacyLifecycleOperationMethods } from './legacy/legacy-lifecycle-operation'
|
||||
import type { LegacyMailAcknowledgeMethods } from './legacy/legacy-mail-acknowledge'
|
||||
import type { LegacyQuestionAcknowledgeMethods } from './legacy/legacy-question-acknowledge'
|
||||
import type { LegacyQuestionLookupMethods } from './legacy/legacy-question-lookup'
|
||||
import type { LegacyRecoveryCohortMethods } from './legacy/legacy-recovery-cohort'
|
||||
import type { LegacyReplyOperationMethods } from './legacy/legacy-reply-operation'
|
||||
import type { LegacyWorkerCompletionMethods } from './legacy/legacy-worker-completion'
|
||||
import type { DirectMailboxRoutingMethods } from './messages/direct-mailbox-routing'
|
||||
import type { ForeignDirectMailboxRoutingMethods } from './messages/foreign-direct-mailbox-routing'
|
||||
import type { MessageInboxMethods } from './messages/message-inbox'
|
||||
import type { MessageInsertMethods } from './messages/message-insert'
|
||||
import type { MutationReceiptStoreMethods } from './mutation-receipts/mutation-receipt-store'
|
||||
import type { QuestionThreadsMethods } from './questions/question-threads'
|
||||
import type { OrchestrationResetMethods } from './reset/orchestration-reset'
|
||||
import type { RunBindingMethods } from './runs/run-binding'
|
||||
import type { RunCoordinatorMailRoutingMethods } from './runs/run-coordinator-mail-routing'
|
||||
import type { RunCreateMethods } from './runs/run-create'
|
||||
import type { RunDeliveryMethods } from './runs/run-delivery'
|
||||
import type { RunLookupMethods } from './runs/run-lookup'
|
||||
import type { LegacyCoordinatorMailTakeoverMethods } from './runs/legacy-coordinator-mail-takeover'
|
||||
import type { AdoptLegacyRunMethods } from './schema/adopt-legacy-run'
|
||||
import type { BackfillLegacyQuestionThreadsMethods } from './schema/backfill-legacy-question-threads'
|
||||
import type { CreateTablesMethods } from './schema/create-tables'
|
||||
import type { MigrateLegacyContractStorageMethods } from './schema/migrate-legacy-contract-storage'
|
||||
import type { SchemaMigrateMethods } from './schema/migrate'
|
||||
import type { SchemaColumnProbesMethods } from './schema/schema-column-probes'
|
||||
import type { TaskStoreMethods } from './tasks/task-store'
|
||||
import type { FederatedWorkerStartReconcileMethods } from './worker-dispatch/federated-worker-start-reconcile'
|
||||
import type { WorkerDispatchAbandonMethods } from './worker-dispatch/worker-dispatch-abandon'
|
||||
import type { WorkerDispatchAuthorityMethods } from './worker-dispatch/worker-dispatch-authority'
|
||||
import type { WorkerDispatchOutcomeMethods } from './worker-dispatch/worker-dispatch-outcome'
|
||||
import type { WorkerDispatchStageMethods } from './worker-dispatch/worker-dispatch-stage'
|
||||
import type { WorkerDispatchStartMethods } from './worker-dispatch/worker-dispatch-start'
|
||||
import type { WorkerDispatchStopMethods } from './worker-dispatch/worker-dispatch-stop'
|
||||
import type { WorkerTerminalRecoveryMethods } from './worker-dispatch/worker-terminal-recovery'
|
||||
import type { WorkerTerminalArchiveMethods } from './worker-terminal/worker-terminal-archive'
|
||||
import type { WorkerTerminalListingMethods } from './worker-terminal/worker-terminal-listing'
|
||||
import type { WorkerTerminalReleaseMethods } from './worker-terminal/worker-terminal-release'
|
||||
import type { WorkerTerminalResourceStoreMethods } from './worker-terminal/worker-terminal-resource-store'
|
||||
import type { WorkerTerminalTransferMethods } from './worker-terminal/worker-terminal-transfer'
|
||||
|
||||
export type OrchestrationDbMethods = CreateTablesMethods &
|
||||
SchemaMigrateMethods &
|
||||
SchemaColumnProbesMethods &
|
||||
MigrateLegacyContractStorageMethods &
|
||||
BackfillLegacyQuestionThreadsMethods &
|
||||
AdoptLegacyRunMethods &
|
||||
MutationReceiptStoreMethods &
|
||||
LegacyCompatibilityPrincipalsMethods &
|
||||
LegacyCompatibilityCandidatesMethods &
|
||||
LegacyWorkerCompletionMethods &
|
||||
LegacyRecoveryCohortMethods &
|
||||
LegacyMailAcknowledgeMethods &
|
||||
LegacyQuestionAcknowledgeMethods &
|
||||
LegacyLifecycleOperationMethods &
|
||||
LegacyAskOperationMethods &
|
||||
LegacyQuestionLookupMethods &
|
||||
LegacyReplyOperationMethods &
|
||||
RunCreateMethods &
|
||||
RunBindingMethods &
|
||||
RunLookupMethods &
|
||||
RunCoordinatorMailRoutingMethods &
|
||||
LegacyCoordinatorMailTakeoverMethods &
|
||||
RunDeliveryMethods &
|
||||
MessageInsertMethods &
|
||||
MessageInboxMethods &
|
||||
DirectMailboxRoutingMethods &
|
||||
ForeignDirectMailboxRoutingMethods &
|
||||
QuestionThreadsMethods &
|
||||
TaskStoreMethods &
|
||||
WorkerDispatchStartMethods &
|
||||
WorkerDispatchStageMethods &
|
||||
WorkerDispatchAuthorityMethods &
|
||||
WorkerDispatchOutcomeMethods &
|
||||
FederatedWorkerStartReconcileMethods &
|
||||
WorkerTerminalRecoveryMethods &
|
||||
WorkerDispatchStopMethods &
|
||||
WorkerDispatchAbandonMethods &
|
||||
FederatedDispatchStoreMethods &
|
||||
RemoteDispatchAttachmentCreateMethods &
|
||||
RemoteDispatchAttachmentAuthorityMethods &
|
||||
RemoteDispatchAttachmentStopMethods &
|
||||
FederationRelayEnqueueMethods &
|
||||
FederationRelayAckMethods &
|
||||
FederationRelayImportMethods &
|
||||
RemoteQuestionStoreMethods &
|
||||
FederationRelayItemMethods &
|
||||
WorkerTerminalResourceStoreMethods &
|
||||
WorkerTerminalTransferMethods &
|
||||
WorkerTerminalReleaseMethods &
|
||||
WorkerTerminalArchiveMethods &
|
||||
WorkerTerminalListingMethods &
|
||||
DispatchContextStoreMethods &
|
||||
DispatchCapabilityMethods &
|
||||
DispatchLookupMethods &
|
||||
DispatchCompletionMethods &
|
||||
WorkerReportSettlementMethods &
|
||||
DecisionGateStoreMethods &
|
||||
CoordinatorRunStoreMethods &
|
||||
OrchestrationResetMethods
|
||||
@@ -0,0 +1,48 @@
|
||||
import Database from '../../../sqlite/sync-database'
|
||||
import { attachOrchestrationDbMethods } from './attach-orchestration-db-methods'
|
||||
import { hardenOrchestrationDatabaseFiles } from './database-file-permissions'
|
||||
import type { OrchestrationDbMethods } from './orchestration-db-methods'
|
||||
import {
|
||||
createCoordinatorMailRoutingTrigger,
|
||||
rememberCurrentRunCoordinatorHandles
|
||||
} from './runs/run-coordinator-mail-routing'
|
||||
import { createTables } from './schema/create-tables'
|
||||
import { migrate } from './schema/migrate'
|
||||
|
||||
class OrchestrationDbCore {
|
||||
db: Database.Database
|
||||
|
||||
// Why: the orchestration DB is created lazily for ALL users, but only the
|
||||
// small minority who dispatch work ever have dispatch_contexts rows. The
|
||||
// renderer graph publish rebuilds orchestration context on every 16ms tick
|
||||
// (buildAgentOrchestrationByPaneKey), issuing 2 queries per terminal. Cache
|
||||
// emptiness so the non-orchestration majority short-circuits the whole
|
||||
// per-terminal fan-out. Only createDispatchContext flips this false→true.
|
||||
hasAnyDispatchContextsCache: boolean | undefined
|
||||
localMutationCallerFingerprint: string | undefined
|
||||
|
||||
constructor(dbPath: (string & {}) | ':memory:') {
|
||||
this.db = new Database(dbPath)
|
||||
this.db.pragma('journal_mode = WAL')
|
||||
this.db.pragma('synchronous = NORMAL')
|
||||
this.db.pragma('busy_timeout = 5000')
|
||||
createTables.call(this as unknown as OrchestrationDb)
|
||||
migrate.call(this as unknown as OrchestrationDb)
|
||||
createCoordinatorMailRoutingTrigger.call(this as unknown as OrchestrationDb)
|
||||
rememberCurrentRunCoordinatorHandles.call(this as unknown as OrchestrationDb)
|
||||
hardenOrchestrationDatabaseFiles(dbPath)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export type OrchestrationDb = OrchestrationDbCore & OrchestrationDbMethods
|
||||
|
||||
attachOrchestrationDbMethods(OrchestrationDbCore)
|
||||
|
||||
// Why: attach adds methods on the prototype; oxlint forbids class/interface merging, so the construct type is asserted.
|
||||
export const OrchestrationDb = OrchestrationDbCore as new (
|
||||
dbPath: (string & {}) | ':memory:'
|
||||
) => OrchestrationDb
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isEquivalentPaneKey,
|
||||
paneKeyMatchSuffix,
|
||||
parseWorkerTerminalPriorOwnerIds
|
||||
} from './pane-key-match'
|
||||
|
||||
describe('pane-key-match', () => {
|
||||
it('treats identical keys and same leaf UUIDs as equivalent', () => {
|
||||
const leaf = '11111111-1111-4111-8111-111111111111'
|
||||
expect(isEquivalentPaneKey('tab-a', 'tab-a')).toBe(true)
|
||||
expect(isEquivalentPaneKey(`tab-a:${leaf}`, `tab-b:${leaf}`)).toBe(true)
|
||||
expect(isEquivalentPaneKey(`tab-a:${leaf}`, `tab-a:22222222-2222-4222-8222-222222222222`)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the text after the first colon as the indexable suffix', () => {
|
||||
expect(paneKeyMatchSuffix('no-colon')).toBe('no-colon')
|
||||
expect(paneKeyMatchSuffix('tab:leaf')).toBe('leaf')
|
||||
expect(paneKeyMatchSuffix('tab:leaf:extra')).toBe('leaf:extra')
|
||||
})
|
||||
|
||||
it('parses a string array of prior owner ids and rejects other JSON', () => {
|
||||
expect(parseWorkerTerminalPriorOwnerIds('["a","b"]')).toEqual(['a', 'b'])
|
||||
expect(parseWorkerTerminalPriorOwnerIds('[1]')).toBeNull()
|
||||
expect(parseWorkerTerminalPriorOwnerIds('not-json')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
|
||||
// Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys.
|
||||
export function isEquivalentPaneKey(a: string, b: string): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
const aLeaf = parsePaneKey(a)?.leafId
|
||||
const bLeaf = parsePaneKey(b)?.leafId
|
||||
return Boolean(aLeaf && bLeaf && aLeaf === bLeaf)
|
||||
}
|
||||
|
||||
export function parseWorkerTerminalPriorOwnerIds(value: string): string[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')
|
||||
? parsed
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: indexable pre-filter for isEquivalentPaneKey — equal strings and equal leaves both share the
|
||||
// text after the first ':', so this narrows candidates without deciding equivalence itself.
|
||||
export const RUN_PANE_KEY_MATCH_SUFFIX_SQL =
|
||||
"substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1)"
|
||||
export const DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL =
|
||||
"substr(assignee_pane_key, instr(assignee_pane_key, ':') + 1)"
|
||||
export const REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL =
|
||||
"substr(pane_key, instr(pane_key, ':') + 1)"
|
||||
|
||||
export function paneKeyMatchSuffix(paneKey: string): string {
|
||||
const colon = paneKey.indexOf(':')
|
||||
return colon === -1 ? paneKey : paneKey.slice(colon + 1)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { MessageRow, QuestionRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { exposeQuestionTimestamps } from '../utc-timestamp'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function createQuestion(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
dispatchId: string
|
||||
askerHandle: string
|
||||
question: string
|
||||
options?: string[]
|
||||
}
|
||||
): { question: QuestionRow; message: MessageRow } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.requireRun(params.runId)
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== params.runId ||
|
||||
(dispatch.status !== 'pending' && dispatch.status !== 'dispatched')
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${params.dispatchId} is not active in Run ${params.runId}.`
|
||||
)
|
||||
}
|
||||
const message = this.insertMessage({
|
||||
from: `dispatch:${params.dispatchId}`,
|
||||
to: `run:${params.runId}`,
|
||||
subject: 'Question',
|
||||
body: params.question,
|
||||
type: 'question',
|
||||
payload: JSON.stringify({
|
||||
taskId: dispatch.task_id,
|
||||
dispatchId: dispatch.id,
|
||||
question: params.question,
|
||||
options: params.options ?? []
|
||||
}),
|
||||
runId: params.runId
|
||||
})
|
||||
this.db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(message.id, message.id)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO question_threads (
|
||||
message_id, run_id, dispatch_id, asker_handle
|
||||
) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(message.id, params.runId, params.dispatchId, params.askerHandle)
|
||||
const question = this.getQuestionRaw(message.id) as QuestionRow
|
||||
const storedMessage = this.getMessageById(message.id) as MessageRow
|
||||
this.db.exec('COMMIT')
|
||||
return { question: exposeQuestionTimestamps(question), message: storedMessage }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuestion(this: OrchestrationDb, messageId: string): QuestionRow | undefined {
|
||||
const question = this.getQuestionRaw(messageId)
|
||||
return question ? exposeQuestionTimestamps(question) : undefined
|
||||
}
|
||||
|
||||
export function getQuestionRaw(this: OrchestrationDb, messageId: string): QuestionRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM question_threads WHERE message_id = ?').get(messageId) as
|
||||
| QuestionRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function answerQuestion(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
messageId: string
|
||||
runId: string
|
||||
consumerGeneration: number
|
||||
body: string
|
||||
}
|
||||
): { question: QuestionRow; message: MessageRow; duplicate: boolean } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.requireCurrentConsumer(params.runId, params.consumerGeneration)
|
||||
const question = this.getQuestionRaw(params.messageId)
|
||||
if (!question || question.run_id !== params.runId) {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Question ${params.messageId} was not found in Run ${params.runId}.`
|
||||
)
|
||||
}
|
||||
if (question.status === 'closed') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Question ${params.messageId} is closed because its Dispatch is inactive.`
|
||||
)
|
||||
}
|
||||
if (question.status === 'answered') {
|
||||
if (question.answer_body !== params.body || !question.answer_message_id) {
|
||||
throw new OrchestrationError(
|
||||
'answer_conflict',
|
||||
`Question ${params.messageId} already has a different answer.`
|
||||
)
|
||||
}
|
||||
const message = this.getMessageById(question.answer_message_id)
|
||||
if (!message) {
|
||||
throw new Error(`Recorded answer message ${question.answer_message_id} was not found.`)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { question: exposeQuestionTimestamps(question), message, duplicate: true }
|
||||
}
|
||||
|
||||
const message = this.insertMessage({
|
||||
from: `run:${params.runId}`,
|
||||
to: `dispatch:${question.dispatch_id}`,
|
||||
subject: 'Re: Question',
|
||||
body: params.body,
|
||||
threadId: question.message_id,
|
||||
runId: params.runId
|
||||
})
|
||||
// Why: ask returns thread state directly; leaving its answer unread would deliver it again via check.
|
||||
this.markAsRead([message.id])
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE question_threads
|
||||
SET status = 'answered', answer_message_id = ?, answer_body = ?,
|
||||
answered_by_generation = ?, answered_at = datetime('now')
|
||||
WHERE message_id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(message.id, params.body, params.consumerGeneration, question.message_id)
|
||||
const answered = this.getQuestionRaw(question.message_id) as QuestionRow
|
||||
const storedMessage = this.getMessageById(message.id) as MessageRow
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
question: exposeQuestionTimestamps(answered),
|
||||
message: storedMessage,
|
||||
duplicate: false
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function closeQuestionsForDispatch(this: OrchestrationDb, dispatchId: string): string[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT message_id FROM question_threads WHERE dispatch_id = ? AND status = 'pending'")
|
||||
.all(dispatchId) as { message_id: string }[]
|
||||
if (rows.length === 0) {
|
||||
return []
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE question_threads SET status = 'closed', closed_at = datetime('now') WHERE dispatch_id = ? AND status = 'pending'"
|
||||
)
|
||||
.run(dispatchId)
|
||||
return rows.map((row) => row.message_id)
|
||||
}
|
||||
|
||||
export type QuestionThreadsMethods = {
|
||||
createQuestion: typeof createQuestion
|
||||
getQuestion: typeof getQuestion
|
||||
getQuestionRaw: typeof getQuestionRaw
|
||||
answerQuestion: typeof answerQuestion
|
||||
closeQuestionsForDispatch: typeof closeQuestionsForDispatch
|
||||
}
|
||||
|
||||
export function attachQuestionThreads(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createQuestion,
|
||||
getQuestion,
|
||||
getQuestionRaw,
|
||||
answerQuestion,
|
||||
closeQuestionsForDispatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
export function runResetTransaction(this: OrchestrationDb, statements: string): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.db.exec(statements)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAll(this: OrchestrationDb): void {
|
||||
// Why: retain mutation receipts so a lost reset response cannot replay as a new mutation.
|
||||
this.runResetTransaction(`
|
||||
DELETE FROM coordinator_runs;
|
||||
DELETE FROM decision_gates;
|
||||
DELETE FROM remote_questions;
|
||||
DELETE FROM question_threads;
|
||||
DELETE FROM deliveries;
|
||||
DELETE FROM legacy_mail_receipts;
|
||||
DELETE FROM legacy_operation_receipts;
|
||||
DELETE FROM legacy_compatibility_principals;
|
||||
DELETE FROM legacy_adoptions;
|
||||
DELETE FROM federation_relay_items;
|
||||
DELETE FROM remote_dispatch_attachments;
|
||||
DELETE FROM federated_dispatches;
|
||||
DELETE FROM worker_terminal_archives;
|
||||
DELETE FROM worker_terminal_resources;
|
||||
DELETE FROM worker_dispatches;
|
||||
DELETE FROM dispatch_contexts;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM messages;
|
||||
DELETE FROM run_coordinator_handles;
|
||||
DELETE FROM runs;
|
||||
INSERT INTO runs (id, objective, home_database, consumer_generation, legacy)
|
||||
VALUES ('${LEGACY_RUN_ID}', 'Legacy orchestration state (inspect only)', 'this_database', 0, 1);
|
||||
`)
|
||||
this.hasAnyDispatchContextsCache = undefined
|
||||
}
|
||||
|
||||
export function resetTasks(this: OrchestrationDb): void {
|
||||
this.runResetTransaction(`
|
||||
DELETE FROM coordinator_runs;
|
||||
DELETE FROM decision_gates;
|
||||
DELETE FROM remote_questions;
|
||||
DELETE FROM question_threads;
|
||||
DELETE FROM legacy_mail_receipts;
|
||||
DELETE FROM legacy_operation_receipts;
|
||||
DELETE FROM legacy_compatibility_principals;
|
||||
DELETE FROM legacy_adoptions;
|
||||
DELETE FROM federation_relay_items;
|
||||
DELETE FROM remote_dispatch_attachments;
|
||||
DELETE FROM federated_dispatches;
|
||||
DELETE FROM worker_terminal_archives;
|
||||
DELETE FROM worker_terminal_resources;
|
||||
DELETE FROM worker_dispatches;
|
||||
DELETE FROM dispatch_contexts;
|
||||
DELETE FROM tasks;
|
||||
`)
|
||||
this.hasAnyDispatchContextsCache = undefined
|
||||
}
|
||||
|
||||
export function resetMessages(this: OrchestrationDb): void {
|
||||
// Why: relay rows carry contiguous cross-server cursors, not just inbox history.
|
||||
this.runResetTransaction(`
|
||||
DELETE FROM legacy_mail_receipts;
|
||||
DELETE FROM question_threads;
|
||||
DELETE FROM deliveries;
|
||||
DELETE FROM messages;
|
||||
`)
|
||||
}
|
||||
|
||||
export type OrchestrationResetMethods = {
|
||||
runResetTransaction: typeof runResetTransaction
|
||||
resetAll: typeof resetAll
|
||||
resetTasks: typeof resetTasks
|
||||
resetMessages: typeof resetMessages
|
||||
}
|
||||
|
||||
export function attachOrchestrationReset(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
runResetTransaction,
|
||||
resetAll,
|
||||
resetTasks,
|
||||
resetMessages
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeRunListCursor, encodeRunListCursor } from './run-list-cursor'
|
||||
import type { RunRow } from '../types'
|
||||
|
||||
describe('run-list-cursor', () => {
|
||||
it('round-trips createdAt and id', () => {
|
||||
const run = { created_at: '2026-01-02 03:04:05', id: 'run_abc' } as RunRow
|
||||
expect(decodeRunListCursor(encodeRunListCursor(run))).toEqual({
|
||||
createdAt: '2026-01-02 03:04:05',
|
||||
id: 'run_abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an invalid cursor with cursor_invalid', () => {
|
||||
expect(() => decodeRunListCursor('not-a-cursor')).toThrow(/cursor is invalid/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OrchestrationError } from '../orchestration-error'
|
||||
import type { RunRow } from '../types'
|
||||
import type { RunListCursor } from './run-list-page'
|
||||
|
||||
export function encodeRunListCursor(run: RunRow): string {
|
||||
const cursor: RunListCursor = { createdAt: run.created_at, id: run.id }
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||
}
|
||||
|
||||
export function decodeRunListCursor(value: string): RunListCursor {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as RunListCursor).createdAt !== 'string' ||
|
||||
typeof (parsed as RunListCursor).id !== 'string'
|
||||
) {
|
||||
throw new Error('invalid cursor shape')
|
||||
}
|
||||
return parsed as RunListCursor
|
||||
} catch {
|
||||
throw new OrchestrationError('cursor_invalid', 'The Run list cursor is invalid.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RunRow, TaskRow } from '../types'
|
||||
|
||||
export type RunListPage = {
|
||||
runs: RunRow[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export type TaskRuntimeLineageRow = TaskRow & {
|
||||
creator_dispatch_id: string | null
|
||||
creator_dispatch_run_id: string | null
|
||||
creator_dispatch_pane_key: string | null
|
||||
creator_dispatch_process_incarnation: string | null
|
||||
}
|
||||
|
||||
export type RunListCursor = {
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function promoteLegacyCoordinatorMailForTakeover(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
retainedCoordinatorHandle: string | null
|
||||
): void {
|
||||
if (!retainedCoordinatorHandle) {
|
||||
return
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET to_handle = ?, delivery_contract = 'current_delivery',
|
||||
read = 0, delivered_at = NULL
|
||||
WHERE run_id = ? AND delivery_contract = 'legacy_direct'
|
||||
AND to_handle = ?
|
||||
AND EXISTS(
|
||||
SELECT 1 FROM dispatch_contexts d
|
||||
WHERE d.run_id = messages.run_id
|
||||
AND d.contract_version = ?
|
||||
AND (
|
||||
messages.from_handle = d.assignee_handle OR
|
||||
messages.from_handle = 'dispatch:' || d.id
|
||||
)
|
||||
)
|
||||
AND (
|
||||
read = 0 OR EXISTS(
|
||||
SELECT 1 FROM question_threads q
|
||||
WHERE q.message_id = messages.id AND q.status = 'pending'
|
||||
) OR EXISTS(
|
||||
SELECT 1
|
||||
FROM legacy_mail_receipts r
|
||||
INNER JOIN legacy_compatibility_principals p
|
||||
ON p.id = r.principal_id
|
||||
WHERE r.message_id = messages.id
|
||||
AND r.acknowledged_at IS NULL
|
||||
AND p.run_id = messages.run_id
|
||||
AND p.role = 'coordinator'
|
||||
AND p.terminal_handle = ?
|
||||
) OR (
|
||||
read = 1
|
||||
AND NOT EXISTS(
|
||||
SELECT 1 FROM legacy_compatibility_principals p
|
||||
WHERE p.run_id = messages.run_id AND p.role = 'coordinator'
|
||||
)
|
||||
AND EXISTS(
|
||||
SELECT 1 FROM dispatch_contexts d
|
||||
WHERE d.run_id = messages.run_id
|
||||
AND d.contract_version = ?
|
||||
AND d.status IN ('pending', 'dispatched')
|
||||
AND messages.created_at >= d.created_at
|
||||
AND (
|
||||
messages.from_handle = d.assignee_handle OR
|
||||
messages.from_handle = 'dispatch:' || d.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)`
|
||||
)
|
||||
.run(
|
||||
`run:${runId}`,
|
||||
runId,
|
||||
retainedCoordinatorHandle,
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
retainedCoordinatorHandle,
|
||||
LEGACY_CONTRACT_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
export function getUniqueLegacyCoordinatorHandle(
|
||||
this: OrchestrationDb,
|
||||
runId: string
|
||||
): string | null {
|
||||
const adoption = this.getLegacyAdoption()
|
||||
if (!adoption || adoption.adopted_run_id !== runId) {
|
||||
return null
|
||||
}
|
||||
const workerHandles = new Set(
|
||||
(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT assignee_handle AS handle
|
||||
FROM dispatch_contexts
|
||||
WHERE run_id = ? AND contract_version = ?
|
||||
AND assignee_handle IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT terminal_handle AS handle
|
||||
FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? AND role = 'worker'
|
||||
AND status IN ('committed', 'settled')`
|
||||
)
|
||||
.all(runId, LEGACY_CONTRACT_VERSION, runId) as { handle: string }[]
|
||||
).map((row) => row.handle)
|
||||
)
|
||||
const durableRows = this.db
|
||||
.prepare(
|
||||
`SELECT coordinator_handle AS handle
|
||||
FROM coordinator_runs
|
||||
WHERE scheduler_lost_at = ?
|
||||
UNION
|
||||
SELECT created_by_terminal_handle AS handle
|
||||
FROM tasks t
|
||||
WHERE t.run_id = ? AND t.created_by_terminal_handle IS NOT NULL
|
||||
AND t.created_at <= ?
|
||||
AND EXISTS(
|
||||
SELECT 1 FROM dispatch_contexts d
|
||||
WHERE d.task_id = t.id AND d.run_id = t.run_id
|
||||
AND d.contract_version = ?
|
||||
)`
|
||||
)
|
||||
.all(adoption.adopted_at, runId, adoption.adopted_at, LEGACY_CONTRACT_VERSION) as {
|
||||
handle: string
|
||||
}[]
|
||||
if (durableRows.some((row) => workerHandles.has(row.handle))) {
|
||||
return null
|
||||
}
|
||||
const candidates = new Set(durableRows.map((row) => row.handle))
|
||||
const mailRows = this.db
|
||||
.prepare(
|
||||
`SELECT m.to_handle AS handle
|
||||
FROM messages m
|
||||
INNER JOIN dispatch_contexts d
|
||||
ON d.run_id = m.run_id AND d.contract_version = ?
|
||||
AND (m.from_handle = d.assignee_handle OR m.from_handle = 'dispatch:' || d.id)
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct'
|
||||
AND m.created_at <= ?
|
||||
UNION
|
||||
SELECT m.from_handle AS handle
|
||||
FROM messages m
|
||||
INNER JOIN dispatch_contexts d
|
||||
ON d.run_id = m.run_id AND d.contract_version = ?
|
||||
AND (m.to_handle = d.assignee_handle OR m.to_handle = 'dispatch:' || d.id)
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct'
|
||||
AND m.created_at <= ?`
|
||||
)
|
||||
.all(
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
runId,
|
||||
adoption.adopted_at,
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
runId,
|
||||
adoption.adopted_at
|
||||
) as {
|
||||
handle: string
|
||||
}[]
|
||||
for (const row of mailRows) {
|
||||
if (
|
||||
!workerHandles.has(row.handle) &&
|
||||
!row.handle.startsWith('dispatch:') &&
|
||||
!row.handle.startsWith('run:')
|
||||
) {
|
||||
candidates.add(row.handle)
|
||||
}
|
||||
}
|
||||
return candidates.size === 1 ? ([...candidates][0] ?? null) : null
|
||||
}
|
||||
|
||||
export type LegacyCoordinatorMailTakeoverMethods = {
|
||||
promoteLegacyCoordinatorMailForTakeover: typeof promoteLegacyCoordinatorMailForTakeover
|
||||
getUniqueLegacyCoordinatorHandle: typeof getUniqueLegacyCoordinatorHandle
|
||||
}
|
||||
|
||||
export function attachLegacyCoordinatorMailTakeover(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
promoteLegacyCoordinatorMailForTakeover,
|
||||
getUniqueLegacyCoordinatorHandle
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { RunRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function bindRun(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
coordinatorHandle: string
|
||||
coordinatorPaneKey: string
|
||||
takeoverLegacy?: boolean
|
||||
legacyCoordinatorAuthority?: {
|
||||
runId: string
|
||||
principalId: string | null
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
consumerGeneration: number
|
||||
}
|
||||
}
|
||||
): RunRow | undefined {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const run = this.getRunRaw(params.runId)
|
||||
if (!run || run.legacy === 1) {
|
||||
this.db.exec('ROLLBACK')
|
||||
return undefined
|
||||
}
|
||||
const sameBinding =
|
||||
run.coordinator_pane_key !== null &&
|
||||
isEquivalentPaneKey(run.coordinator_pane_key, params.coordinatorPaneKey)
|
||||
const adoption = this.getLegacyAdoption()
|
||||
const adoptedRun = adoption?.adopted_run_id === params.runId
|
||||
const legacyAuthority = params.legacyCoordinatorAuthority
|
||||
const legacyPrincipalId = legacyAuthority?.principalId
|
||||
const legacyPrincipal = legacyPrincipalId
|
||||
? this.getLegacyCompatibilityPrincipal(legacyPrincipalId)
|
||||
: undefined
|
||||
const provenLegacyBinding = Boolean(
|
||||
adoptedRun &&
|
||||
legacyAuthority &&
|
||||
legacyAuthority.principalId !== null &&
|
||||
legacyAuthority.runId === params.runId &&
|
||||
legacyAuthority.consumerGeneration === run.consumer_generation &&
|
||||
legacyPrincipal?.run_id === params.runId &&
|
||||
legacyPrincipal.role === 'coordinator' &&
|
||||
legacyPrincipal.status === 'committed' &&
|
||||
legacyPrincipal.terminal_handle === legacyAuthority.terminalHandle &&
|
||||
isEquivalentPaneKey(legacyPrincipal.pane_key, legacyAuthority.paneKey) &&
|
||||
params.coordinatorHandle === legacyAuthority.terminalHandle &&
|
||||
isEquivalentPaneKey(params.coordinatorPaneKey, legacyAuthority.paneKey)
|
||||
)
|
||||
if (legacyAuthority && !provenLegacyBinding) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator no longer has lifecycle authority. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
const activeLegacyAssignment =
|
||||
adoptedRun &&
|
||||
Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM dispatch_contexts
|
||||
WHERE run_id = ? AND contract_version = ?
|
||||
AND status IN ('pending', 'dispatched')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(params.runId, LEGACY_CONTRACT_VERSION)
|
||||
)
|
||||
const coordinatorPrincipal = adoptedRun
|
||||
? this.getLegacyCoordinatorPrincipal(params.runId)
|
||||
: undefined
|
||||
const retainedCoordinatorHandle =
|
||||
coordinatorPrincipal?.terminal_handle ??
|
||||
run.coordinator_handle ??
|
||||
this.getUniqueLegacyCoordinatorHandle(params.runId)
|
||||
const takeoverAlreadyApplied = Boolean(
|
||||
params.takeoverLegacy &&
|
||||
sameBinding &&
|
||||
run.coordinator_handle === params.coordinatorHandle &&
|
||||
coordinatorPrincipal?.status !== 'committed'
|
||||
)
|
||||
const replacesLegacyCoordinator = Boolean(
|
||||
adoptedRun &&
|
||||
!provenLegacyBinding &&
|
||||
retainedCoordinatorHandle &&
|
||||
(params.takeoverLegacy ||
|
||||
retainedCoordinatorHandle !== params.coordinatorHandle ||
|
||||
!sameBinding)
|
||||
)
|
||||
if (params.takeoverLegacy && !adoptedRun) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Legacy takeover is only available for the automatically adopted Run.'
|
||||
)
|
||||
}
|
||||
// Why: only LIVE legacy work needs the flag — settled work has no competing authority left, and
|
||||
// fencing it would strand the recovered graph behind an attestation the caller may not have.
|
||||
if (activeLegacyAssignment && !sameBinding && !provenLegacyBinding && !params.takeoverLegacy) {
|
||||
throw new OrchestrationError(
|
||||
'consumer_fenced',
|
||||
'This adopted Run still has live legacy work. Its attested coordinator may rebind it, or a current coordinator may explicitly use run-use --takeover-legacy.',
|
||||
{
|
||||
effectsApplied: false,
|
||||
recoveryCommand: `orca orchestration run-use --id ${params.runId} --takeover-legacy`
|
||||
}
|
||||
)
|
||||
}
|
||||
this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId)
|
||||
for (const handle of new Set(
|
||||
[run.coordinator_handle, params.coordinatorHandle].filter((value): value is string =>
|
||||
Boolean(value)
|
||||
)
|
||||
)) {
|
||||
this.rememberRunCoordinatorHandle(params.runId, handle)
|
||||
this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, handle)
|
||||
}
|
||||
if (
|
||||
(params.takeoverLegacy && !takeoverAlreadyApplied) ||
|
||||
!sameBinding ||
|
||||
run.coordinator_handle !== params.coordinatorHandle
|
||||
) {
|
||||
if (adoptedRun && (params.takeoverLegacy || !activeLegacyAssignment)) {
|
||||
if (
|
||||
coordinatorPrincipal?.status === 'committed' &&
|
||||
(params.takeoverLegacy ||
|
||||
coordinatorPrincipal.terminal_handle !== params.coordinatorHandle ||
|
||||
!isEquivalentPaneKey(coordinatorPrincipal.pane_key, params.coordinatorPaneKey))
|
||||
) {
|
||||
this.setLegacyCompatibilityPrincipalStatus(coordinatorPrincipal.id, 'revoked')
|
||||
}
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE runs
|
||||
SET coordinator_handle = ?, coordinator_pane_key = ?,
|
||||
consumer_generation = consumer_generation + 1,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(params.coordinatorHandle, params.coordinatorPaneKey, params.runId)
|
||||
this.fenceOutstandingDelivery(params.runId)
|
||||
if (params.takeoverLegacy || replacesLegacyCoordinator) {
|
||||
this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle)
|
||||
}
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return this.getRun(params.runId)
|
||||
}
|
||||
|
||||
export type RunBindingMethods = {
|
||||
bindRun: typeof bindRun
|
||||
}
|
||||
|
||||
export function attachRunBinding(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
bindRun
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function rememberRunCoordinatorHandle(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
terminalHandle: string
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) VALUES (?, ?)`
|
||||
)
|
||||
.run(runId, terminalHandle)
|
||||
}
|
||||
|
||||
export function rememberCurrentRunCoordinatorHandles(this: OrchestrationDb): void {
|
||||
this.db.exec(`
|
||||
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
|
||||
SELECT id, coordinator_handle FROM runs
|
||||
WHERE legacy = 0 AND coordinator_handle IS NOT NULL
|
||||
`)
|
||||
}
|
||||
|
||||
export function createCoordinatorMailRoutingTrigger(this: OrchestrationDb): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.db.exec(`
|
||||
DROP TRIGGER IF EXISTS trg_messages_route_coordinator_mail;
|
||||
CREATE TRIGGER trg_messages_route_coordinator_mail
|
||||
AFTER INSERT ON messages
|
||||
WHEN NEW.read = 0 AND NEW.delivery_contract = 'current_delivery'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM runs
|
||||
WHERE runs.id = NEW.run_id AND runs.legacy = 0
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM run_coordinator_handles
|
||||
WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dispatch_contexts
|
||||
WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle
|
||||
AND status IN ('pending', 'dispatched')
|
||||
)
|
||||
BEGIN
|
||||
UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence;
|
||||
END;
|
||||
`)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function routeAllUnreadDirectMessagesToRunMailbox(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
directHandle: string
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages SET to_handle = ?
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dispatch_contexts
|
||||
WHERE dispatch_contexts.run_id = messages.run_id
|
||||
AND dispatch_contexts.assignee_handle = messages.to_handle
|
||||
AND dispatch_contexts.status IN ('pending', 'dispatched')
|
||||
)`
|
||||
)
|
||||
.run(`run:${runId}`, runId, directHandle)
|
||||
}
|
||||
|
||||
export type RunCoordinatorMailRoutingMethods = {
|
||||
rememberRunCoordinatorHandle: typeof rememberRunCoordinatorHandle
|
||||
rememberCurrentRunCoordinatorHandles: typeof rememberCurrentRunCoordinatorHandles
|
||||
createCoordinatorMailRoutingTrigger: typeof createCoordinatorMailRoutingTrigger
|
||||
routeAllUnreadDirectMessagesToRunMailbox: typeof routeAllUnreadDirectMessagesToRunMailbox
|
||||
}
|
||||
|
||||
export function attachRunCoordinatorMailRouting(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
rememberRunCoordinatorHandle,
|
||||
rememberCurrentRunCoordinatorHandles,
|
||||
createCoordinatorMailRoutingTrigger,
|
||||
routeAllUnreadDirectMessagesToRunMailbox
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RunRow } from '../../types'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Runs ──
|
||||
|
||||
export function createRun(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
objective: string
|
||||
coordinatorHandle: string
|
||||
coordinatorPaneKey: string
|
||||
}
|
||||
): RunRow {
|
||||
const id = generateId('run')
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.unbindOtherRunsForPane(params.coordinatorPaneKey)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO runs (
|
||||
id, objective, coordinator_handle, coordinator_pane_key,
|
||||
consumer_generation, legacy
|
||||
) VALUES (?, ?, ?, ?, 1, 0)`
|
||||
)
|
||||
.run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey)
|
||||
this.rememberRunCoordinatorHandle(id, params.coordinatorHandle)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return this.getRun(id) as RunRow
|
||||
}
|
||||
|
||||
export type RunCreateMethods = {
|
||||
createRun: typeof createRun
|
||||
}
|
||||
|
||||
export function attachRunCreate(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createRun
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { MessageType, MessageRow, RunRow, DeliveryRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { generateId } from '../generated-id'
|
||||
import { exposeMessageListTimestamps, exposeDeliveryTimestamps } from '../utc-timestamp'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from '../messages/mailbox-routing-page'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function requireCurrentConsumer(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
consumerGeneration: number
|
||||
): RunRow {
|
||||
const run = this.getRunRaw(runId)
|
||||
if (!run || run.legacy === 1 || run.consumer_generation !== consumerGeneration) {
|
||||
throw new OrchestrationError(
|
||||
'consumer_fenced',
|
||||
'This mailbox consumer has been replaced. Rebind with orchestration run-use.'
|
||||
)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
export function getDeliveryRaw(this: OrchestrationDb, id: string): DeliveryRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM deliveries WHERE id = ?').get(id) as DeliveryRow | undefined
|
||||
}
|
||||
|
||||
export function getDeliveryMessages(this: OrchestrationDb, delivery: DeliveryRow): MessageRow[] {
|
||||
const ids = JSON.parse(delivery.message_ids) as string[]
|
||||
if (ids.length === 0) {
|
||||
return []
|
||||
}
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM messages WHERE id IN (${ids.map(() => '?').join(',')})`)
|
||||
.all(...ids) as MessageRow[]
|
||||
const byId = new Map(rows.map((row) => [row.id, row]))
|
||||
return exposeMessageListTimestamps(
|
||||
ids.map((id) => byId.get(id)).filter((row): row is MessageRow => row !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export function getOrCreateRunDelivery(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
consumerGeneration: number
|
||||
limit?: number
|
||||
wakeTypes?: MessageType[]
|
||||
}
|
||||
): { delivery: DeliveryRow; messages: MessageRow[]; replayed: boolean } | undefined {
|
||||
const limit = Math.min(
|
||||
Math.max(params.limit ?? ORCHESTRATION_DELIVERY_BATCH_LIMIT, 1),
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT
|
||||
)
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.requireCurrentConsumer(params.runId, params.consumerGeneration)
|
||||
const existing = this.db
|
||||
.prepare("SELECT * FROM deliveries WHERE run_id = ? AND status = 'outstanding'")
|
||||
.get(params.runId) as DeliveryRow | undefined
|
||||
if (existing) {
|
||||
if (existing.consumer_generation !== params.consumerGeneration) {
|
||||
throw new OrchestrationError(
|
||||
'consumer_fenced',
|
||||
'This mailbox Delivery belongs to a fenced consumer generation.'
|
||||
)
|
||||
}
|
||||
const messages = this.getDeliveryMessages(existing)
|
||||
this.db.exec('COMMIT')
|
||||
return { delivery: exposeDeliveryTimestamps(existing), messages, replayed: true }
|
||||
}
|
||||
|
||||
const address = `run:${params.runId}`
|
||||
if (params.wakeTypes && params.wakeTypes.length > 0) {
|
||||
const placeholders = params.wakeTypes.map(() => '?').join(',')
|
||||
const matching = this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
AND type IN (${placeholders}) LIMIT 1`
|
||||
)
|
||||
.get(params.runId, address, ...params.wakeTypes)
|
||||
if (!matching) {
|
||||
this.db.exec('COMMIT')
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const messages = exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE run_id = ? AND to_handle = ? AND read = 0
|
||||
AND delivery_contract = 'current_delivery'
|
||||
ORDER BY sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(params.runId, address, limit) as MessageRow[]
|
||||
)
|
||||
if (messages.length === 0) {
|
||||
this.db.exec('COMMIT')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const deliveryId = generateId('delivery')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO deliveries (id, run_id, consumer_generation, message_ids)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
deliveryId,
|
||||
params.runId,
|
||||
params.consumerGeneration,
|
||||
JSON.stringify(messages.map((message) => message.id))
|
||||
)
|
||||
const delivery = this.getDeliveryRaw(deliveryId) as DeliveryRow
|
||||
this.db.exec('COMMIT')
|
||||
return { delivery: exposeDeliveryTimestamps(delivery), messages, replayed: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function acknowledgeRunDelivery(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
consumerGeneration: number
|
||||
deliveryId: string
|
||||
}
|
||||
): { delivery: DeliveryRow; duplicate: boolean } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.requireCurrentConsumer(params.runId, params.consumerGeneration)
|
||||
const delivery = this.getDeliveryRaw(params.deliveryId)
|
||||
if (!delivery || delivery.run_id !== params.runId) {
|
||||
throw new OrchestrationError(
|
||||
'stale_delivery',
|
||||
`Delivery ${params.deliveryId} does not belong to this Run.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
delivery.consumer_generation !== params.consumerGeneration ||
|
||||
delivery.status === 'fenced'
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'consumer_fenced',
|
||||
'This mailbox Delivery belongs to a fenced consumer generation.'
|
||||
)
|
||||
}
|
||||
if (delivery.status === 'acknowledged') {
|
||||
this.db.exec('COMMIT')
|
||||
return { delivery: exposeDeliveryTimestamps(delivery), duplicate: true }
|
||||
}
|
||||
|
||||
const messageIds = JSON.parse(delivery.message_ids) as string[]
|
||||
if (messageIds.length > 0) {
|
||||
const placeholders = messageIds.map(() => '?').join(',')
|
||||
this.db
|
||||
.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`)
|
||||
.run(...messageIds)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE deliveries SET status = 'acknowledged', acknowledged_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.run(delivery.id)
|
||||
const acknowledged = this.getDeliveryRaw(delivery.id) as DeliveryRow
|
||||
this.db.exec('COMMIT')
|
||||
return { delivery: exposeDeliveryTimestamps(acknowledged), duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getRunMailboxHistory(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
limit = 100,
|
||||
types?: MessageType[]
|
||||
): MessageRow[] {
|
||||
const address = `run:${runId}`
|
||||
if (types && types.length > 0) {
|
||||
const placeholders = types.map(() => '?').join(',')
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages WHERE run_id = ? AND to_handle = ?
|
||||
AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?`
|
||||
)
|
||||
.all(runId, address, ...types, limit) as MessageRow[]
|
||||
)
|
||||
}
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages WHERE run_id = ? AND to_handle = ?
|
||||
ORDER BY sequence DESC LIMIT ?`
|
||||
)
|
||||
.all(runId, address, limit) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
export function getUnreadRunMailbox(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
limit = 100,
|
||||
types?: MessageType[]
|
||||
): MessageRow[] {
|
||||
const address = `run:${runId}`
|
||||
const conditions = [
|
||||
'run_id = ?',
|
||||
'to_handle = ?',
|
||||
'read = 0',
|
||||
"delivery_contract = 'current_delivery'"
|
||||
]
|
||||
const params: (string | number)[] = [runId, address]
|
||||
if (types?.length) {
|
||||
conditions.push(`type IN (${types.map(() => '?').join(',')})`)
|
||||
params.push(...types)
|
||||
}
|
||||
const indexClause = types?.length ? ' INDEXED BY idx_messages_unread_current_run_type' : ''
|
||||
params.push(Math.max(1, Math.floor(limit)))
|
||||
return exposeMessageListTimestamps(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages${indexClause} WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(...params) as MessageRow[]
|
||||
)
|
||||
}
|
||||
|
||||
export function hasOutstandingRunDelivery(this: OrchestrationDb, runId: string): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare("SELECT 1 FROM deliveries WHERE run_id = ? AND status = 'outstanding' LIMIT 1")
|
||||
.get(runId)
|
||||
)
|
||||
}
|
||||
|
||||
export type RunDeliveryMethods = {
|
||||
requireCurrentConsumer: typeof requireCurrentConsumer
|
||||
getDeliveryRaw: typeof getDeliveryRaw
|
||||
getDeliveryMessages: typeof getDeliveryMessages
|
||||
getOrCreateRunDelivery: typeof getOrCreateRunDelivery
|
||||
acknowledgeRunDelivery: typeof acknowledgeRunDelivery
|
||||
getRunMailboxHistory: typeof getRunMailboxHistory
|
||||
getUnreadRunMailbox: typeof getUnreadRunMailbox
|
||||
hasOutstandingRunDelivery: typeof hasOutstandingRunDelivery
|
||||
}
|
||||
|
||||
export function attachRunDelivery(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
requireCurrentConsumer,
|
||||
getDeliveryRaw,
|
||||
getDeliveryMessages,
|
||||
getOrCreateRunDelivery,
|
||||
acknowledgeRunDelivery,
|
||||
getRunMailboxHistory,
|
||||
getUnreadRunMailbox,
|
||||
hasOutstandingRunDelivery
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { RunRow } from '../../types'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../shared/orchestration-run-pagination'
|
||||
import {
|
||||
isEquivalentPaneKey,
|
||||
RUN_PANE_KEY_MATCH_SUFFIX_SQL,
|
||||
paneKeyMatchSuffix
|
||||
} from '../pane-key-match'
|
||||
import { exposeRunTimestamps } from '../utc-timestamp'
|
||||
import { encodeRunListCursor, decodeRunListCursor } from '../run-list-cursor'
|
||||
import type { RunListPage } from '../run-list-page'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getRun(this: OrchestrationDb, id: string): RunRow | undefined {
|
||||
const run = this.getRunRaw(id)
|
||||
return run ? exposeRunTimestamps(run) : undefined
|
||||
}
|
||||
|
||||
export function listRuns(
|
||||
this: OrchestrationDb,
|
||||
params: { limit?: number; cursor?: string } = {}
|
||||
): RunListPage {
|
||||
if (params.limit === undefined && params.cursor === undefined) {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC')
|
||||
.all() as RunRow[]
|
||||
return { runs: rows.map(exposeRunTimestamps), nextCursor: null }
|
||||
}
|
||||
const limit = Math.min(
|
||||
Math.max(1, params.limit ?? ORCHESTRATION_RUN_PAGE_LIMIT),
|
||||
ORCHESTRATION_RUN_PAGE_LIMIT
|
||||
)
|
||||
const cursor = params.cursor ? decodeRunListCursor(params.cursor) : undefined
|
||||
const rows = (
|
||||
cursor
|
||||
? this.db
|
||||
.prepare(
|
||||
`SELECT * FROM runs
|
||||
WHERE created_at < ? OR (created_at = ? AND id < ?)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(cursor.createdAt, cursor.createdAt, cursor.id, limit + 1)
|
||||
: this.db
|
||||
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?')
|
||||
.all(limit + 1)
|
||||
) as RunRow[]
|
||||
const hasMore = rows.length > limit
|
||||
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
||||
return {
|
||||
runs: pageRows.map(exposeRunTimestamps),
|
||||
nextCursor: hasMore ? encodeRunListCursor(pageRows.at(-1) as RunRow) : null
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentRunForPane(this: OrchestrationDb, paneKey: string): RunRow | undefined {
|
||||
const run = this.runsBoundToPane(paneKey)[0]
|
||||
return run ? exposeRunTimestamps(run) : undefined
|
||||
}
|
||||
|
||||
// Why: the indexed suffix only narrows candidates; isEquivalentPaneKey still decides, so
|
||||
// reminted tab halves keep matching and unparseable keys keep requiring an exact match.
|
||||
export function runsBoundToPane(this: OrchestrationDb, paneKey: string): RunRow[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM runs
|
||||
WHERE coordinator_pane_key IS NOT NULL AND legacy = 0
|
||||
AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid`
|
||||
)
|
||||
.all(paneKeyMatchSuffix(paneKey)) as RunRow[]
|
||||
).filter(
|
||||
(run) =>
|
||||
run.coordinator_pane_key !== null && isEquivalentPaneKey(run.coordinator_pane_key, paneKey)
|
||||
)
|
||||
}
|
||||
|
||||
export function getRunRaw(this: OrchestrationDb, id: string): RunRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id) as RunRow | undefined
|
||||
}
|
||||
|
||||
export function unbindOtherRunsForPane(
|
||||
this: OrchestrationDb,
|
||||
paneKey: string,
|
||||
exceptRunId?: string
|
||||
): void {
|
||||
for (const run of this.runsBoundToPane(paneKey)) {
|
||||
if (run.id !== exceptRunId) {
|
||||
if (run.coordinator_handle) {
|
||||
this.routeAllUnreadDirectMessagesToRunMailbox(run.id, run.coordinator_handle)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE runs
|
||||
SET coordinator_handle = NULL, coordinator_pane_key = NULL,
|
||||
consumer_generation = consumer_generation + 1,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(run.id)
|
||||
this.fenceOutstandingDelivery(run.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRun(this: OrchestrationDb, runId: string): void {
|
||||
if (!this.getRunRaw(runId)) {
|
||||
throw new Error(`Run not found: ${runId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function fenceOutstandingDelivery(this: OrchestrationDb, runId: string): void {
|
||||
this.db
|
||||
.prepare("UPDATE deliveries SET status = 'fenced' WHERE run_id = ? AND status = 'outstanding'")
|
||||
.run(runId)
|
||||
}
|
||||
|
||||
export type RunLookupMethods = {
|
||||
getRun: typeof getRun
|
||||
listRuns: typeof listRuns
|
||||
getCurrentRunForPane: typeof getCurrentRunForPane
|
||||
runsBoundToPane: typeof runsBoundToPane
|
||||
getRunRaw: typeof getRunRaw
|
||||
unbindOtherRunsForPane: typeof unbindOtherRunsForPane
|
||||
requireRun: typeof requireRun
|
||||
fenceOutstandingDelivery: typeof fenceOutstandingDelivery
|
||||
}
|
||||
|
||||
export function attachRunLookup(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getRun,
|
||||
listRuns,
|
||||
getCurrentRunForPane,
|
||||
runsBoundToPane,
|
||||
getRunRaw,
|
||||
unbindOtherRunsForPane,
|
||||
requireRun,
|
||||
fenceOutstandingDelivery
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { LegacyAdoptionRow } from '../../types'
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function adoptLegacyRunIfNeeded(this: OrchestrationDb): void {
|
||||
const existing = this.db
|
||||
.prepare('SELECT * FROM legacy_adoptions WHERE source_run_id = ?')
|
||||
.get(LEGACY_RUN_ID) as LegacyAdoptionRow | undefined
|
||||
const hasGraph = this.db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
WHERE EXISTS(SELECT 1 FROM tasks WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM dispatch_contexts WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM decision_gates WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM messages WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM question_threads WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM deliveries WHERE run_id = ?)`
|
||||
)
|
||||
.get(LEGACY_RUN_ID, LEGACY_RUN_ID, LEGACY_RUN_ID, LEGACY_RUN_ID, LEGACY_RUN_ID, LEGACY_RUN_ID)
|
||||
if (!existing && !hasGraph) {
|
||||
return
|
||||
}
|
||||
|
||||
const adoptedRunId = existing?.adopted_run_id ?? generateId('run')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO runs (
|
||||
id, objective, home_database, consumer_generation, legacy
|
||||
) VALUES (?, ?, 'this_database', 0, 0)`
|
||||
)
|
||||
.run(adoptedRunId, 'Recovered orchestration work from a contract update')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO legacy_adoptions (
|
||||
source_run_id, adopted_run_id, scheduler_state_lost
|
||||
) VALUES (?, ?, 1)`
|
||||
)
|
||||
.run(LEGACY_RUN_ID, adoptedRunId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE coordinator_runs
|
||||
SET status = 'failed',
|
||||
completed_at = COALESCE(
|
||||
completed_at,
|
||||
(SELECT adopted_at FROM legacy_adoptions WHERE source_run_id = ?)
|
||||
),
|
||||
scheduler_lost_at = (
|
||||
SELECT adopted_at FROM legacy_adoptions WHERE source_run_id = ?
|
||||
)
|
||||
WHERE status = 'running'
|
||||
AND julianday(created_at) <= julianday((
|
||||
SELECT adopted_at FROM legacy_adoptions WHERE source_run_id = ?
|
||||
))`
|
||||
)
|
||||
.run(LEGACY_RUN_ID, LEGACY_RUN_ID, LEGACY_RUN_ID)
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE deliveries SET status = 'fenced'
|
||||
WHERE run_id = ? AND status = 'outstanding'`
|
||||
)
|
||||
.run(LEGACY_RUN_ID)
|
||||
for (const table of [
|
||||
'tasks',
|
||||
'dispatch_contexts',
|
||||
'decision_gates',
|
||||
'messages',
|
||||
'question_threads',
|
||||
'deliveries'
|
||||
]) {
|
||||
this.db
|
||||
.prepare(`UPDATE ${table} SET run_id = ? WHERE run_id = ?`)
|
||||
.run(adoptedRunId, LEGACY_RUN_ID)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE runs
|
||||
SET objective = 'Legacy orchestration state (adopted; inspect only)',
|
||||
coordinator_handle = NULL, coordinator_pane_key = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(LEGACY_RUN_ID)
|
||||
|
||||
const mismatch = this.db
|
||||
.prepare(
|
||||
`WITH migration_runs(run_id) AS (VALUES (?), (?))
|
||||
SELECT 1
|
||||
WHERE EXISTS(
|
||||
SELECT 1 FROM dispatch_contexts d
|
||||
INNER JOIN tasks t ON t.id = d.task_id
|
||||
WHERE d.run_id <> t.run_id
|
||||
AND (
|
||||
d.run_id IN (SELECT run_id FROM migration_runs)
|
||||
OR t.run_id IN (SELECT run_id FROM migration_runs)
|
||||
)
|
||||
)
|
||||
OR EXISTS(
|
||||
SELECT 1 FROM decision_gates g
|
||||
INNER JOIN tasks t ON t.id = g.task_id
|
||||
WHERE g.run_id <> t.run_id
|
||||
AND (
|
||||
g.run_id IN (SELECT run_id FROM migration_runs)
|
||||
OR t.run_id IN (SELECT run_id FROM migration_runs)
|
||||
)
|
||||
)
|
||||
OR EXISTS(
|
||||
SELECT 1 FROM question_threads q
|
||||
INNER JOIN dispatch_contexts d ON d.id = q.dispatch_id
|
||||
WHERE q.run_id <> d.run_id
|
||||
AND (
|
||||
q.run_id IN (SELECT run_id FROM migration_runs)
|
||||
OR d.run_id IN (SELECT run_id FROM migration_runs)
|
||||
)
|
||||
)
|
||||
OR EXISTS(
|
||||
SELECT 1 FROM deliveries d
|
||||
INNER JOIN json_each(d.message_ids) ids
|
||||
INNER JOIN messages m ON m.id = ids.value
|
||||
WHERE d.run_id <> m.run_id
|
||||
AND (
|
||||
d.run_id IN (SELECT run_id FROM migration_runs)
|
||||
OR m.run_id IN (SELECT run_id FROM migration_runs)
|
||||
)
|
||||
)`
|
||||
)
|
||||
.get(LEGACY_RUN_ID, adoptedRunId)
|
||||
if (mismatch) {
|
||||
throw new Error('Legacy orchestration adoption produced inconsistent Run ownership.')
|
||||
}
|
||||
}
|
||||
|
||||
export type AdoptLegacyRunMethods = {
|
||||
adoptLegacyRunIfNeeded: typeof adoptLegacyRunIfNeeded
|
||||
}
|
||||
|
||||
export function attachAdoptLegacyRun(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
adoptLegacyRunIfNeeded
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { LEGACY_RUN_ID, LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function backfillLegacyQuestionThreads(this: OrchestrationDb): void {
|
||||
const messages = this.db
|
||||
.prepare(
|
||||
`SELECT id, run_id, from_handle, to_handle, payload, created_at, sequence
|
||||
FROM messages
|
||||
WHERE type = 'decision_gate'
|
||||
AND delivery_contract IN ('legacy_direct', 'current_delivery')
|
||||
ORDER BY sequence`
|
||||
)
|
||||
.all() as {
|
||||
id: string
|
||||
run_id: string
|
||||
from_handle: string
|
||||
to_handle: string
|
||||
payload: string | null
|
||||
created_at: string
|
||||
sequence: number
|
||||
}[]
|
||||
const getDispatch = this.db.prepare(
|
||||
'SELECT id, run_id, task_id FROM dispatch_contexts WHERE id = ? AND contract_version = ?'
|
||||
)
|
||||
const getDispatchesForLegacyQuestion = this.db.prepare(
|
||||
`SELECT id, run_id, task_id
|
||||
FROM dispatch_contexts
|
||||
WHERE contract_version = ? AND assignee_handle = ?
|
||||
AND (? IS NULL OR task_id = ?)
|
||||
AND created_at <= ?
|
||||
AND (completed_at IS NULL OR completed_at >= ?)
|
||||
ORDER BY rowid
|
||||
LIMIT 2`
|
||||
)
|
||||
const getAnswer = this.db.prepare(
|
||||
`SELECT id, body, created_at
|
||||
FROM messages
|
||||
WHERE run_id = ?
|
||||
AND thread_id = ?
|
||||
AND delivery_contract IN ('legacy_direct', 'current_delivery')
|
||||
AND from_handle = ?
|
||||
AND to_handle IN (?, ?)
|
||||
AND sequence > ?
|
||||
ORDER BY sequence
|
||||
LIMIT 1`
|
||||
)
|
||||
const insert = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO question_threads (
|
||||
message_id, run_id, dispatch_id, asker_handle, status,
|
||||
answer_message_id, answer_body, created_at, answered_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
for (const message of messages) {
|
||||
let payload: { taskId?: unknown; dispatchId?: unknown }
|
||||
try {
|
||||
payload = JSON.parse(message.payload ?? '{}') as {
|
||||
taskId?: unknown
|
||||
dispatchId?: unknown
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const inferredDispatches =
|
||||
typeof payload.dispatchId === 'string'
|
||||
? []
|
||||
: (getDispatchesForLegacyQuestion.all(
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
message.from_handle,
|
||||
typeof payload.taskId === 'string' ? payload.taskId : null,
|
||||
typeof payload.taskId === 'string' ? payload.taskId : null,
|
||||
message.created_at,
|
||||
message.created_at
|
||||
) as { id: string; run_id: string; task_id: string }[])
|
||||
const dispatch =
|
||||
typeof payload.dispatchId === 'string'
|
||||
? (getDispatch.get(payload.dispatchId, LEGACY_CONTRACT_VERSION) as
|
||||
| { id: string; run_id: string; task_id: string }
|
||||
| undefined)
|
||||
: inferredDispatches.length === 1
|
||||
? inferredDispatches[0]
|
||||
: undefined
|
||||
if (
|
||||
!dispatch ||
|
||||
(typeof payload.taskId === 'string' && payload.taskId !== dispatch.task_id) ||
|
||||
(message.run_id !== LEGACY_RUN_ID && message.run_id !== dispatch.run_id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const answer = getAnswer.get(
|
||||
message.run_id,
|
||||
message.id,
|
||||
message.to_handle,
|
||||
message.from_handle,
|
||||
`dispatch:${dispatch.id}`,
|
||||
message.sequence
|
||||
) as { id: string; body: string; created_at: string } | undefined
|
||||
insert.run(
|
||||
message.id,
|
||||
dispatch.run_id,
|
||||
dispatch.id,
|
||||
message.from_handle,
|
||||
answer ? 'answered' : 'pending',
|
||||
answer?.id ?? null,
|
||||
answer?.body ?? null,
|
||||
message.created_at,
|
||||
answer?.created_at ?? null
|
||||
)
|
||||
}
|
||||
const adoption = this.getLegacyAdoption()
|
||||
const coordinator = adoption
|
||||
? this.getLegacyCoordinatorPrincipal(adoption.adopted_run_id)
|
||||
: undefined
|
||||
if (adoption && coordinator?.status === 'revoked') {
|
||||
this.promoteLegacyCoordinatorMailForTakeover(
|
||||
adoption.adopted_run_id,
|
||||
coordinator.terminal_handle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type BackfillLegacyQuestionThreadsMethods = {
|
||||
backfillLegacyQuestionThreads: typeof backfillLegacyQuestionThreads
|
||||
}
|
||||
|
||||
export function attachBackfillLegacyQuestionThreads(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
backfillLegacyQuestionThreads
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
|
||||
export function createCoreTablesSql(): string {
|
||||
return `
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
objective TEXT NOT NULL,
|
||||
home_database TEXT NOT NULL DEFAULT 'this_database',
|
||||
coordinator_handle TEXT,
|
||||
coordinator_pane_key TEXT,
|
||||
consumer_generation INTEGER NOT NULL DEFAULT 0,
|
||||
legacy INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}',
|
||||
delivery_contract TEXT NOT NULL DEFAULT 'current_delivery'
|
||||
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only')),
|
||||
from_handle TEXT NOT NULL,
|
||||
to_handle TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'status'
|
||||
CHECK(type IN (
|
||||
'status', 'dispatch', 'worker_done', 'merge_ready',
|
||||
'escalation', 'handoff', 'decision_gate', 'question', 'heartbeat'
|
||||
)),
|
||||
priority TEXT NOT NULL DEFAULT 'normal'
|
||||
CHECK(priority IN ('normal', 'high', 'urgent')),
|
||||
thread_id TEXT,
|
||||
payload TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
delivered_at TEXT,
|
||||
sender_pane_key TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_id ON messages(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox ON messages(to_handle, read);
|
||||
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_coordinator_handles (
|
||||
run_id TEXT NOT NULL,
|
||||
terminal_handle TEXT NOT NULL,
|
||||
first_bound_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (run_id, terminal_handle)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_run_coordinator_handles_handle
|
||||
ON run_coordinator_handles(terminal_handle, run_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_insert
|
||||
AFTER INSERT ON runs
|
||||
WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
|
||||
VALUES (NEW.id, NEW.coordinator_handle);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_update
|
||||
AFTER UPDATE OF coordinator_handle ON runs
|
||||
WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
|
||||
VALUES (NEW.id, NEW.coordinator_handle);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_runs_forget_coordinator_handles
|
||||
AFTER DELETE ON runs
|
||||
BEGIN
|
||||
DELETE FROM run_coordinator_handles WHERE run_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
consumer_generation INTEGER NOT NULL,
|
||||
message_ids TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'outstanding'
|
||||
CHECK(status IN ('outstanding', 'acknowledged', 'fenced')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
acknowledged_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_deliveries_one_outstanding
|
||||
ON deliveries(run_id) WHERE status = 'outstanding';
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_run_created
|
||||
ON deliveries(run_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mutation_receipts (
|
||||
caller_fingerprint TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(state IN ('pending', 'completed')),
|
||||
receipt TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (caller_fingerprint, request_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mutation_caller_identities (
|
||||
transport TEXT PRIMARY KEY,
|
||||
caller_fingerprint TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_dispatches (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
runtime_epoch TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'starting'
|
||||
CHECK(state IN (
|
||||
'starting', 'ready', 'start_unknown', 'failed', 'succeeded',
|
||||
'stopping', 'stop_unknown', 'stopped', 'abandoned'
|
||||
)),
|
||||
stage TEXT NOT NULL DEFAULT 'accepted',
|
||||
worktree_id TEXT,
|
||||
agent_terminal_handle TEXT,
|
||||
setup_state TEXT NOT NULL DEFAULT 'not_applicable',
|
||||
effects TEXT NOT NULL DEFAULT '[]',
|
||||
residual_resources TEXT NOT NULL DEFAULT '[]',
|
||||
start_options TEXT NOT NULL DEFAULT '{}',
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_terminal_resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
origin_dispatch_id TEXT NOT NULL,
|
||||
owner_dispatch_id TEXT NOT NULL,
|
||||
prior_owner_dispatch_ids TEXT NOT NULL DEFAULT '[]',
|
||||
worktree_id TEXT,
|
||||
terminal_handle TEXT NOT NULL,
|
||||
pane_key TEXT,
|
||||
process_incarnation TEXT,
|
||||
host_scope TEXT,
|
||||
ownership_state TEXT NOT NULL DEFAULT 'owned'
|
||||
CHECK(ownership_state IN ('owned', 'transferred', 'user_owned', 'external', 'released')),
|
||||
release_state TEXT NOT NULL DEFAULT 'not_requested'
|
||||
CHECK(release_state IN (
|
||||
'not_requested', 'retained', 'requested', 'releasing', 'released', 'unknown'
|
||||
)),
|
||||
retained_reason TEXT,
|
||||
release_requested_at TEXT,
|
||||
release_completed_at TEXT,
|
||||
release_error TEXT,
|
||||
archive_source TEXT,
|
||||
archive_status TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_terminal_resources_owner
|
||||
ON worker_terminal_resources(owner_dispatch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_handle
|
||||
ON worker_terminal_resources(terminal_handle);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_pane
|
||||
ON worker_terminal_resources(pane_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_identity
|
||||
ON worker_terminal_resources(process_incarnation, host_scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_release
|
||||
ON worker_terminal_resources(release_state);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_terminal_archives (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
resource_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail')),
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { CURRENT_CONTRACT_VERSION, LEGACY_RUN_ID } from '../contract-constants'
|
||||
import {
|
||||
REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL,
|
||||
RUN_PANE_KEY_MATCH_SUFFIX_SQL
|
||||
} from '../pane-key-match'
|
||||
|
||||
export function createGraphTablesSql(): string {
|
||||
return `
|
||||
CREATE TABLE IF NOT EXISTS federated_dispatches (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
environment_id TEXT NOT NULL,
|
||||
environment_name TEXT NOT NULL,
|
||||
peer_fingerprint TEXT NOT NULL,
|
||||
remote_runtime_epoch TEXT,
|
||||
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||
remote_worktree_id TEXT,
|
||||
remote_terminal_handle TEXT,
|
||||
to_home_imported_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
to_home_acknowledged_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_dispatch_attachments (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
home_peer_fingerprint TEXT NOT NULL,
|
||||
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||
runtime_epoch TEXT NOT NULL,
|
||||
capability_hash TEXT,
|
||||
pane_key TEXT,
|
||||
process_incarnation TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'starting'
|
||||
CHECK(state IN (
|
||||
'starting', 'ready', 'start_unknown', 'failed', 'succeeded',
|
||||
'stopping', 'stop_unknown', 'stopped', 'abandoned'
|
||||
)),
|
||||
stage TEXT NOT NULL DEFAULT 'accepted',
|
||||
worktree_id TEXT,
|
||||
terminal_handle TEXT,
|
||||
setup_state TEXT NOT NULL DEFAULT 'not_applicable',
|
||||
effects TEXT NOT NULL DEFAULT '[]',
|
||||
residual_resources TEXT NOT NULL DEFAULT '[]',
|
||||
to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane
|
||||
ON remote_dispatch_attachments(pane_key)
|
||||
WHERE state IN ('starting', 'ready');
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_dispatch_attachments_active_pane_suffix
|
||||
ON remote_dispatch_attachments(${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_relay_items (
|
||||
dispatch_id TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('to_home', 'to_worker')),
|
||||
sequence INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
acked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (dispatch_id, direction, sequence),
|
||||
UNIQUE (dispatch_id, direction, message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_relay_pending
|
||||
ON federation_relay_items(dispatch_id, direction, acked_at, sequence);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_questions (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
dispatch_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'answered', 'closed')),
|
||||
answer_message_id TEXT,
|
||||
answer_body TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
answered_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_questions_dispatch_status
|
||||
ON remote_questions(dispatch_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}',
|
||||
parent_id TEXT,
|
||||
created_by_terminal_handle TEXT,
|
||||
created_by_pane_key TEXT,
|
||||
created_by_process_incarnation TEXT,
|
||||
created_by_run_generation INTEGER,
|
||||
task_title TEXT,
|
||||
display_name TEXT,
|
||||
spec TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN (
|
||||
'pending', 'ready', 'dispatched',
|
||||
'completed', 'failed', 'blocked'
|
||||
)),
|
||||
deps TEXT NOT NULL DEFAULT '[]',
|
||||
result TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dispatch_contexts (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}',
|
||||
task_id TEXT NOT NULL,
|
||||
contract_version INTEGER NOT NULL DEFAULT ${CURRENT_CONTRACT_VERSION},
|
||||
launch_token_hash TEXT,
|
||||
assignee_handle TEXT,
|
||||
assignee_pane_key TEXT,
|
||||
capability_hash TEXT,
|
||||
process_incarnation TEXT,
|
||||
capability_revoked_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')),
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failure TEXT,
|
||||
dispatched_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_heartbeat_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle ON dispatch_contexts(assignee_handle);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_gates (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}',
|
||||
task_id TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'resolved', 'timeout')),
|
||||
resolution TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gates_task ON decision_gates(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_coordinator_pane_leaf
|
||||
ON runs(${RUN_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE coordinator_pane_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coordinator_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
spec TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'idle'
|
||||
CHECK(status IN ('idle', 'running', 'completed', 'failed')),
|
||||
coordinator_handle TEXT NOT NULL,
|
||||
poll_interval_ms INTEGER NOT NULL DEFAULT 2000,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
scheduler_lost_at TEXT
|
||||
);
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
import { createCoreTablesSql } from './create-core-tables-sql'
|
||||
import { createGraphTablesSql } from './create-graph-tables-sql'
|
||||
|
||||
export function createTables(this: OrchestrationDb): void {
|
||||
this.db.exec(`${createCoreTablesSql()}\n${createGraphTablesSql()}`)
|
||||
this.createMailboxDeliveryIndexesIfPossible()
|
||||
}
|
||||
|
||||
export type CreateTablesMethods = {
|
||||
createTables: typeof createTables
|
||||
}
|
||||
|
||||
export function attachCreateTables(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createTables
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
LEGACY_RUN_ID,
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
CURRENT_CONTRACT_VERSION
|
||||
} from '../contract-constants'
|
||||
import { hasLifecycleRejectionMarker } from '../lifecycle-rejection-marker'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function migrateLegacyContractStorage(this: OrchestrationDb): void {
|
||||
if (!this.hasColumn('dispatch_contexts', 'contract_version')) {
|
||||
this.db.exec(
|
||||
`ALTER TABLE dispatch_contexts
|
||||
ADD COLUMN contract_version INTEGER NOT NULL DEFAULT ${CURRENT_CONTRACT_VERSION}`
|
||||
)
|
||||
}
|
||||
if (!this.hasColumn('dispatch_contexts', 'launch_token_hash')) {
|
||||
this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN launch_token_hash TEXT')
|
||||
}
|
||||
if (!this.hasColumn('messages', 'delivery_contract')) {
|
||||
this.db.exec(
|
||||
`ALTER TABLE messages
|
||||
ADD COLUMN delivery_contract TEXT NOT NULL DEFAULT 'current_delivery'
|
||||
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))`
|
||||
)
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_delivery_contract
|
||||
ON messages(run_id, delivery_contract, to_handle, read, sequence);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legacy_adoptions (
|
||||
source_run_id TEXT PRIMARY KEY,
|
||||
adopted_run_id TEXT UNIQUE NOT NULL,
|
||||
scheduler_state_lost INTEGER NOT NULL,
|
||||
adopted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legacy_compatibility_principals (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
dispatch_id TEXT,
|
||||
role TEXT NOT NULL CHECK(role IN ('worker', 'coordinator')),
|
||||
host_scope TEXT NOT NULL,
|
||||
terminal_handle TEXT NOT NULL,
|
||||
pane_key TEXT NOT NULL,
|
||||
launch_token_hash TEXT NOT NULL,
|
||||
process_incarnation TEXT,
|
||||
status TEXT NOT NULL
|
||||
CHECK(status IN ('committed', 'settled', 'revoked')),
|
||||
CHECK(
|
||||
(role = 'worker' AND dispatch_id IS NOT NULL) OR
|
||||
(role = 'coordinator' AND dispatch_id IS NULL)
|
||||
),
|
||||
UNIQUE(role, run_id, dispatch_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_legacy_principal_coordinator
|
||||
ON legacy_compatibility_principals(run_id)
|
||||
WHERE role = 'coordinator';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_legacy_principal_dispatch
|
||||
ON legacy_compatibility_principals(dispatch_id)
|
||||
WHERE role = 'worker';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legacy_operation_receipts (
|
||||
principal_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY(principal_id, operation_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS legacy_mail_receipts (
|
||||
principal_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
acknowledged_at TEXT,
|
||||
PRIMARY KEY(principal_id, message_id)
|
||||
);
|
||||
`)
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET contract_version = ?
|
||||
WHERE run_id = ? AND capability_hash IS NULL`
|
||||
)
|
||||
.run(LEGACY_CONTRACT_VERSION, LEGACY_RUN_ID)
|
||||
this.classifyLegacyMessageContracts(LEGACY_RUN_ID, false)
|
||||
this.ensureLegacySchedulerLossColumn()
|
||||
this.adoptLegacyRunIfNeeded()
|
||||
}
|
||||
|
||||
export function classifyLegacyMessageContracts(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
adoptedOnly: boolean
|
||||
): void {
|
||||
const contractFilter = adoptedOnly
|
||||
? " AND delivery_contract IN ('legacy_direct', 'audit_only')"
|
||||
: ''
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages SET delivery_contract = 'legacy_direct'
|
||||
WHERE run_id = ?${contractFilter}`
|
||||
)
|
||||
.run(runId)
|
||||
const rows = this.db
|
||||
.prepare(`SELECT id, payload FROM messages WHERE run_id = ?${contractFilter}`)
|
||||
.all(runId) as { id: string; payload: string | null }[]
|
||||
const markAuditOnly = this.db.prepare(
|
||||
"UPDATE messages SET delivery_contract = 'audit_only' WHERE id = ? AND run_id = ?"
|
||||
)
|
||||
for (const row of rows) {
|
||||
if (hasLifecycleRejectionMarker(row.payload)) {
|
||||
markAuditOnly.run(row.id, runId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateLegacySchedulerLossProvenance(this: OrchestrationDb): void {
|
||||
this.ensureLegacySchedulerLossColumn()
|
||||
this.adoptLegacyRunIfNeeded()
|
||||
const adoption = this.getLegacyAdoption()
|
||||
if (adoption) {
|
||||
this.classifyLegacyMessageContracts(adoption.adopted_run_id, true)
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureLegacySchedulerLossColumn(this: OrchestrationDb): void {
|
||||
if (!this.hasColumn('coordinator_runs', 'scheduler_lost_at')) {
|
||||
this.db.exec('ALTER TABLE coordinator_runs ADD COLUMN scheduler_lost_at TEXT')
|
||||
}
|
||||
}
|
||||
|
||||
export type MigrateLegacyContractStorageMethods = {
|
||||
migrateLegacyContractStorage: typeof migrateLegacyContractStorage
|
||||
classifyLegacyMessageContracts: typeof classifyLegacyMessageContracts
|
||||
migrateLegacySchedulerLossProvenance: typeof migrateLegacySchedulerLossProvenance
|
||||
ensureLegacySchedulerLossColumn: typeof ensureLegacySchedulerLossColumn
|
||||
}
|
||||
|
||||
export function attachMigrateLegacyContractStorage(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
migrateLegacyContractStorage,
|
||||
classifyLegacyMessageContracts,
|
||||
migrateLegacySchedulerLossProvenance,
|
||||
ensureLegacySchedulerLossColumn
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { migrateMutationReceiptCapacity } from '../../mutation-receipt-capacity'
|
||||
import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function applySchemaMigrationsV13ToV27(this: OrchestrationDb, current: number): void {
|
||||
if (current < 13 && !this.hasColumn('worker_dispatches', 'runtime_epoch')) {
|
||||
this.db.exec('ALTER TABLE worker_dispatches ADD COLUMN runtime_epoch TEXT')
|
||||
}
|
||||
if (current < 14) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS federated_dispatches (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
environment_id TEXT NOT NULL,
|
||||
environment_name TEXT NOT NULL,
|
||||
peer_fingerprint TEXT NOT NULL,
|
||||
remote_runtime_epoch TEXT,
|
||||
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||
remote_worktree_id TEXT,
|
||||
remote_terminal_handle TEXT,
|
||||
to_home_imported_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
to_home_acknowledged_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS remote_dispatch_attachments (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
home_peer_fingerprint TEXT NOT NULL,
|
||||
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||
runtime_epoch TEXT NOT NULL,
|
||||
capability_hash TEXT,
|
||||
pane_key TEXT,
|
||||
process_incarnation TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'starting'
|
||||
CHECK(state IN (
|
||||
'starting', 'ready', 'start_unknown', 'failed', 'succeeded',
|
||||
'stopping', 'stop_unknown', 'stopped', 'abandoned'
|
||||
)),
|
||||
stage TEXT NOT NULL DEFAULT 'accepted',
|
||||
worktree_id TEXT,
|
||||
terminal_handle TEXT,
|
||||
setup_state TEXT NOT NULL DEFAULT 'not_applicable',
|
||||
effects TEXT NOT NULL DEFAULT '[]',
|
||||
residual_resources TEXT NOT NULL DEFAULT '[]',
|
||||
to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
}
|
||||
if (current < 15) {
|
||||
if (!this.hasColumn('federated_dispatches', 'to_home_imported_sequence')) {
|
||||
this.db.exec(
|
||||
'ALTER TABLE federated_dispatches ADD COLUMN to_home_imported_sequence INTEGER NOT NULL DEFAULT 0'
|
||||
)
|
||||
}
|
||||
if (!this.hasColumn('remote_dispatch_attachments', 'to_worker_imported_sequence')) {
|
||||
this.db.exec(
|
||||
'ALTER TABLE remote_dispatch_attachments ADD COLUMN to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0'
|
||||
)
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS federation_relay_items (
|
||||
dispatch_id TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('to_home', 'to_worker')),
|
||||
sequence INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
acked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (dispatch_id, direction, sequence),
|
||||
UNIQUE (dispatch_id, direction, message_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_relay_pending
|
||||
ON federation_relay_items(dispatch_id, direction, acked_at, sequence);
|
||||
`)
|
||||
}
|
||||
if (current < 16) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS remote_questions (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
dispatch_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'answered', 'closed')),
|
||||
answer_message_id TEXT,
|
||||
answer_body TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
answered_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_questions_dispatch_status
|
||||
ON remote_questions(dispatch_id, status);
|
||||
`)
|
||||
}
|
||||
if (current < 17 && !this.hasColumn('remote_dispatch_attachments', 'protocol_version')) {
|
||||
this.db.exec(
|
||||
'ALTER TABLE remote_dispatch_attachments ADD COLUMN protocol_version INTEGER NOT NULL DEFAULT 1'
|
||||
)
|
||||
}
|
||||
if (current < 19) {
|
||||
this.migrateLegacyContractStorage()
|
||||
}
|
||||
if (current < 20) {
|
||||
this.backfillLegacyQuestionThreads()
|
||||
}
|
||||
if (current < 21) {
|
||||
this.migrateLegacySchedulerLossProvenance()
|
||||
}
|
||||
if (current < 22) {
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle
|
||||
ON dispatch_contexts(assignee_handle);
|
||||
`)
|
||||
}
|
||||
if (current < 23) {
|
||||
this.backfillWorkerTerminalResources()
|
||||
}
|
||||
if (current < 24) {
|
||||
if (!this.hasColumn('tasks', 'created_by_pane_key')) {
|
||||
this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_pane_key TEXT')
|
||||
}
|
||||
if (!this.hasColumn('tasks', 'created_by_process_incarnation')) {
|
||||
this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_process_incarnation TEXT')
|
||||
}
|
||||
if (!this.hasColumn('tasks', 'created_by_run_generation')) {
|
||||
this.db.exec('ALTER TABLE tasks ADD COLUMN created_by_run_generation INTEGER')
|
||||
}
|
||||
}
|
||||
if (current < 25) {
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_assignee_handle
|
||||
ON dispatch_contexts(assignee_handle)
|
||||
WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
`)
|
||||
}
|
||||
if (current < 26) {
|
||||
migrateMutationReceiptCapacity(this.db)
|
||||
}
|
||||
if (current < 27 && !this.hasColumn('federated_dispatches', 'to_home_acknowledged_sequence')) {
|
||||
this.db.exec(
|
||||
'ALTER TABLE federated_dispatches ADD COLUMN to_home_acknowledged_sequence INTEGER NOT NULL DEFAULT 0'
|
||||
)
|
||||
}
|
||||
if (current < 28) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mutation_caller_identities (
|
||||
transport TEXT PRIMARY KEY,
|
||||
caller_fingerprint TEXT NOT NULL UNIQUE
|
||||
);
|
||||
`)
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf
|
||||
ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_run_assignee_handle
|
||||
ON dispatch_contexts(run_id, assignee_handle)
|
||||
WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_run_pane_leaf
|
||||
ON dispatch_contexts(run_id, ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL})
|
||||
WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_active_assignee_pane_key
|
||||
ON dispatch_contexts(assignee_pane_key)
|
||||
WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched');
|
||||
`)
|
||||
this.createMailboxDeliveryIndexesIfPossible()
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function applySchemaMigrationsV2ToV12(this: OrchestrationDb, current: number): void {
|
||||
// v1 → v2: SQLite can't ALTER a CHECK, so rebuild messages to allow 'heartbeat'; fold in v3's delivered_at to skip a second rebuild.
|
||||
if (current < 2) {
|
||||
if (!this.hasColumn('dispatch_contexts', 'last_heartbeat_at')) {
|
||||
this.db.exec(`ALTER TABLE dispatch_contexts ADD COLUMN last_heartbeat_at TEXT`)
|
||||
}
|
||||
|
||||
if (!this.messagesTypeCheckAllowsHeartbeat()) {
|
||||
// Why: recreate indexes here — DROP TABLE drops them; createTables re-runs only next startup, so skipping full-scans until restart.
|
||||
this.db.exec(`
|
||||
CREATE TABLE messages_new (
|
||||
id TEXT NOT NULL,
|
||||
from_handle TEXT NOT NULL,
|
||||
to_handle TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'status'
|
||||
CHECK(type IN (
|
||||
'status', 'dispatch', 'worker_done', 'merge_ready',
|
||||
'escalation', 'handoff', 'decision_gate', 'question', 'heartbeat'
|
||||
)),
|
||||
priority TEXT NOT NULL DEFAULT 'normal'
|
||||
CHECK(priority IN ('normal', 'high', 'urgent')),
|
||||
thread_id TEXT,
|
||||
payload TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
delivered_at TEXT
|
||||
);
|
||||
INSERT INTO messages_new (
|
||||
id, from_handle, to_handle, subject, body, type, priority,
|
||||
thread_id, payload, read, sequence, created_at
|
||||
)
|
||||
SELECT
|
||||
id, from_handle, to_handle, subject, body, type, priority,
|
||||
thread_id, payload, read, sequence, created_at
|
||||
FROM messages;
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
|
||||
CREATE UNIQUE INDEX idx_messages_id ON messages(id);
|
||||
CREATE INDEX idx_inbox ON messages(to_handle, read);
|
||||
CREATE INDEX idx_messages_undelivered_inbox
|
||||
ON messages(to_handle, read, delivered_at, sequence);
|
||||
CREATE INDEX idx_thread ON messages(thread_id);
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// v2 → v3: add messages.delivered_at. hasColumn probe skips DBs that already got it via the v1→v2 rebuild (else a dup-column error aborts the txn).
|
||||
if (current < 3) {
|
||||
if (!this.hasColumn('messages', 'delivered_at')) {
|
||||
this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`)
|
||||
}
|
||||
}
|
||||
if (current < 4) {
|
||||
if (!this.hasColumn('tasks', 'created_by_terminal_handle')) {
|
||||
this.db.exec(`ALTER TABLE tasks ADD COLUMN created_by_terminal_handle TEXT`)
|
||||
}
|
||||
}
|
||||
if (current < 5) {
|
||||
if (!this.hasColumn('tasks', 'task_title')) {
|
||||
this.db.exec(`ALTER TABLE tasks ADD COLUMN task_title TEXT`)
|
||||
}
|
||||
if (!this.hasColumn('tasks', 'display_name')) {
|
||||
this.db.exec(`ALTER TABLE tasks ADD COLUMN display_name TEXT`)
|
||||
}
|
||||
}
|
||||
if (current < 6) {
|
||||
if (!this.hasColumn('dispatch_contexts', 'assignee_pane_key')) {
|
||||
this.db.exec(`ALTER TABLE dispatch_contexts ADD COLUMN assignee_pane_key TEXT`)
|
||||
}
|
||||
if (!this.hasColumn('messages', 'sender_pane_key')) {
|
||||
this.db.exec(`ALTER TABLE messages ADD COLUMN sender_pane_key TEXT`)
|
||||
}
|
||||
}
|
||||
if (current < 7) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO runs (
|
||||
id, objective, home_database, consumer_generation, legacy
|
||||
) VALUES (?, ?, 'this_database', 0, 1)`
|
||||
)
|
||||
.run(LEGACY_RUN_ID, 'Legacy orchestration state (inspect only)')
|
||||
for (const table of ['messages', 'tasks', 'dispatch_contexts', 'decision_gates']) {
|
||||
if (!this.hasColumn(table, 'run_id')) {
|
||||
this.db.exec(
|
||||
`ALTER TABLE ${table} ADD COLUMN run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_run_sequence ON messages(run_id, sequence);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_run_status ON tasks(run_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_run_status ON dispatch_contexts(run_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gates_run_status ON decision_gates(run_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_coordinator_pane ON runs(coordinator_pane_key);
|
||||
`)
|
||||
}
|
||||
if (current < 8) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
consumer_generation INTEGER NOT NULL,
|
||||
message_ids TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'outstanding'
|
||||
CHECK(status IN ('outstanding', 'acknowledged', 'fenced')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
acknowledged_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_deliveries_one_outstanding
|
||||
ON deliveries(run_id) WHERE status = 'outstanding';
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_run_created
|
||||
ON deliveries(run_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS question_threads (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
dispatch_id TEXT NOT NULL,
|
||||
asker_handle TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'answered', 'closed')),
|
||||
answer_message_id TEXT,
|
||||
answer_body TEXT,
|
||||
answered_by_generation INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
answered_at TEXT,
|
||||
closed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_dispatch_status
|
||||
ON question_threads(dispatch_id, status);
|
||||
`)
|
||||
}
|
||||
if (current < 9 && !this.messagesTypeCheckAllowsQuestion()) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE messages_new (
|
||||
id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}',
|
||||
from_handle TEXT NOT NULL,
|
||||
to_handle TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'status'
|
||||
CHECK(type IN (
|
||||
'status', 'dispatch', 'worker_done', 'merge_ready',
|
||||
'escalation', 'handoff', 'decision_gate', 'question', 'heartbeat'
|
||||
)),
|
||||
priority TEXT NOT NULL DEFAULT 'normal'
|
||||
CHECK(priority IN ('normal', 'high', 'urgent')),
|
||||
thread_id TEXT,
|
||||
payload TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
delivered_at TEXT,
|
||||
sender_pane_key TEXT
|
||||
);
|
||||
INSERT INTO messages_new (
|
||||
id, run_id, from_handle, to_handle, subject, body, type, priority,
|
||||
thread_id, payload, read, sequence, created_at, delivered_at, sender_pane_key
|
||||
)
|
||||
SELECT
|
||||
id, run_id, from_handle, to_handle, subject, body, type, priority,
|
||||
thread_id, payload, read, sequence, created_at, delivered_at, sender_pane_key
|
||||
FROM messages;
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
|
||||
CREATE UNIQUE INDEX idx_messages_id ON messages(id);
|
||||
CREATE INDEX idx_inbox ON messages(to_handle, read);
|
||||
CREATE INDEX idx_thread ON messages(thread_id);
|
||||
CREATE INDEX idx_messages_run_sequence ON messages(run_id, sequence);
|
||||
CREATE INDEX idx_messages_undelivered_inbox
|
||||
ON messages(to_handle, read, delivered_at, sequence);
|
||||
`)
|
||||
}
|
||||
if (current < 10) {
|
||||
if (!this.hasColumn('dispatch_contexts', 'capability_hash')) {
|
||||
this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN capability_hash TEXT')
|
||||
}
|
||||
if (!this.hasColumn('dispatch_contexts', 'process_incarnation')) {
|
||||
this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN process_incarnation TEXT')
|
||||
}
|
||||
if (!this.hasColumn('dispatch_contexts', 'capability_revoked_at')) {
|
||||
this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN capability_revoked_at TEXT')
|
||||
}
|
||||
}
|
||||
if (current < 11) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mutation_receipts (
|
||||
caller_fingerprint TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(state IN ('pending', 'completed')),
|
||||
receipt TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (caller_fingerprint, request_id)
|
||||
);
|
||||
`)
|
||||
}
|
||||
if (current < 12) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS worker_dispatches (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
runtime_epoch TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'starting'
|
||||
CHECK(state IN (
|
||||
'starting', 'ready', 'start_unknown', 'failed', 'succeeded',
|
||||
'stopping', 'stop_unknown', 'stopped', 'abandoned'
|
||||
)),
|
||||
stage TEXT NOT NULL DEFAULT 'accepted',
|
||||
worktree_id TEXT,
|
||||
agent_terminal_handle TEXT,
|
||||
setup_state TEXT NOT NULL DEFAULT 'not_applicable',
|
||||
effects TEXT NOT NULL DEFAULT '[]',
|
||||
residual_resources TEXT NOT NULL DEFAULT '[]',
|
||||
start_options TEXT NOT NULL DEFAULT '{}',
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolveOrchestrationMigrationStartVersion } from '../../orchestration-schema-version-skew'
|
||||
import { SCHEMA_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
import { applySchemaMigrationsV13ToV27 } from './migrate-v13-v27'
|
||||
import { applySchemaMigrationsV2ToV12 } from './migrate-v2-v12'
|
||||
|
||||
// Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing).
|
||||
export function migrate(this: OrchestrationDb): void {
|
||||
const storedVersion = this.db.pragma('user_version', { simple: true }) as number
|
||||
const current = resolveOrchestrationMigrationStartVersion(this.db, storedVersion, SCHEMA_VERSION)
|
||||
if (current >= SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
applySchemaMigrationsV2ToV12.call(this, current)
|
||||
applySchemaMigrationsV13ToV27.call(this, current)
|
||||
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export type SchemaMigrateMethods = {
|
||||
migrate: typeof migrate
|
||||
}
|
||||
|
||||
export function attachSchemaMigrate(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
migrate
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function hasColumn(this: OrchestrationDb, table: string, column: string): boolean {
|
||||
const rows = this.db.pragma(`table_info(${table})`) as { name: string }[]
|
||||
return rows.some((r) => r.name === column)
|
||||
}
|
||||
|
||||
export function createMailboxDeliveryIndexesIfPossible(this: OrchestrationDb): void {
|
||||
const hasDeliveredAt = this.hasColumn('messages', 'delivered_at')
|
||||
if (hasDeliveredAt) {
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_undelivered_inbox
|
||||
ON messages(to_handle, read, delivered_at, sequence)
|
||||
`)
|
||||
}
|
||||
|
||||
if (
|
||||
!hasDeliveredAt ||
|
||||
!this.hasColumn('messages', 'run_id') ||
|
||||
!this.hasColumn('messages', 'delivery_contract')
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_undelivered_direct_run
|
||||
ON messages(run_id, to_handle, sequence)
|
||||
WHERE read = 0 AND delivered_at IS NULL
|
||||
AND delivery_contract = 'current_delivery';
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_current_inbox
|
||||
ON messages(to_handle, sequence)
|
||||
WHERE read = 0 AND delivery_contract = 'current_delivery';
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_current_inbox_type
|
||||
ON messages(to_handle, type, sequence)
|
||||
WHERE read = 0 AND delivery_contract = 'current_delivery';
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_current_run_type
|
||||
ON messages(run_id, to_handle, type, sequence)
|
||||
WHERE read = 0 AND delivery_contract = 'current_delivery';
|
||||
`)
|
||||
}
|
||||
|
||||
// Why: sqlite_master holds the table's CREATE SQL incl. the CHECK — cheapest reliable probe for whether it already allows 'heartbeat'.
|
||||
export function messagesTypeCheckAllowsHeartbeat(this: OrchestrationDb): boolean {
|
||||
const row = this.db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'")
|
||||
.get() as { sql: string } | undefined
|
||||
return !!row && row.sql.includes("'heartbeat'")
|
||||
}
|
||||
|
||||
export function messagesTypeCheckAllowsQuestion(this: OrchestrationDb): boolean {
|
||||
const row = this.db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'")
|
||||
.get() as { sql: string } | undefined
|
||||
return !!row && row.sql.includes("'question'")
|
||||
}
|
||||
|
||||
export type SchemaColumnProbesMethods = {
|
||||
hasColumn: typeof hasColumn
|
||||
createMailboxDeliveryIndexesIfPossible: typeof createMailboxDeliveryIndexesIfPossible
|
||||
messagesTypeCheckAllowsHeartbeat: typeof messagesTypeCheckAllowsHeartbeat
|
||||
messagesTypeCheckAllowsQuestion: typeof messagesTypeCheckAllowsQuestion
|
||||
}
|
||||
|
||||
export function attachSchemaColumnProbes(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
hasColumn,
|
||||
createMailboxDeliveryIndexesIfPossible,
|
||||
messagesTypeCheckAllowsHeartbeat,
|
||||
messagesTypeCheckAllowsQuestion
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type Database from '../../../../sqlite/sync-database'
|
||||
import type { TaskStatus, TaskRow } from '../../types'
|
||||
import { buildOrchestrationTaskDisplayMetadata } from '../../../../../shared/orchestration-task-display'
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { TaskRuntimeLineageRow } from '../run-list-page'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Tasks ──
|
||||
|
||||
export function createTask(
|
||||
this: OrchestrationDb,
|
||||
task: {
|
||||
spec: string
|
||||
taskTitle?: string
|
||||
displayName?: string
|
||||
deps?: string[]
|
||||
parentId?: string
|
||||
createdByTerminalHandle?: string
|
||||
createdByPaneKey?: string
|
||||
createdByProcessIncarnation?: string
|
||||
createdByRunGeneration?: number
|
||||
runId?: string
|
||||
}
|
||||
): TaskRow {
|
||||
const runId = task.runId ?? LEGACY_RUN_ID
|
||||
this.requireRun(runId)
|
||||
if (task.parentId) {
|
||||
const parent = this.getTask(task.parentId)
|
||||
if (!parent || parent.run_id !== runId) {
|
||||
throw new Error(`Parent task ${task.parentId} must belong to run ${runId}`)
|
||||
}
|
||||
}
|
||||
for (const depId of task.deps ?? []) {
|
||||
const dependency = this.getTask(depId)
|
||||
if (!dependency || dependency.run_id !== runId) {
|
||||
throw new Error(`Dependency task ${depId} must belong to run ${runId}`)
|
||||
}
|
||||
}
|
||||
const id = generateId('task')
|
||||
const depsJson = JSON.stringify(task.deps ?? [])
|
||||
const display = buildOrchestrationTaskDisplayMetadata({
|
||||
spec: task.spec,
|
||||
taskTitle: task.taskTitle,
|
||||
displayName: task.displayName
|
||||
})
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO tasks (
|
||||
id, run_id, parent_id, created_by_terminal_handle, created_by_pane_key,
|
||||
created_by_process_incarnation, created_by_run_generation,
|
||||
task_title, display_name, spec, status, deps
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(?) requested
|
||||
LEFT JOIN tasks dependency ON dependency.id = requested.value
|
||||
WHERE dependency.id IS NULL
|
||||
OR dependency.run_id <> ?
|
||||
OR dependency.status <> 'completed'
|
||||
) THEN 'pending' ELSE 'ready' END,
|
||||
?
|
||||
)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
runId,
|
||||
task.parentId ?? null,
|
||||
task.createdByTerminalHandle ?? null,
|
||||
task.createdByPaneKey ?? null,
|
||||
task.createdByProcessIncarnation ?? null,
|
||||
task.createdByRunGeneration ?? null,
|
||||
display.taskTitle || null,
|
||||
display.displayName || null,
|
||||
task.spec,
|
||||
depsJson,
|
||||
runId,
|
||||
depsJson
|
||||
)
|
||||
return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow
|
||||
}
|
||||
|
||||
// Why: return the active creator Dispatch proof with the Task read; runtime still owns pane/process currency.
|
||||
export function getTask(this: OrchestrationDb, id: string): TaskRow | undefined
|
||||
export function getTask(
|
||||
this: OrchestrationDb,
|
||||
id: string,
|
||||
dispatchRunId: string
|
||||
): TaskRuntimeLineageRow | undefined
|
||||
export function getTask(
|
||||
this: OrchestrationDb,
|
||||
id: string,
|
||||
dispatchRunId?: string
|
||||
): TaskRow | TaskRuntimeLineageRow | undefined {
|
||||
if (dispatchRunId === undefined) {
|
||||
return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT t.*,
|
||||
creator.id AS creator_dispatch_id,
|
||||
creator.run_id AS creator_dispatch_run_id,
|
||||
creator.assignee_pane_key AS creator_dispatch_pane_key,
|
||||
creator.process_incarnation AS creator_dispatch_process_incarnation
|
||||
FROM tasks t
|
||||
LEFT JOIN dispatch_contexts creator ON creator.rowid = (
|
||||
SELECT candidate.rowid
|
||||
FROM dispatch_contexts candidate
|
||||
WHERE candidate.assignee_handle = t.created_by_terminal_handle
|
||||
AND candidate.run_id = ?
|
||||
AND candidate.status IN ('pending', 'dispatched')
|
||||
ORDER BY candidate.rowid DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE t.id = ?`
|
||||
)
|
||||
.get(dispatchRunId, id) as TaskRuntimeLineageRow | undefined
|
||||
}
|
||||
|
||||
export function listTasks(
|
||||
this: OrchestrationDb,
|
||||
filter?: { status?: TaskStatus; ready?: boolean; runId?: string }
|
||||
): TaskRow[] {
|
||||
const runWhere = filter?.runId ? 'run_id = ? AND ' : ''
|
||||
const runParams: Database.BindValue[] = filter?.runId ? [filter.runId] : []
|
||||
if (filter?.ready) {
|
||||
return this.db
|
||||
.prepare(`SELECT * FROM tasks WHERE ${runWhere}status = 'ready' ORDER BY created_at`)
|
||||
.all(...runParams) as TaskRow[]
|
||||
}
|
||||
if (filter?.status) {
|
||||
return this.db
|
||||
.prepare(`SELECT * FROM tasks WHERE ${runWhere}status = ? ORDER BY created_at`)
|
||||
.all(...runParams, filter.status) as TaskRow[]
|
||||
}
|
||||
if (filter?.runId) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM tasks WHERE run_id = ? ORDER BY created_at')
|
||||
.all(filter.runId) as TaskRow[]
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[]
|
||||
}
|
||||
|
||||
// Why: the correlated indexed lookup avoids materializing every retained Dispatch before filtering Tasks.
|
||||
export function listTasksWithDispatch(
|
||||
this: OrchestrationDb,
|
||||
filter?: {
|
||||
status?: TaskStatus
|
||||
ready?: boolean
|
||||
runId?: string
|
||||
}
|
||||
): (TaskRow & {
|
||||
assignee_handle: string | null
|
||||
dispatch_id: string | null
|
||||
})[] {
|
||||
const whereClauses: string[] = []
|
||||
const params: Database.BindValue[] = []
|
||||
if (filter?.runId) {
|
||||
whereClauses.push('t.run_id = ?')
|
||||
params.push(filter.runId)
|
||||
}
|
||||
if (filter?.ready) {
|
||||
whereClauses.push("t.status = 'ready'")
|
||||
} else if (filter?.status) {
|
||||
whereClauses.push('t.status = ?')
|
||||
params.push(filter.status)
|
||||
}
|
||||
const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
|
||||
const sql = `
|
||||
SELECT
|
||||
t.*,
|
||||
d.assignee_handle AS assignee_handle,
|
||||
d.id AS dispatch_id
|
||||
FROM tasks t
|
||||
LEFT JOIN dispatch_contexts d ON d.rowid = (
|
||||
SELECT candidate.rowid
|
||||
FROM dispatch_contexts candidate
|
||||
WHERE candidate.task_id = t.id
|
||||
AND candidate.status IN ('pending', 'dispatched')
|
||||
ORDER BY candidate.rowid DESC
|
||||
LIMIT 1
|
||||
)
|
||||
${where}
|
||||
ORDER BY t.created_at
|
||||
`
|
||||
return this.db.prepare(sql).all(...params) as (TaskRow & {
|
||||
assignee_handle: string | null
|
||||
dispatch_id: string | null
|
||||
})[]
|
||||
}
|
||||
|
||||
export function updateTaskStatus(
|
||||
this: OrchestrationDb,
|
||||
id: string,
|
||||
status: TaskStatus,
|
||||
result?: string
|
||||
): TaskRow | undefined {
|
||||
const completedAt =
|
||||
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE tasks SET status = ?, result = COALESCE(?, result), completed_at = COALESCE(?, completed_at) WHERE id = ?'
|
||||
)
|
||||
.run(status, result ?? null, completedAt, id)
|
||||
|
||||
if (status === 'completed') {
|
||||
this.promoteReadyTasks(id)
|
||||
this.completeActiveDispatchForTask(id)
|
||||
}
|
||||
|
||||
return this.getTask(id)
|
||||
}
|
||||
|
||||
// Why: runs in the status-update transaction, so a completed task never leaves its ready children unpromoted.
|
||||
export function promoteReadyTasks(this: OrchestrationDb, completedTaskId: string): void {
|
||||
const candidates = this.db
|
||||
.prepare("SELECT * FROM tasks WHERE status = 'pending'")
|
||||
.all() as TaskRow[]
|
||||
|
||||
for (const task of candidates) {
|
||||
const deps: string[] = JSON.parse(task.deps)
|
||||
if (!deps.includes(completedTaskId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const allDepsCompleted = deps.every((depId) => {
|
||||
const dep = this.getTask(depId)
|
||||
return dep?.status === 'completed'
|
||||
})
|
||||
if (allDepsCompleted) {
|
||||
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskStoreMethods = {
|
||||
createTask: typeof createTask
|
||||
getTask: typeof getTask
|
||||
listTasks: typeof listTasks
|
||||
listTasksWithDispatch: typeof listTasksWithDispatch
|
||||
updateTaskStatus: typeof updateTaskStatus
|
||||
promoteReadyTasks: typeof promoteReadyTasks
|
||||
}
|
||||
|
||||
export function attachTaskStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createTask,
|
||||
getTask,
|
||||
listTasks,
|
||||
listTasksWithDispatch,
|
||||
updateTaskStatus,
|
||||
promoteReadyTasks
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DeliveryRow, MessageRow, QuestionRow, RunRow } from '../types'
|
||||
|
||||
export const SQLITE_UTC_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/
|
||||
|
||||
export function exposeUtcTimestamp(timestamp: string | null): string | null {
|
||||
if (!timestamp || !SQLITE_UTC_TIMESTAMP_RE.test(timestamp)) {
|
||||
return timestamp
|
||||
}
|
||||
return `${timestamp.replace(' ', 'T')}Z`
|
||||
}
|
||||
|
||||
export function exposeMessageTimestamps(message: MessageRow): MessageRow {
|
||||
// Why: SQLite stores UTC as timezone-less space format for SQL ordering, but RPC/CLI consumers need an explicit offset.
|
||||
return {
|
||||
...message,
|
||||
created_at: exposeUtcTimestamp(message.created_at) ?? message.created_at,
|
||||
delivered_at: exposeUtcTimestamp(message.delivered_at)
|
||||
}
|
||||
}
|
||||
|
||||
export function exposeMessageListTimestamps(messages: MessageRow[]): MessageRow[] {
|
||||
return messages.map(exposeMessageTimestamps)
|
||||
}
|
||||
|
||||
export function exposeRunTimestamps(run: RunRow): RunRow {
|
||||
return {
|
||||
...run,
|
||||
created_at: exposeUtcTimestamp(run.created_at) ?? run.created_at,
|
||||
updated_at: exposeUtcTimestamp(run.updated_at) ?? run.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
export function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow {
|
||||
return {
|
||||
...delivery,
|
||||
created_at: exposeUtcTimestamp(delivery.created_at) ?? delivery.created_at,
|
||||
acknowledged_at: exposeUtcTimestamp(delivery.acknowledged_at)
|
||||
}
|
||||
}
|
||||
|
||||
export function exposeQuestionTimestamps(question: QuestionRow): QuestionRow {
|
||||
return {
|
||||
...question,
|
||||
created_at: exposeUtcTimestamp(question.created_at) ?? question.created_at,
|
||||
answered_at: exposeUtcTimestamp(question.answered_at),
|
||||
closed_at: exposeUtcTimestamp(question.closed_at)
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import type { WorkerDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function reconcileFederatedWorkerStart(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
state: 'ready' | 'failed' | 'stopped' | 'start_unknown'
|
||||
stage: string
|
||||
lastError?: string | null
|
||||
worktreeId?: string | null
|
||||
terminalHandle?: string | null
|
||||
setupState?: string
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
}
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
const worker = this.getWorkerDispatch(params.dispatchId)
|
||||
if (!dispatch || !worker) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Federated Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
if (!['starting', 'start_unknown'].includes(worker.state)) {
|
||||
this.db.exec('COMMIT')
|
||||
return worker
|
||||
}
|
||||
|
||||
if (params.state === 'ready') {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'ready', stage = ?, worktree_id = COALESCE(?, worktree_id),
|
||||
agent_terminal_handle = COALESCE(?, agent_terminal_handle), setup_state = ?,
|
||||
effects = ?, residual_resources = ?, last_error = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('starting', 'start_unknown')`
|
||||
)
|
||||
.run(
|
||||
params.stage,
|
||||
params.worktreeId ?? null,
|
||||
params.terminalHandle ?? null,
|
||||
params.setupState ?? worker.setup_state,
|
||||
JSON.stringify(params.effects ?? JSON.parse(worker.effects)),
|
||||
JSON.stringify(params.residualResources ?? JSON.parse(worker.residual_resources)),
|
||||
params.dispatchId
|
||||
)
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE dispatch_contexts SET status = 'dispatched' WHERE id = ? AND status = 'pending'"
|
||||
)
|
||||
.run(params.dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE tasks SET status = 'dispatched', completed_at = NULL WHERE id = ? AND status = 'blocked'"
|
||||
)
|
||||
.run(dispatch.task_id)
|
||||
} else if (params.state === 'start_unknown') {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET stage = ?, last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('starting', 'start_unknown')`
|
||||
)
|
||||
.run(params.stage, params.lastError ?? worker.last_error, params.dispatchId)
|
||||
} else {
|
||||
const reason = params.lastError ?? `The worker server reported ${params.state}.`
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = ?, stage = ?, last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('starting', 'start_unknown')`
|
||||
)
|
||||
.run(params.state, params.stage, reason, params.dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = 'failed', last_failure = ?, completed_at = datetime('now'),
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ? AND status IN ('pending', 'dispatched')`
|
||||
)
|
||||
.run(reason, params.dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE id = ? AND status IN ('blocked', 'dispatched')"
|
||||
)
|
||||
.run(dispatch.task_id)
|
||||
this.closeQuestionsForDispatch(params.dispatchId)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(params.dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type FederatedWorkerStartReconcileMethods = {
|
||||
reconcileFederatedWorkerStart: typeof reconcileFederatedWorkerStart
|
||||
}
|
||||
|
||||
export function attachFederatedWorkerStartReconcile(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
reconcileFederatedWorkerStart
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { WorkerDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import {
|
||||
releaseContextOnlyDispatch,
|
||||
type ContextOnlyDispatchReleaseResult
|
||||
} from '../../context-only-dispatch-release'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function abandonWorkerDispatch(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
):
|
||||
| {
|
||||
disposition: 'abandoned' | 'already_abandoned' | 'stale'
|
||||
worker: WorkerDispatchRow
|
||||
}
|
||||
| ({ disposition: 'context_only' } & ContextOnlyDispatchReleaseResult) {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (!dispatch) {
|
||||
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
|
||||
}
|
||||
if (!worker) {
|
||||
const released = releaseContextOnlyDispatch(
|
||||
this.db,
|
||||
dispatch,
|
||||
this.getDispatchContext(dispatch.task_id)?.id,
|
||||
'abandoned'
|
||||
)
|
||||
if (!released.alreadySettled) {
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'context_only', ...released }
|
||||
}
|
||||
if (worker.state === 'abandoned') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'already_abandoned', worker }
|
||||
}
|
||||
if (this.getDispatchContext(dispatch.task_id)?.id !== dispatchId) {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'stale', worker }
|
||||
}
|
||||
if (worker.state === 'succeeded') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} already succeeded and cannot be abandoned.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'abandoned', stage = 'abandoned', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = CASE WHEN status IN ('pending', 'dispatched') THEN 'failed' ELSE status END,
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')),
|
||||
completed_at = COALESCE(completed_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id)
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
disposition: 'abandoned',
|
||||
worker: this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// --- Worker terminal resources (schema v23) ---------------------------------------------------
|
||||
|
||||
// Historical renderer input and reuse cannot be proven, so pre-v23 terminals stay external.
|
||||
|
||||
export type WorkerDispatchAbandonMethods = {
|
||||
abandonWorkerDispatch: typeof abandonWorkerDispatch
|
||||
}
|
||||
|
||||
export function attachWorkerDispatchAbandon(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
abandonWorkerDispatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { hashDispatchCapability } from '../dispatch-capability-hash'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function prepareStartingWorkerAuthority(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
handle: string
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
launchTokenHash?: string
|
||||
worktreeId: string
|
||||
effects: unknown[]
|
||||
setupState: string
|
||||
hostScope?: string | null
|
||||
// 'created': this worker-start operation created the agent terminal (including agent-first
|
||||
// worktree creation, whose effects receipt says 'reused_agent_terminal'). 'external': an
|
||||
// explicit --terminal reuse; ownership transfers only from an exact owned settled resource.
|
||||
terminalOwnership?: 'created' | 'external'
|
||||
}
|
||||
): string {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
const worker = this.getWorkerDispatch(params.dispatchId)
|
||||
if (!dispatch || dispatch.status !== 'pending' || worker?.state !== 'starting') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${params.dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
dispatch.launch_token_hash &&
|
||||
params.launchTokenHash &&
|
||||
dispatch.launch_token_hash !== params.launchTokenHash
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${params.dispatchId} already has a different launch-token commitment.`
|
||||
)
|
||||
}
|
||||
const capability = `dcap_${randomBytes(32).toString('base64url')}`
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?,
|
||||
capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?),
|
||||
capability_revoked_at = NULL
|
||||
WHERE id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(
|
||||
params.handle,
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
hashDispatchCapability(capability),
|
||||
params.launchTokenHash ?? null,
|
||||
params.dispatchId
|
||||
)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET stage = 'authority_attached', worktree_id = ?, agent_terminal_handle = ?,
|
||||
setup_state = ?, effects = ?, residual_resources = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(
|
||||
params.worktreeId,
|
||||
params.handle,
|
||||
params.setupState,
|
||||
JSON.stringify(params.effects),
|
||||
JSON.stringify(
|
||||
params.effects.filter((effect) =>
|
||||
Boolean(
|
||||
effect &&
|
||||
typeof effect === 'object' &&
|
||||
((effect as { action?: string }).action?.startsWith('created') ||
|
||||
(effect as { action?: string }).action === 'reused_agent_terminal')
|
||||
)
|
||||
)
|
||||
),
|
||||
params.dispatchId
|
||||
)
|
||||
if (params.terminalOwnership && !this.getWorkerTerminalResourceByOwner(params.dispatchId)) {
|
||||
if (params.terminalOwnership === 'created') {
|
||||
this.createWorkerTerminalResourceStatement({
|
||||
dispatchId: params.dispatchId,
|
||||
worktreeId: params.worktreeId,
|
||||
terminalHandle: params.handle,
|
||||
paneKey: params.paneKey,
|
||||
processIncarnation: params.processIncarnation,
|
||||
hostScope: params.hostScope,
|
||||
ownership: 'owned'
|
||||
})
|
||||
} else {
|
||||
const transferable = this.findTransferableWorkerTerminalResource({
|
||||
terminalHandle: params.handle,
|
||||
paneKey: params.paneKey,
|
||||
processIncarnation: params.processIncarnation,
|
||||
hostScope: params.hostScope ?? null
|
||||
})
|
||||
if (transferable) {
|
||||
this.transferWorkerTerminalResourceStatement({
|
||||
resourceId: transferable.id,
|
||||
toDispatchId: params.dispatchId,
|
||||
terminalHandle: params.handle,
|
||||
paneKey: params.paneKey,
|
||||
processIncarnation: params.processIncarnation,
|
||||
hostScope: params.hostScope ?? null
|
||||
})
|
||||
} else {
|
||||
this.createWorkerTerminalResourceStatement({
|
||||
dispatchId: params.dispatchId,
|
||||
worktreeId: params.worktreeId,
|
||||
terminalHandle: params.handle,
|
||||
paneKey: params.paneKey,
|
||||
processIncarnation: params.processIncarnation,
|
||||
hostScope: params.hostScope,
|
||||
ownership: 'external'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return capability
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerDispatchAuthorityMethods = {
|
||||
prepareStartingWorkerAuthority: typeof prepareStartingWorkerAuthority
|
||||
}
|
||||
|
||||
export function attachWorkerDispatchAuthority(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
prepareStartingWorkerAuthority
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { WorkerDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function markWorkerDispatchReady(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
effects?: unknown[]
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
if (!dispatch || dispatch.status !== 'pending' || worker?.state !== 'starting') {
|
||||
throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not starting.`)
|
||||
}
|
||||
this.db
|
||||
.prepare("UPDATE dispatch_contexts SET status = 'dispatched' WHERE id = ?")
|
||||
.run(dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'ready', stage = 'input_accepted',
|
||||
effects = COALESCE(?, effects), updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(effects ? JSON.stringify(effects) : null, dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function failWorkerStart(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
stage: string,
|
||||
reason: string
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
if (!dispatch || !worker || worker.state !== 'starting') {
|
||||
throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not starting.`)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = 'failed', last_failure = ?, completed_at = datetime('now'),
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(reason, dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'failed', stage = ?, last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(stage, reason, dispatchId)
|
||||
this.db
|
||||
.prepare("UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE id = ?")
|
||||
.run(dispatch.task_id)
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function markWorkerStartUnknown(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
stage: string,
|
||||
reason: string
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
if (!dispatch || !worker || worker.state !== 'starting') {
|
||||
throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not starting.`)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'start_unknown', stage = ?, last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(stage, reason, dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id)
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkerDispatch(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): WorkerDispatchRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM worker_dispatches WHERE dispatch_id = ?')
|
||||
.get(dispatchId) as WorkerDispatchRow | undefined
|
||||
}
|
||||
|
||||
export type WorkerDispatchOutcomeMethods = {
|
||||
markWorkerDispatchReady: typeof markWorkerDispatchReady
|
||||
failWorkerStart: typeof failWorkerStart
|
||||
markWorkerStartUnknown: typeof markWorkerStartUnknown
|
||||
getWorkerDispatch: typeof getWorkerDispatch
|
||||
}
|
||||
|
||||
export function attachWorkerDispatchOutcome(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
markWorkerDispatchReady,
|
||||
failWorkerStart,
|
||||
markWorkerStartUnknown,
|
||||
getWorkerDispatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { WorkerDispatchRow, WorkerDispatchState } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function recordWorkerStage(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
stage: string
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setupState?: string
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
lastError?: string
|
||||
state?: WorkerDispatchState
|
||||
}
|
||||
): WorkerDispatchRow {
|
||||
const current = this.getWorkerDispatch(params.dispatchId)
|
||||
if (!current) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET stage = ?, state = ?, worktree_id = ?, agent_terminal_handle = ?,
|
||||
setup_state = ?, effects = ?, residual_resources = ?, last_error = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(
|
||||
params.stage,
|
||||
params.state ?? current.state,
|
||||
params.worktreeId ?? current.worktree_id,
|
||||
params.terminalHandle ?? current.agent_terminal_handle,
|
||||
params.setupState ?? current.setup_state,
|
||||
params.effects ? JSON.stringify(params.effects) : current.effects,
|
||||
params.residualResources
|
||||
? JSON.stringify(params.residualResources)
|
||||
: current.residual_resources,
|
||||
params.lastError ?? current.last_error,
|
||||
params.dispatchId
|
||||
)
|
||||
return this.getWorkerDispatch(params.dispatchId) as WorkerDispatchRow
|
||||
}
|
||||
|
||||
export function updateWorkerSetupEvidence(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
setupState: string
|
||||
effects: unknown[]
|
||||
}
|
||||
): { worker: WorkerDispatchRow; changed: boolean } {
|
||||
const current = this.getWorkerDispatch(params.dispatchId)
|
||||
if (!current) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
const effects = JSON.stringify(params.effects)
|
||||
if (current.setup_state === params.setupState && current.effects === effects) {
|
||||
return { worker: current, changed: false }
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET setup_state = ?, effects = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(params.setupState, effects, params.dispatchId)
|
||||
return {
|
||||
worker: this.getWorkerDispatch(params.dispatchId) as WorkerDispatchRow,
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerDispatchStageMethods = {
|
||||
recordWorkerStage: typeof recordWorkerStage
|
||||
updateWorkerSetupEvidence: typeof updateWorkerSetupEvidence
|
||||
}
|
||||
|
||||
export function attachWorkerDispatchStage(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
recordWorkerStage,
|
||||
updateWorkerSetupEvidence
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DispatchContextRow, WorkerDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity'
|
||||
import { CURRENT_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function createStartingWorkerDispatch(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
taskId: string
|
||||
startOptions: unknown
|
||||
launchTokenHash?: string
|
||||
retryOf?: string
|
||||
runtimeEpoch?: string
|
||||
federation?: {
|
||||
environmentId: string
|
||||
environmentName: string
|
||||
peerFingerprint: string
|
||||
protocolVersion: number
|
||||
}
|
||||
mutationReceipt?: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
}
|
||||
): { dispatch: DispatchContextRow; worker: WorkerDispatchRow } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (params.mutationReceipt) {
|
||||
const receipt = params.mutationReceipt
|
||||
const existing = this.getMutationReceipt(receipt.callerFingerprint, receipt.requestId)
|
||||
if (existing) {
|
||||
if (existing.method !== receipt.method || existing.payload_hash !== receipt.payloadHash) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Mutation request ${receipt.requestId} was already used with different input.`
|
||||
)
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Mutation ${receipt.requestId} already has a durable acceptance record.`
|
||||
)
|
||||
}
|
||||
ensureMutationReceiptCapacity(this.db)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state
|
||||
) VALUES (?, ?, ?, ?, 'pending')`
|
||||
)
|
||||
.run(receipt.callerFingerprint, receipt.requestId, receipt.method, receipt.payloadHash)
|
||||
}
|
||||
const task = this.getTask(params.taskId)
|
||||
if (!task) {
|
||||
throw new OrchestrationError('task_not_found', `Task ${params.taskId} was not found.`)
|
||||
}
|
||||
if (params.retryOf) {
|
||||
const prior = this.getDispatchContextById(params.retryOf)
|
||||
const priorWorker = this.getWorkerDispatch(params.retryOf)
|
||||
const latest = this.getDispatchContext(task.id)
|
||||
if (
|
||||
!prior ||
|
||||
prior.task_id !== task.id ||
|
||||
latest?.id !== prior.id ||
|
||||
!priorWorker ||
|
||||
!['failed', 'stopped', 'abandoned'].includes(priorWorker.state) ||
|
||||
!['failed', 'blocked'].includes(task.status)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'task_not_startable',
|
||||
`Task ${task.id} cannot retry from Dispatch ${params.retryOf}.`
|
||||
)
|
||||
}
|
||||
} else if (task.status !== 'ready') {
|
||||
throw new OrchestrationError(
|
||||
'task_not_startable',
|
||||
`Task ${task.id} is ${task.status}; only a ready Task can start.`
|
||||
)
|
||||
}
|
||||
|
||||
const id = generateId('ctx')
|
||||
if (params.mutationReceipt) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE mutation_receipts
|
||||
SET receipt = ?, updated_at = datetime('now')
|
||||
WHERE caller_fingerprint = ? AND request_id = ? AND state = 'pending'`
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({ accepted: { dispatchId: id } }),
|
||||
params.mutationReceipt.callerFingerprint,
|
||||
params.mutationReceipt.requestId
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, launch_token_hash, status, dispatched_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))`
|
||||
)
|
||||
.run(id, task.run_id, task.id, CURRENT_CONTRACT_VERSION, params.launchTokenHash ?? null)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO worker_dispatches (
|
||||
dispatch_id, runtime_epoch, state, stage, start_options
|
||||
) VALUES (?, ?, 'starting', 'accepted', ?)`
|
||||
)
|
||||
.run(id, params.runtimeEpoch ?? null, JSON.stringify(params.startOptions))
|
||||
if (params.federation) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO federated_dispatches (
|
||||
dispatch_id, environment_id, environment_name, peer_fingerprint, protocol_version
|
||||
) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
params.federation.environmentId,
|
||||
params.federation.environmentName,
|
||||
params.federation.peerFingerprint,
|
||||
params.federation.protocolVersion
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE tasks SET status = 'dispatched', result = NULL, completed_at = NULL WHERE id = ?"
|
||||
)
|
||||
.run(task.id)
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
dispatch: this.getDispatchContextById(id) as DispatchContextRow,
|
||||
worker: this.getWorkerDispatch(id) as WorkerDispatchRow
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerDispatchStartMethods = {
|
||||
createStartingWorkerDispatch: typeof createStartingWorkerDispatch
|
||||
}
|
||||
|
||||
export function attachWorkerDispatchStart(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createStartingWorkerDispatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { DispatchContextRow, WorkerDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import {
|
||||
releaseContextOnlyDispatch,
|
||||
type ContextOnlyDispatchReleaseResult
|
||||
} from '../../context-only-dispatch-release'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function isDispatchProcessCurrent(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
}
|
||||
): boolean {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
return Boolean(
|
||||
dispatch?.assignee_pane_key &&
|
||||
params.paneKey &&
|
||||
isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey) &&
|
||||
dispatch.process_incarnation &&
|
||||
params.processIncarnation === dispatch.process_incarnation
|
||||
)
|
||||
}
|
||||
|
||||
export function beginWorkerStop(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
):
|
||||
| { disposition: 'stopping'; worker: WorkerDispatchRow; dispatch: DispatchContextRow }
|
||||
| { disposition: 'already_settled'; worker: WorkerDispatchRow; dispatch: DispatchContextRow }
|
||||
| ({ disposition: 'context_only' } & ContextOnlyDispatchReleaseResult) {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
if (!dispatch) {
|
||||
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
|
||||
}
|
||||
if (!worker) {
|
||||
const released = releaseContextOnlyDispatch(
|
||||
this.db,
|
||||
dispatch,
|
||||
this.getDispatchContext(dispatch.task_id)?.id,
|
||||
'stopped'
|
||||
)
|
||||
if (!released.alreadySettled) {
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'context_only', ...released }
|
||||
}
|
||||
if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(worker.state)) {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'already_settled', worker, dispatch }
|
||||
}
|
||||
if (!['ready', 'start_unknown'].includes(worker.state)) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} cannot stop from ${worker.state}.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'stopping', stage = 'stop_requested', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('ready', 'start_unknown')`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id)
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
disposition: 'stopping',
|
||||
worker: this.getWorkerDispatch(dispatchId) as WorkerDispatchRow,
|
||||
dispatch: this.getDispatchContextById(dispatchId) as DispatchContextRow
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function settleWorkerStop(this: OrchestrationDb, dispatchId: string): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (!worker || !dispatch || worker.state !== 'stopping') {
|
||||
throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not stopping.`)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'stopped', stage = 'process_stopped', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = 'failed', completed_at = datetime('now'), last_failure = 'stopped'
|
||||
WHERE id = ? AND status IN ('pending', 'dispatched')`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileFederatedWorkerStop(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (!worker || !dispatch || !this.getFederatedDispatch(dispatchId)) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Federated Dispatch ${dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
if (worker.state === 'stopped') {
|
||||
this.db.exec('COMMIT')
|
||||
return worker
|
||||
}
|
||||
if (!['stopping', 'stop_unknown'].includes(worker.state)) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Federated Dispatch ${dispatchId} cannot reconcile stop from ${worker.state}.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'stopped', stage = 'process_stopped', last_error = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('stopping', 'stop_unknown')`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = 'failed', completed_at = COALESCE(completed_at, datetime('now')),
|
||||
last_failure = 'stopped'
|
||||
WHERE id = ? AND status IN ('pending', 'dispatched')`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function resumeFederatedWorkerForTerminalRelay(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (!worker || !dispatch || worker.state !== 'stopping') {
|
||||
throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not stopping.`)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'ready', stage = 'remote_report_pending', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(dispatchId)
|
||||
this.db
|
||||
.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ? AND status = 'blocked'")
|
||||
.run(dispatch.task_id)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function markWorkerStopUnknown(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
reason: string
|
||||
): WorkerDispatchRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = 'stop_unknown', stage = 'stop_outcome_unknown', last_error = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(reason, dispatchId)
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
}
|
||||
|
||||
export type WorkerDispatchStopMethods = {
|
||||
isDispatchProcessCurrent: typeof isDispatchProcessCurrent
|
||||
beginWorkerStop: typeof beginWorkerStop
|
||||
settleWorkerStop: typeof settleWorkerStop
|
||||
reconcileFederatedWorkerStop: typeof reconcileFederatedWorkerStop
|
||||
resumeFederatedWorkerForTerminalRelay: typeof resumeFederatedWorkerForTerminalRelay
|
||||
markWorkerStopUnknown: typeof markWorkerStopUnknown
|
||||
}
|
||||
|
||||
export function attachWorkerDispatchStop(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
isDispatchProcessCurrent,
|
||||
beginWorkerStop,
|
||||
settleWorkerStop,
|
||||
reconcileFederatedWorkerStop,
|
||||
resumeFederatedWorkerForTerminalRelay,
|
||||
markWorkerStopUnknown
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
TaskStatus,
|
||||
DispatchStatus,
|
||||
WorkerDispatchRow,
|
||||
LegacyWorkerTerminalRecoveryRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function listLegacyWorkerTerminalRecoveryRows(
|
||||
this: OrchestrationDb
|
||||
): LegacyWorkerTerminalRecoveryRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT dc.id AS dispatch_id, dc.task_id, dc.status AS dispatch_status,
|
||||
dc.contract_version, dc.assignee_handle, dc.assignee_pane_key,
|
||||
dc.process_incarnation, wd.state AS worker_state, wd.worktree_id,
|
||||
wd.agent_terminal_handle
|
||||
FROM dispatch_contexts dc
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = dc.id
|
||||
WHERE wd.state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown')
|
||||
ORDER BY dc.rowid`
|
||||
)
|
||||
.all() as LegacyWorkerTerminalRecoveryRow[]
|
||||
}
|
||||
|
||||
export function reconcileMissingWorkerTerminal(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
reason: string
|
||||
): WorkerDispatchRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
if (!dispatch || !worker) {
|
||||
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
|
||||
}
|
||||
if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(worker.state)) {
|
||||
this.db.exec('COMMIT')
|
||||
return worker
|
||||
}
|
||||
|
||||
const activeDispatch = dispatch.status === 'pending' || dispatch.status === 'dispatched'
|
||||
const stopWasPending = worker.state === 'stopping' || worker.state === 'stop_unknown'
|
||||
if (activeDispatch) {
|
||||
const failureCount = dispatch.failure_count + 1
|
||||
const dispatchStatus: DispatchStatus = failureCount >= 3 ? 'circuit_broken' : 'failed'
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = ?, failure_count = ?, last_failure = ?,
|
||||
completed_at = datetime('now'),
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ? AND status IN ('pending', 'dispatched')`
|
||||
)
|
||||
.run(dispatchStatus, failureCount, reason, dispatchId)
|
||||
if (!stopWasPending) {
|
||||
const taskStatus: TaskStatus = dispatchStatus === 'circuit_broken' ? 'failed' : 'ready'
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = ?, completed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END
|
||||
WHERE id = ? AND status IN ('dispatched', 'blocked')`
|
||||
)
|
||||
.run(taskStatus, taskStatus, dispatch.task_id)
|
||||
}
|
||||
this.closeQuestionsForDispatch(dispatchId)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = ?, stage = 'terminal_missing', last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(stopWasPending ? 'stopped' : 'abandoned', reason, dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerTerminalRecoveryMethods = {
|
||||
listLegacyWorkerTerminalRecoveryRows: typeof listLegacyWorkerTerminalRecoveryRows
|
||||
reconcileMissingWorkerTerminal: typeof reconcileMissingWorkerTerminal
|
||||
}
|
||||
|
||||
export function attachWorkerTerminalRecovery(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
listLegacyWorkerTerminalRecoveryRows,
|
||||
reconcileMissingWorkerTerminal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
WorkerTerminalResourceRow,
|
||||
WorkerTerminalArchiveRow,
|
||||
WorkerTerminalArchiveStatus,
|
||||
WorkerTerminalRetainedReason
|
||||
} from '../../worker-terminal-ownership'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function storeWorkerTerminalArchive(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
resourceId: string
|
||||
kind: 'transcript_pin' | 'terminal_tail'
|
||||
content: string
|
||||
}
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO worker_terminal_archives (dispatch_id, resource_id, kind, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(dispatch_id) DO UPDATE SET
|
||||
resource_id = excluded.resource_id, kind = excluded.kind, content = excluded.content`
|
||||
)
|
||||
.run(params.dispatchId, params.resourceId, params.kind, params.content)
|
||||
}
|
||||
|
||||
export function commitWorkerTerminalArchiveForRelease(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
resourceId: string
|
||||
kind?: 'transcript_pin' | 'terminal_tail'
|
||||
content?: string
|
||||
archiveSource: 'transcript' | 'terminal'
|
||||
archiveStatus: Extract<WorkerTerminalArchiveStatus, 'captured' | 'empty'>
|
||||
}
|
||||
): WorkerTerminalResourceRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const resource = this.getWorkerTerminalResource(params.resourceId)
|
||||
if (
|
||||
resource?.owner_dispatch_id === params.dispatchId &&
|
||||
resource.ownership_state === 'owned' &&
|
||||
resource.release_state === 'requested'
|
||||
) {
|
||||
if (params.kind && params.content !== undefined) {
|
||||
this.storeWorkerTerminalArchive({
|
||||
dispatchId: params.dispatchId,
|
||||
resourceId: params.resourceId,
|
||||
kind: params.kind,
|
||||
content: params.content
|
||||
})
|
||||
}
|
||||
const archive = this.getWorkerTerminalArchive(params.dispatchId)
|
||||
if (!archive || archive.resource_id !== params.resourceId) {
|
||||
throw new OrchestrationError(
|
||||
'archive_failed',
|
||||
`Output could not be preserved for Dispatch ${params.dispatchId}; the terminal was retained.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'releasing', archive_source = ?, archive_status = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND owner_dispatch_id = ? AND ownership_state = 'owned'
|
||||
AND release_state = 'requested'`
|
||||
)
|
||||
.run(params.archiveSource, params.archiveStatus, params.resourceId, params.dispatchId)
|
||||
}
|
||||
const updated = this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow
|
||||
this.db.exec('COMMIT')
|
||||
return updated
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkerTerminalArchive(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): WorkerTerminalArchiveRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM worker_terminal_archives WHERE dispatch_id = ?')
|
||||
.get(dispatchId) as WorkerTerminalArchiveRow | undefined
|
||||
}
|
||||
|
||||
export function settleWorkerTerminalRelease(
|
||||
this: OrchestrationDb,
|
||||
resourceId: string
|
||||
): WorkerTerminalResourceRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'released', ownership_state = 'released',
|
||||
release_completed_at = datetime('now'), release_error = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND release_state IN ('requested', 'releasing', 'unknown')`
|
||||
)
|
||||
.run(resourceId)
|
||||
return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow
|
||||
}
|
||||
|
||||
export function markWorkerTerminalReleaseUnknown(
|
||||
this: OrchestrationDb,
|
||||
resourceId: string,
|
||||
reason: string
|
||||
): WorkerTerminalResourceRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'unknown', release_error = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND release_state IN ('requested', 'releasing')`
|
||||
)
|
||||
.run(reason, resourceId)
|
||||
return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow
|
||||
}
|
||||
|
||||
export function revertWorkerTerminalReleaseToRetained(
|
||||
this: OrchestrationDb,
|
||||
resourceId: string,
|
||||
reason: WorkerTerminalRetainedReason
|
||||
): WorkerTerminalResourceRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'retained', retained_reason = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND release_state IN ('requested', 'releasing')`
|
||||
)
|
||||
.run(reason, resourceId)
|
||||
return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow
|
||||
}
|
||||
|
||||
export function retainWorkerTerminalResource(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
):
|
||||
| { disposition: 'retained'; resource: WorkerTerminalResourceRow }
|
||||
| { disposition: 'already_released'; resource: WorkerTerminalResourceRow }
|
||||
| { disposition: 'release_committed'; resource: WorkerTerminalResourceRow }
|
||||
| { disposition: 'no_owned_resource'; resource: null } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const resource = this.getWorkerTerminalResourceByOwner(dispatchId)
|
||||
if (!resource) {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'no_owned_resource', resource: null }
|
||||
}
|
||||
if (resource.release_state === 'released') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'already_released', resource }
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'retained', retained_reason = 'user_requested',
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested')`
|
||||
)
|
||||
.run(resource.id)
|
||||
const updated = this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow
|
||||
if (updated.release_state !== 'retained') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'release_committed', resource: updated }
|
||||
}
|
||||
this.db.prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?').run(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'retained', resource: updated }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerTerminalArchiveMethods = {
|
||||
storeWorkerTerminalArchive: typeof storeWorkerTerminalArchive
|
||||
commitWorkerTerminalArchiveForRelease: typeof commitWorkerTerminalArchiveForRelease
|
||||
getWorkerTerminalArchive: typeof getWorkerTerminalArchive
|
||||
settleWorkerTerminalRelease: typeof settleWorkerTerminalRelease
|
||||
markWorkerTerminalReleaseUnknown: typeof markWorkerTerminalReleaseUnknown
|
||||
revertWorkerTerminalReleaseToRetained: typeof revertWorkerTerminalReleaseToRetained
|
||||
retainWorkerTerminalResource: typeof retainWorkerTerminalResource
|
||||
}
|
||||
|
||||
export function attachWorkerTerminalArchive(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
storeWorkerTerminalArchive,
|
||||
commitWorkerTerminalArchiveForRelease,
|
||||
getWorkerTerminalArchive,
|
||||
settleWorkerTerminalRelease,
|
||||
markWorkerTerminalReleaseUnknown,
|
||||
revertWorkerTerminalReleaseToRetained,
|
||||
retainWorkerTerminalResource
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { DispatchStatus, WorkerDispatchState } from '../../types'
|
||||
import { deriveWorkerTerminalListState } from '../../worker-terminal-ownership'
|
||||
import type {
|
||||
WorkerTerminalResourceRow,
|
||||
WorkerTerminalListState
|
||||
} from '../../worker-terminal-ownership'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// Real user input relinquishes orchestration ownership durably; programmatic prompt delivery,
|
||||
// query auto-replies, resize, and output never reach this path.
|
||||
export function markWorkerTerminalUserOwned(this: OrchestrationDb, paneKey: string): number {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const exact = this.db
|
||||
.prepare(
|
||||
`SELECT id, owner_dispatch_id, pane_key FROM worker_terminal_resources
|
||||
WHERE pane_key = ? AND ownership_state = 'owned'
|
||||
AND release_state IN ('not_requested', 'retained', 'requested')`
|
||||
)
|
||||
.all(paneKey) as { id: string; owner_dispatch_id: string; pane_key: string }[]
|
||||
const candidates =
|
||||
exact.length > 0
|
||||
? exact
|
||||
: (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT id, owner_dispatch_id, pane_key FROM worker_terminal_resources
|
||||
WHERE ownership_state = 'owned'
|
||||
AND release_state IN ('not_requested', 'retained', 'requested')
|
||||
AND pane_key IS NOT NULL`
|
||||
)
|
||||
.all() as { id: string; owner_dispatch_id: string; pane_key: string }[]
|
||||
).filter((candidate) => isEquivalentPaneKey(candidate.pane_key, paneKey))
|
||||
const update = this.db.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET ownership_state = 'user_owned', release_state = 'retained',
|
||||
retained_reason = 'user_takeover', updated_at = datetime('now')
|
||||
WHERE id = ? AND ownership_state = 'owned'
|
||||
AND release_state IN ('not_requested', 'retained', 'requested')`
|
||||
)
|
||||
let changed = 0
|
||||
for (const candidate of candidates) {
|
||||
const result = Number(update.run(candidate.id).changes)
|
||||
if (result > 0) {
|
||||
this.db
|
||||
.prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?')
|
||||
.run(candidate.owner_dispatch_id)
|
||||
changed += result
|
||||
}
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return changed
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function listWorkerTerminalReleaseBacklog(
|
||||
this: OrchestrationDb
|
||||
): WorkerTerminalResourceRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM worker_terminal_resources
|
||||
WHERE release_state IN ('requested', 'releasing')
|
||||
ORDER BY release_requested_at ASC`
|
||||
)
|
||||
.all() as WorkerTerminalResourceRow[]
|
||||
}
|
||||
|
||||
export function listWorkerTerminalResources(
|
||||
this: OrchestrationDb,
|
||||
params: { runId?: string } = {}
|
||||
): {
|
||||
dispatchId: string
|
||||
taskId: string
|
||||
runId: string
|
||||
workerState: WorkerDispatchState
|
||||
dispatchStatus: DispatchStatus
|
||||
agentTerminalHandle: string | null
|
||||
terminalState: WorkerTerminalListState | null
|
||||
resource: WorkerTerminalResourceRow | null
|
||||
}[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT w.dispatch_id, w.state AS worker_state, w.agent_terminal_handle,
|
||||
d.task_id, d.run_id, d.status AS dispatch_status
|
||||
FROM worker_dispatches w
|
||||
JOIN dispatch_contexts d ON d.id = w.dispatch_id
|
||||
${params.runId ? 'WHERE d.run_id = ?' : ''}
|
||||
ORDER BY w.created_at ASC`
|
||||
)
|
||||
.all(...(params.runId ? [params.runId] : [])) as {
|
||||
dispatch_id: string
|
||||
worker_state: WorkerDispatchState
|
||||
agent_terminal_handle: string | null
|
||||
task_id: string
|
||||
run_id: string
|
||||
dispatch_status: DispatchStatus
|
||||
}[]
|
||||
const resources = this.db
|
||||
.prepare(
|
||||
`SELECT r.* FROM worker_terminal_resources r
|
||||
JOIN dispatch_contexts d ON d.id = r.owner_dispatch_id
|
||||
${params.runId ? 'WHERE d.run_id = ?' : ''}`
|
||||
)
|
||||
.all(...(params.runId ? [params.runId] : [])) as WorkerTerminalResourceRow[]
|
||||
const resourceByOwner = new Map(
|
||||
resources.map((resource) => [resource.owner_dispatch_id, resource])
|
||||
)
|
||||
return rows.map((row) => {
|
||||
const resource = resourceByOwner.get(row.dispatch_id) ?? null
|
||||
return {
|
||||
dispatchId: row.dispatch_id,
|
||||
taskId: row.task_id,
|
||||
runId: row.run_id,
|
||||
workerState: row.worker_state,
|
||||
dispatchStatus: row.dispatch_status,
|
||||
agentTerminalHandle: row.agent_terminal_handle,
|
||||
terminalState: deriveWorkerTerminalListState({
|
||||
workerState: row.worker_state,
|
||||
agentTerminalHandle: row.agent_terminal_handle,
|
||||
resource
|
||||
}),
|
||||
resource
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type WorkerTerminalListingMethods = {
|
||||
markWorkerTerminalUserOwned: typeof markWorkerTerminalUserOwned
|
||||
listWorkerTerminalReleaseBacklog: typeof listWorkerTerminalReleaseBacklog
|
||||
listWorkerTerminalResources: typeof listWorkerTerminalResources
|
||||
}
|
||||
|
||||
export function attachWorkerTerminalListing(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
markWorkerTerminalUserOwned,
|
||||
listWorkerTerminalReleaseBacklog,
|
||||
listWorkerTerminalResources
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership'
|
||||
import type {
|
||||
WorkerTerminalResourceRow,
|
||||
WorkerTerminalRetainedReason
|
||||
} from '../../worker-terminal-ownership'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { parseWorkerTerminalPriorOwnerIds } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function requestWorkerTerminalRelease(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
):
|
||||
| { disposition: 'requested'; resource: WorkerTerminalResourceRow }
|
||||
| { disposition: 'already_released'; resource: WorkerTerminalResourceRow }
|
||||
| {
|
||||
disposition: 'retained'
|
||||
resource: WorkerTerminalResourceRow | null
|
||||
reason: WorkerTerminalRetainedReason
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const worker = this.getWorkerDispatch(dispatchId)
|
||||
if (!worker) {
|
||||
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
|
||||
}
|
||||
if (!WORKER_SETTLED_STATES.includes(worker.state)) {
|
||||
// Why: release is post-completion cleanup only; recording intent for an unsettled or
|
||||
// uncertain worker would let recovery close a terminal the coordinator never reviewed.
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is ${worker.state}; only a settled worker can release. Use worker-stop to cancel an active worker.`
|
||||
)
|
||||
}
|
||||
const resource = this.getWorkerTerminalResourceByOwner(dispatchId)
|
||||
if (!resource) {
|
||||
const transferred = this.getWorkerTerminalResourceFormerlyOwnedBy(dispatchId)
|
||||
this.db.exec('COMMIT')
|
||||
return transferred
|
||||
? { disposition: 'retained', resource: transferred, reason: 'ownership_transferred' }
|
||||
: { disposition: 'retained', resource: null, reason: 'no_owned_resource' }
|
||||
}
|
||||
if (resource.release_state === 'released' || resource.ownership_state === 'released') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'already_released', resource }
|
||||
}
|
||||
if (worker.state === 'stopped' || worker.state === 'abandoned') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'retained', resource, reason: 'identity_unproven' }
|
||||
}
|
||||
if (resource.ownership_state === 'external') {
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
disposition: 'retained',
|
||||
resource,
|
||||
reason: (resource.retained_reason as WorkerTerminalRetainedReason) ?? 'external_terminal'
|
||||
}
|
||||
}
|
||||
if (resource.ownership_state === 'user_owned') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'retained', resource, reason: 'user_takeover' }
|
||||
}
|
||||
if (resource.ownership_state === 'transferred') {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'retained', resource, reason: 'ownership_transferred' }
|
||||
}
|
||||
if (
|
||||
resource.release_state === 'unknown' ||
|
||||
(resource.release_state === 'retained' && resource.retained_reason === 'user_requested')
|
||||
) {
|
||||
this.db.prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?').run(dispatchId)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = CASE
|
||||
WHEN release_state = 'releasing' THEN 'releasing'
|
||||
ELSE 'requested'
|
||||
END,
|
||||
retained_reason = NULL,
|
||||
release_requested_at = COALESCE(release_requested_at, datetime('now')),
|
||||
release_error = NULL, updated_at = datetime('now')
|
||||
WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested', 'releasing', 'unknown')`
|
||||
)
|
||||
.run(resource.id)
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
disposition: 'requested',
|
||||
resource: this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow
|
||||
}
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function settleDeadWorkerTerminalRelease(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
requestingDispatchId: string
|
||||
resourceId: string
|
||||
processIncarnation: string
|
||||
}
|
||||
):
|
||||
| { disposition: 'released'; resource: WorkerTerminalResourceRow }
|
||||
| { disposition: 'retained'; resource: WorkerTerminalResourceRow } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const resource = this.getWorkerTerminalResource(params.resourceId)
|
||||
if (!resource) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Worker terminal resource ${params.resourceId} was not found.`
|
||||
)
|
||||
}
|
||||
const priorOwners = parseWorkerTerminalPriorOwnerIds(resource.prior_owner_dispatch_ids)
|
||||
const requesterRelated =
|
||||
resource.owner_dispatch_id === params.requestingDispatchId ||
|
||||
priorOwners?.includes(params.requestingDispatchId) === true
|
||||
const requester = this.getWorkerDispatch(params.requestingDispatchId)
|
||||
const owner = this.getWorkerDispatch(resource.owner_dispatch_id)
|
||||
const requesterSettled = Boolean(requester && WORKER_SETTLED_STATES.includes(requester.state))
|
||||
const ownerSettled = Boolean(owner && WORKER_SETTLED_STATES.includes(owner.state))
|
||||
if (
|
||||
!priorOwners ||
|
||||
!requesterRelated ||
|
||||
!requesterSettled ||
|
||||
!ownerSettled ||
|
||||
resource.process_incarnation !== params.processIncarnation ||
|
||||
resource.ownership_state === 'released' ||
|
||||
!['not_requested', 'retained', 'unknown'].includes(resource.release_state)
|
||||
) {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: 'retained', resource }
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'released', ownership_state = 'released', retained_reason = NULL,
|
||||
release_requested_at = COALESCE(release_requested_at, datetime('now')),
|
||||
release_completed_at = datetime('now'), release_error = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND process_incarnation = ? AND ownership_state != 'released'
|
||||
AND release_state IN ('not_requested', 'retained', 'unknown')`
|
||||
)
|
||||
.run(params.resourceId, params.processIncarnation)
|
||||
const released = this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow
|
||||
this.db.exec('COMMIT')
|
||||
return released.release_state === 'released'
|
||||
? { disposition: 'released', resource: released }
|
||||
: { disposition: 'retained', resource: released }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerTerminalReleaseMethods = {
|
||||
requestWorkerTerminalRelease: typeof requestWorkerTerminalRelease
|
||||
settleDeadWorkerTerminalRelease: typeof settleDeadWorkerTerminalRelease
|
||||
}
|
||||
|
||||
export function attachWorkerTerminalRelease(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
requestWorkerTerminalRelease,
|
||||
settleDeadWorkerTerminalRelease
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type {
|
||||
WorkerTerminalResourceRow,
|
||||
WorkerTerminalOwnershipState
|
||||
} from '../../worker-terminal-ownership'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// --- Worker terminal resources (schema v23) ---------------------------------------------------
|
||||
|
||||
// Historical renderer input and reuse cannot be proven, so pre-v23 terminals stay external.
|
||||
export function backfillWorkerTerminalResources(this: OrchestrationDb): void {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT w.dispatch_id, w.worktree_id, w.agent_terminal_handle,
|
||||
d.assignee_pane_key, d.process_incarnation
|
||||
FROM worker_dispatches w
|
||||
JOIN dispatch_contexts d ON d.id = w.dispatch_id
|
||||
WHERE w.agent_terminal_handle IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM federated_dispatches f WHERE f.dispatch_id = w.dispatch_id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM worker_terminal_resources r WHERE r.owner_dispatch_id = w.dispatch_id
|
||||
)`
|
||||
)
|
||||
.all() as {
|
||||
dispatch_id: string
|
||||
worktree_id: string | null
|
||||
agent_terminal_handle: string
|
||||
assignee_pane_key: string | null
|
||||
process_incarnation: string | null
|
||||
}[]
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO worker_terminal_resources (
|
||||
id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle,
|
||||
pane_key, process_incarnation, ownership_state, release_state, retained_reason
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
for (const row of rows) {
|
||||
insert.run(
|
||||
generateId('wtr'),
|
||||
row.dispatch_id,
|
||||
row.dispatch_id,
|
||||
row.worktree_id,
|
||||
row.agent_terminal_handle,
|
||||
row.assignee_pane_key,
|
||||
row.process_incarnation,
|
||||
'external',
|
||||
'retained',
|
||||
'legacy_ambiguous'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// No transaction: composes inside worker-start's authority transaction.
|
||||
|
||||
// No transaction: composes inside worker-start's authority transaction.
|
||||
export function createWorkerTerminalResourceStatement(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
worktreeId: string | null
|
||||
terminalHandle: string
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
hostScope?: string | null
|
||||
ownership: Extract<WorkerTerminalOwnershipState, 'owned' | 'external'>
|
||||
}
|
||||
): WorkerTerminalResourceRow {
|
||||
const id = generateId('wtr')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO worker_terminal_resources (
|
||||
id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle,
|
||||
pane_key, process_incarnation, host_scope, ownership_state, release_state,
|
||||
retained_reason
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_requested', ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
params.dispatchId,
|
||||
params.dispatchId,
|
||||
params.worktreeId,
|
||||
params.terminalHandle,
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
params.hostScope ?? null,
|
||||
params.ownership,
|
||||
params.ownership === 'external' ? 'external_terminal' : null
|
||||
)
|
||||
return this.getWorkerTerminalResource(id) as WorkerTerminalResourceRow
|
||||
}
|
||||
|
||||
export function getWorkerTerminalResource(
|
||||
this: OrchestrationDb,
|
||||
id: string
|
||||
): WorkerTerminalResourceRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM worker_terminal_resources WHERE id = ?').get(id) as
|
||||
| WorkerTerminalResourceRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function getWorkerTerminalResourceByOwner(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): WorkerTerminalResourceRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM worker_terminal_resources WHERE owner_dispatch_id = ?')
|
||||
.get(dispatchId) as WorkerTerminalResourceRow | undefined
|
||||
}
|
||||
|
||||
export function getWorkerTerminalResourceFormerlyOwnedBy(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): WorkerTerminalResourceRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM worker_terminal_resources
|
||||
WHERE prior_owner_dispatch_ids LIKE ?
|
||||
ORDER BY updated_at DESC LIMIT 1`
|
||||
)
|
||||
.get(`%"${dispatchId}"%`) as WorkerTerminalResourceRow | undefined
|
||||
}
|
||||
|
||||
// Reusable exact settled terminal: transfers cleanup ownership to the new Dispatch and fences
|
||||
// release through the old owner. No transaction: composes inside the authority transaction.
|
||||
|
||||
// Reusable exact settled terminal: transfers cleanup ownership to the new Dispatch and fences
|
||||
// release through the old owner. No transaction: composes inside the authority transaction.
|
||||
export function transferWorkerTerminalResourceStatement(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
resourceId: string
|
||||
toDispatchId: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
hostScope: string | null
|
||||
}
|
||||
): WorkerTerminalResourceRow {
|
||||
const resource = this.getWorkerTerminalResource(params.resourceId)
|
||||
if (!resource) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Worker terminal resource ${params.resourceId} was not found.`
|
||||
)
|
||||
}
|
||||
const priorOwners = JSON.parse(resource.prior_owner_dispatch_ids) as string[]
|
||||
priorOwners.push(resource.owner_dispatch_id)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET owner_dispatch_id = ?, prior_owner_dispatch_ids = ?, release_state = 'not_requested',
|
||||
retained_reason = NULL, release_requested_at = NULL, release_completed_at = NULL,
|
||||
release_error = NULL, terminal_handle = ?, pane_key = ?, process_incarnation = ?,
|
||||
host_scope = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND ownership_state = 'owned'`
|
||||
)
|
||||
.run(
|
||||
params.toDispatchId,
|
||||
JSON.stringify(priorOwners),
|
||||
params.terminalHandle,
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
params.hostScope,
|
||||
params.resourceId
|
||||
)
|
||||
return this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow
|
||||
}
|
||||
|
||||
// Finds an owned, settled, exact-match resource for an explicitly reused terminal.
|
||||
|
||||
export type WorkerTerminalResourceStoreMethods = {
|
||||
backfillWorkerTerminalResources: typeof backfillWorkerTerminalResources
|
||||
createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement
|
||||
getWorkerTerminalResource: typeof getWorkerTerminalResource
|
||||
getWorkerTerminalResourceByOwner: typeof getWorkerTerminalResourceByOwner
|
||||
getWorkerTerminalResourceFormerlyOwnedBy: typeof getWorkerTerminalResourceFormerlyOwnedBy
|
||||
transferWorkerTerminalResourceStatement: typeof transferWorkerTerminalResourceStatement
|
||||
}
|
||||
|
||||
export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
backfillWorkerTerminalResources,
|
||||
createWorkerTerminalResourceStatement,
|
||||
getWorkerTerminalResource,
|
||||
getWorkerTerminalResourceByOwner,
|
||||
getWorkerTerminalResourceFormerlyOwnedBy,
|
||||
transferWorkerTerminalResourceStatement
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { WorkerTerminalResourceRow } from '../../worker-terminal-ownership'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// Finds an owned, settled, exact-match resource for an explicitly reused terminal.
|
||||
export function findTransferableWorkerTerminalResource(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
terminalHandle: string
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
hostScope: string | null
|
||||
}
|
||||
): WorkerTerminalResourceRow | undefined {
|
||||
if (!params.paneKey || !params.processIncarnation) {
|
||||
return undefined
|
||||
}
|
||||
const candidates = this.db
|
||||
.prepare(
|
||||
`SELECT r.* FROM worker_terminal_resources r
|
||||
JOIN worker_dispatches w ON w.dispatch_id = r.owner_dispatch_id
|
||||
WHERE r.process_incarnation = ? AND r.host_scope IS ?
|
||||
AND r.ownership_state != 'released'`
|
||||
)
|
||||
.all(params.processIncarnation, params.hostScope) as WorkerTerminalResourceRow[]
|
||||
const exact = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.pane_key &&
|
||||
params.paneKey &&
|
||||
isEquivalentPaneKey(candidate.pane_key, params.paneKey) &&
|
||||
candidate.process_incarnation === params.processIncarnation &&
|
||||
candidate.host_scope === params.hostScope
|
||||
)
|
||||
if (
|
||||
exact.some((candidate) =>
|
||||
['requested', 'releasing', 'unknown'].includes(candidate.release_state)
|
||||
)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'terminal_release_in_progress',
|
||||
`Terminal ${params.terminalHandle} has a release in progress; wait for cleanup or use another terminal.`
|
||||
)
|
||||
}
|
||||
return exact.find(
|
||||
(candidate) =>
|
||||
candidate.ownership_state === 'owned' &&
|
||||
['not_requested', 'retained'].includes(candidate.release_state) &&
|
||||
['succeeded', 'failed', 'stopped', 'abandoned'].includes(
|
||||
this.getWorkerDispatch(candidate.owner_dispatch_id)?.state ?? ''
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function workerTerminalResourceHasIdentityConflict(
|
||||
this: OrchestrationDb,
|
||||
resourceId: string
|
||||
): boolean {
|
||||
const resource = this.getWorkerTerminalResource(resourceId)
|
||||
if (!resource?.pane_key || !resource.process_incarnation) {
|
||||
return true
|
||||
}
|
||||
const candidates = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM worker_terminal_resources
|
||||
WHERE process_incarnation = ? AND host_scope IS ?
|
||||
AND id != ? AND ownership_state != 'released'`
|
||||
)
|
||||
.all(
|
||||
resource.process_incarnation,
|
||||
resource.host_scope,
|
||||
resource.id
|
||||
) as WorkerTerminalResourceRow[]
|
||||
return candidates.some(
|
||||
(candidate) =>
|
||||
candidate.pane_key &&
|
||||
isEquivalentPaneKey(candidate.pane_key, resource.pane_key as string) &&
|
||||
candidate.process_incarnation === resource.process_incarnation &&
|
||||
candidate.host_scope === resource.host_scope
|
||||
)
|
||||
}
|
||||
|
||||
export type WorkerTerminalTransferMethods = {
|
||||
findTransferableWorkerTerminalResource: typeof findTransferableWorkerTerminalResource
|
||||
workerTerminalResourceHasIdentityConflict: typeof workerTerminalResourceHasIdentityConflict
|
||||
}
|
||||
|
||||
export function attachWorkerTerminalTransfer(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
findTransferableWorkerTerminalResource,
|
||||
workerTerminalResourceHasIdentityConflict
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user