@knpkv/herdr-work 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/display-state.d.ts +10 -0
- package/dist/display-state.d.ts.map +1 -0
- package/dist/display-state.js +5 -0
- package/dist/display-state.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/legacy-lane-claim.d.ts +74 -0
- package/dist/internal/legacy-lane-claim.d.ts.map +1 -0
- package/dist/internal/legacy-lane-claim.js +81 -0
- package/dist/internal/legacy-lane-claim.js.map +1 -0
- package/dist/internal/session-index.d.ts +18 -0
- package/dist/internal/session-index.d.ts.map +1 -0
- package/dist/internal/session-index.js +23 -0
- package/dist/internal/session-index.js.map +1 -0
- package/dist/internal/terminal-reconcile.d.ts +29 -0
- package/dist/internal/terminal-reconcile.d.ts.map +1 -0
- package/dist/internal/terminal-reconcile.js +79 -0
- package/dist/internal/terminal-reconcile.js.map +1 -0
- package/dist/model.d.ts +909 -29
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +236 -7
- package/dist/model.js.map +1 -1
- package/dist/observed.d.ts +77 -0
- package/dist/observed.d.ts.map +1 -0
- package/dist/observed.js +240 -0
- package/dist/observed.js.map +1 -0
- package/dist/projection.d.ts +192 -12
- package/dist/projection.d.ts.map +1 -1
- package/dist/service.d.ts +121 -992
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +13 -3
- package/dist/service.js.map +1 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +76 -3
- package/dist/sql.js.map +1 -1
- package/dist/store.d.ts +193 -11
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +687 -37
- package/dist/store.js.map +1 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +169 -103
- package/dist/view.js.map +1 -1
- package/dist/work-triage.d.ts +72 -0
- package/dist/work-triage.d.ts.map +1 -0
- package/dist/work-triage.js +114 -0
- package/dist/work-triage.js.map +1 -0
- package/package.json +3 -3
- package/src/styles.css +229 -237
package/dist/store.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { agentConnectTarget, fleetResponseBodyMaxBytes, workReassignActivitySummary } from "@knpkv/herdr-fleet";
|
|
1
|
+
import { agentConnectTarget, fleetResponseBodyMaxBytes, workAbandonActivitySummary, workReassignActivitySummary } from "@knpkv/herdr-fleet";
|
|
2
2
|
import { openPrivateSqlite } from "@knpkv/herdr-fleet/sqlite";
|
|
3
|
-
import { Clock, Crypto, Effect, Equal, Option, Schema } from "effect";
|
|
3
|
+
import { Clock, Crypto, Effect, Equal, Option, Result, Schema } from "effect";
|
|
4
4
|
import { Hex } from "effect/encoding";
|
|
5
5
|
import { makeWorkAgentBinding } from "./agent-binding.js";
|
|
6
|
-
import { WorkAdmissionConflictError, WorkAgentBindingAuthorityError, WorkAgentBindingConflictError, WorkCheckpointConflictError, WorkCoordinatorHandoffConflictError, WorkDecisionAuthorityConflictError, WorkDecisionHandoffConflictError, WorkDecisionRevisionConflictError, WorkGoalAgentTargetConflictError, WorkGoalBindingRequiresAgentError, WorkGoalOwnerMismatchError, WorkGoalReassignmentConflictError, WorkGoalRevisionConflictError, WorkLaneClaimConflictError, WorkLaneGoalConflictError, WorkLaneOperationConflictError, WorkProjectionError, WorkPullRequestLinkError, WorkStoreError, WorkTransactionConflictError } from "./errors.js";
|
|
6
|
+
import { WorkAdmissionConflictError, WorkAgentBindingAuthorityError, WorkAgentBindingConflictError, WorkCheckpointConflictError, WorkCoordinatorHandoffConflictError, WorkDecisionAuthorityConflictError, WorkDecisionHandoffConflictError, WorkDecisionRevisionConflictError, WorkGoalAbandonmentConflictError, WorkGoalAgentTargetConflictError, WorkGoalBindingRequiresAgentError, WorkGoalLaneActiveError, WorkGoalOwnerMismatchError, WorkGoalReassignmentConflictError, WorkGoalRevisionConflictError, WorkGoalTerminalError, WorkLaneClaimConflictError, WorkLaneGoalConflictError, WorkLaneOperationConflictError, WorkProjectionError, WorkPullRequestLinkError, WorkStoreError, WorkTransactionConflictError } from "./errors.js";
|
|
7
7
|
import { validateGoalFamilyHistory } from "./goal-family.js";
|
|
8
8
|
import { agentBindingAdmissionError, workAgentBindingLaneOperationMaxBytes, workAgentBindingLaneOperationMaxRecords, workAgentBindingMaximumSnapshotBytes, workAgentBindingSnapshotEnvelopeMaxBytes, workMaximumSnapshotBytesForHistory } from "./internal/agent-binding-admission.js";
|
|
9
9
|
import { AgentBindingGoalEventRow, AgentBindingLaneOperationRow, agentBindingReadbackError, AgentBindingRow, decodeAgentBindingGoalEvent, decodeAgentBindingRow, laneOperationReadbackError } from "./internal/agent-binding-readback.js";
|
|
10
10
|
import { CoordinatorCommandRow, CoordinatorLifecycleDispatchRow, CoordinatorLifecycleEventRow, coordinatorLifecycleRunningAt, CoordinatorRouteDiscriminatorRow, coordinatorRouteRequiresWorkLink, requireCoordinatorFailedLunaAuthority, requireCoordinatorLifecycleAuthority, requireCoordinatorRouteAuthority, requireCoordinatorRouteBinding } from "./internal/coordinator-authority.js";
|
|
11
11
|
import { currentDecisionHandoffEquivalent, CurrentMetadataWorkLink, decodePreviousDecisionHandoff, previousDecisionHandoffEquivalent, PreviousWorkDecisionHandoff, upgradePreviousDecisionHandoff, workDispatchLineageContainedBy, workDispatchLineageEquivalent } from "./internal/decision-handoff-migration.js";
|
|
12
12
|
import { workHistoryError } from "./internal/history-validation.js";
|
|
13
|
-
import {
|
|
13
|
+
import { LaneOperationLedgerRow, LaneOperationTotalsRow, planLegacyLaneOperations, resolveLegacyLaneClaim } from "./internal/legacy-lane-claim.js";
|
|
14
|
+
import { enforcesOneHandoffPerSession, SessionIndexDefinition, sessionIndexDefinitionQuery } from "./internal/session-index.js";
|
|
15
|
+
import { goalActivityLimit, terminalCandidates, terminalCheckpoint, withoutBlockers } from "./internal/terminal-reconcile.js";
|
|
16
|
+
import { admissionEvidence, isTerminalWorkState, WorkAdmissionTarget, WorkAgentBinding, WorkAgentBindingRequest, WorkCoordinatorSessionId, WorkDecisionHandoff, WorkDispatchHandoff, WorkExistingGoalRecovery, WorkGoal, WorkGoalAbandoned, WorkGoalAbandonment, WorkGoalCheckpoint, WorkGoalId, WorkGoalReassigned, WorkGoalReassignment, workHistoryMaxEvents, WorkLaneClaim, WorkLaneClaimed, WorkObservationEnvelope, workObservationMaxSkewMillis, WorkObservationSubject, WorkObservedAdmission, WorkObservedFact, workObservedFactMaxBytes, workObservedFactMaxRecords, WorkObservedFailure, WorkProspectiveAdmission, WorkPullRequestLink, workReconcilerHeadroom, WorkRecoveryTarget, workSnapshotMaxGoals } from "./model.js";
|
|
17
|
+
import { asciiLower, canonicalSubject, observationSubject, reconcilerEventPrefix } from "./observed.js";
|
|
14
18
|
const StoredEventRow = Schema.Struct({ record: Schema.String });
|
|
15
19
|
const StoredEventRows = Schema.Array(StoredEventRow);
|
|
16
20
|
const StoredEventWithTransactionRow = Schema.Struct({
|
|
@@ -138,6 +142,31 @@ const legacyDecisionEquivalent = Schema.toEquivalence(LegacyDecisionRecord);
|
|
|
138
142
|
const TransactionId = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256), Schema.isPattern(/^(?:[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF])*$/));
|
|
139
143
|
const storeError = (operation) => (cause) => new WorkStoreError({ cause, operation });
|
|
140
144
|
const fromPrivateDatabaseError = (error) => new WorkStoreError({ cause: error.cause, operation: error.operation });
|
|
145
|
+
/**
|
|
146
|
+
* Files written before the session index was unique carry a plain index under
|
|
147
|
+
* the same name, which `CREATE UNIQUE INDEX IF NOT EXISTS` leaves in place.
|
|
148
|
+
* Replace it, failing closed when two handoffs already share a session. Runs
|
|
149
|
+
* inside `WorkStore.open`'s schema transaction, so a failure leaves the plain
|
|
150
|
+
* index in place.
|
|
151
|
+
*/
|
|
152
|
+
const requireUniqueSessionIndex = (database) => {
|
|
153
|
+
const sessionIndex = Schema.decodeUnknownSync(Schema.Array(SessionIndexDefinition))(database.prepare(sessionIndexDefinitionQuery).all())[0];
|
|
154
|
+
if (sessionIndex === undefined || enforcesOneHandoffPerSession(sessionIndex))
|
|
155
|
+
return;
|
|
156
|
+
// NULL sessions never collide under a UNIQUE index, so they are not duplicates.
|
|
157
|
+
const duplicateGroups = `SELECT session_id AS sessionId, COUNT(*) AS rows, group_concat(handoff_id) AS handoffIds
|
|
158
|
+
FROM work_decision_handoffs WHERE session_id IS NOT NULL
|
|
159
|
+
GROUP BY session_id HAVING COUNT(*) > 1`;
|
|
160
|
+
const duplicates = database.prepare(`${duplicateGroups} LIMIT 10`).all();
|
|
161
|
+
if (duplicates.length > 0) {
|
|
162
|
+
const total = database.prepare(`SELECT COUNT(*) AS sessions FROM (${duplicateGroups})`).get();
|
|
163
|
+
throw new WorkStoreError({ cause: { duplicates, total }, operation: "open.migrate.session-index" });
|
|
164
|
+
}
|
|
165
|
+
database.exec(`
|
|
166
|
+
DROP INDEX work_decision_handoffs_session;
|
|
167
|
+
CREATE UNIQUE INDEX work_decision_handoffs_session ON work_decision_handoffs (session_id);
|
|
168
|
+
`);
|
|
169
|
+
};
|
|
141
170
|
/**
|
|
142
171
|
* Upgrades pre-session Work tables in place. Runs inside `WorkStore.open`'s
|
|
143
172
|
* schema transaction, so a failure here or in a later schema step leaves the
|
|
@@ -371,6 +400,8 @@ const migrateLegacyAuthorityTables = (database) => {
|
|
|
371
400
|
});
|
|
372
401
|
})
|
|
373
402
|
: [];
|
|
403
|
+
// Lanes the running bindings of legacy handoffs recorded; see resolveLegacyLaneClaim.
|
|
404
|
+
const bindingLanes = [];
|
|
374
405
|
const decisions = decisionColumns.length > 0 && !decisionColumns.includes("session_id")
|
|
375
406
|
? Schema.decodeUnknownSync(Schema.Array(LegacyDecisionStoredRow))(database.prepare("SELECT handoff_id AS handoffId, record FROM work_decision_handoffs").all()).map((row) => {
|
|
376
407
|
const legacy = Schema.decodeUnknownSync(LegacyDecisionRecord)(JSON.parse(row.record));
|
|
@@ -454,6 +485,7 @@ const migrateLegacyAuthorityTables = (database) => {
|
|
|
454
485
|
operation: "open.migrate.legacy-handoff-lane-revision"
|
|
455
486
|
});
|
|
456
487
|
}
|
|
488
|
+
bindingLanes.push(bindingDecision.binding.lane);
|
|
457
489
|
requireBindingCompanions(bindingDecision.binding, "open.migrate.legacy-agent-binding");
|
|
458
490
|
requireLifecycleAuthority(bindingDecision.binding.request.dispatchRequestId, bindingDecision.binding.checkpoint.occurredAt, "open.migrate.legacy-agent-binding.lifecycle");
|
|
459
491
|
return Schema.decodeUnknownSync(WorkDecisionHandoff)({
|
|
@@ -468,13 +500,47 @@ const migrateLegacyAuthorityTables = (database) => {
|
|
|
468
500
|
});
|
|
469
501
|
})
|
|
470
502
|
: [];
|
|
503
|
+
const migratedClaims = lanes.map((lane) => resolveLegacyLaneClaim(lane, bindingLanes));
|
|
471
504
|
if (laneColumns.length > 0 && !laneColumns.includes("goal_id")) {
|
|
472
505
|
database.exec("ALTER TABLE work_lane_claims ADD COLUMN goal_id TEXT");
|
|
473
506
|
database.exec("ALTER TABLE work_lane_claims ADD COLUMN operation_id TEXT");
|
|
474
507
|
database.exec("ALTER TABLE work_lane_claims ADD COLUMN phase TEXT");
|
|
475
508
|
const update = database.prepare("UPDATE work_lane_claims SET goal_id = ?, operation_id = ?, phase = ?, record = ? WHERE lane_id = ?");
|
|
476
|
-
|
|
509
|
+
// An existing lane-operation ledger is not backfilled later, so record each
|
|
510
|
+
// migrated claim's operation here; the claim's readback requires it.
|
|
511
|
+
// Without a ledger, schema creation below creates it and backfills every
|
|
512
|
+
// claim, so the same bound applies to an empty ledger.
|
|
513
|
+
const hasLedger = tables.includes("work_lane_operations");
|
|
514
|
+
const operations = planLegacyLaneOperations(migratedClaims.map(({ lane }) => lane), new Map(hasLedger
|
|
515
|
+
? migratedClaims.flatMap(({ lane }) => {
|
|
516
|
+
const matches = database.prepare(`SELECT operation_id AS operationId, lane_id AS laneId, goal_id AS goalId, phase, revision, record
|
|
517
|
+
FROM work_lane_operations WHERE CAST(operation_id AS TEXT) = ?`).all(lane.operationId);
|
|
518
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(LaneOperationLedgerRow))(matches);
|
|
519
|
+
// A key stored as a blob, or twice, is a collision even when its bytes match.
|
|
520
|
+
const found = rows.find(({ operationId }) => operationId !== lane.operationId) ?? rows[0];
|
|
521
|
+
return found === undefined ? [] : [[lane.operationId, found]];
|
|
522
|
+
})
|
|
523
|
+
: []), hasLedger
|
|
524
|
+
? Schema.decodeUnknownSync(LaneOperationTotalsRow)(database.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(
|
|
525
|
+
length(CAST(operation_id AS BLOB)) + length(CAST(record AS BLOB))), 0) AS bytes
|
|
526
|
+
FROM work_lane_operations`).get())
|
|
527
|
+
: { bytes: 0, count: 0 }, { bytes: workLaneOperationMaxBytes, records: workLaneOperationMaxRecords });
|
|
528
|
+
if (operations._tag === "collision") {
|
|
529
|
+
throw new WorkStoreError({ cause: operations, operation: "open.migrate.lane-operation-collision" });
|
|
530
|
+
}
|
|
531
|
+
if (operations._tag === "capacity") {
|
|
532
|
+
throw new WorkStoreError({ cause: operations, operation: "open.migrate.lane-operation-capacity" });
|
|
533
|
+
}
|
|
534
|
+
for (const { lane } of migratedClaims) {
|
|
477
535
|
update.run(lane.goalId, lane.operationId, lane.phase, JSON.stringify(lane), lane.laneId);
|
|
536
|
+
}
|
|
537
|
+
if (hasLedger && operations.inserts.length > 0) {
|
|
538
|
+
const recordOperation = database.prepare(`INSERT INTO work_lane_operations
|
|
539
|
+
(operation_id, lane_id, goal_id, phase, revision, record) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
540
|
+
for (const lane of operations.inserts) {
|
|
541
|
+
recordOperation.run(lane.operationId, lane.laneId, lane.goalId, lane.phase, lane.revision, JSON.stringify(lane));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
478
544
|
}
|
|
479
545
|
if (decisionColumns.length > 0 && !decisionColumns.includes("session_id")) {
|
|
480
546
|
database.exec("ALTER TABLE work_decision_handoffs ADD COLUMN session_id TEXT");
|
|
@@ -721,6 +787,16 @@ const migrateLegacyAuthorityTables = (database) => {
|
|
|
721
787
|
}
|
|
722
788
|
}
|
|
723
789
|
}
|
|
790
|
+
// Reported after capacity: lane CAS allows one binding per revision, so
|
|
791
|
+
// several mean the file is inconsistent rather than merely large.
|
|
792
|
+
const ambiguousClaims = migratedClaims.filter(({ _tag }) => _tag === "ambiguous");
|
|
793
|
+
if (ambiguousClaims.length > 0) {
|
|
794
|
+
throw new WorkStoreError({ cause: { claims: ambiguousClaims }, operation: "open.migrate.lane-binding-ambiguous" });
|
|
795
|
+
}
|
|
796
|
+
const mismatchedClaims = migratedClaims.filter(({ _tag }) => _tag === "mismatch");
|
|
797
|
+
if (mismatchedClaims.length > 0) {
|
|
798
|
+
throw new WorkStoreError({ cause: { claims: mismatchedClaims }, operation: "open.migrate.lane-binding-mismatch" });
|
|
799
|
+
}
|
|
724
800
|
if (tables.includes("orchestrator_dispatch_metadata") && coordinatorDispatchColumns.includes("is_routed")) {
|
|
725
801
|
const pageSize = 512;
|
|
726
802
|
let cursor = null;
|
|
@@ -794,6 +870,7 @@ const readDecisionLedgerTotals = (database) => Schema.decodeUnknownSync(Decision
|
|
|
794
870
|
FROM work_decision_totals WHERE singleton = 1`).get());
|
|
795
871
|
const readLaneOperationLedgerTotals = (database) => Schema.decodeUnknownSync(LaneOperationLedgerTotalsRow)(database.prepare(`SELECT operation_count AS operationCount, operation_bytes AS operationBytes
|
|
796
872
|
FROM work_lane_operation_totals WHERE singleton = 1`).get());
|
|
873
|
+
const ObservationIdRow = Schema.Struct({ observationId: Schema.String });
|
|
797
874
|
const ReassignmentRow = Schema.Struct({ approvalJobId: Schema.String, goalId: Schema.String, record: Schema.String });
|
|
798
875
|
/** Applies a reassignment's agent choice; `keep` is only reached when the goal has no target. */
|
|
799
876
|
const withReassignedAgent = (goal, toAgent) => {
|
|
@@ -946,11 +1023,11 @@ const admissionState = (database, target) => {
|
|
|
946
1023
|
(claim.worktree === target.worktree && claim.laneId !== target.laneId)) ||
|
|
947
1024
|
authority.latest.some(({ request }) => request.worker.agentId === target.worker.agentId && request.laneId !== target.laneId) ||
|
|
948
1025
|
// A session id stays claimed by its lane even after a reassignment supersedes that binding.
|
|
949
|
-
bindings.some(({ request }) => (request
|
|
1026
|
+
bindings.some(({ request }) => (admissionEvidence(request)?.sessionId === target.sessionId && request.laneId !== target.laneId) ||
|
|
950
1027
|
(request.existingGoalRecovery?.sessionId === target.sessionId && request.laneId !== target.laneId)) || authority.ambiguousLanes.has(target.laneId);
|
|
951
1028
|
const exact = goal !== undefined && lane !== undefined && binding !== undefined &&
|
|
952
|
-
goal.goal.id === target.goalId && goal.goal.state
|
|
953
|
-
goal.goal.
|
|
1029
|
+
goal.goal.id === target.goalId && !isTerminalWorkState(goal.goal.state) &&
|
|
1030
|
+
goal.goal.goalFamily?.role === "canonical" &&
|
|
954
1031
|
goal.goal.goalFamily.canonicalGoalId === target.goalId &&
|
|
955
1032
|
Equal.equals(goal.goal.owner, target.owner) &&
|
|
956
1033
|
lane.goalId === target.goalId && lane.head === target.head &&
|
|
@@ -958,9 +1035,9 @@ const admissionState = (database, target) => {
|
|
|
958
1035
|
lane.phase !== "shipped" && Equal.equals(lane.owner, target.owner) &&
|
|
959
1036
|
binding.lane.laneId === target.laneId &&
|
|
960
1037
|
Equal.equals(binding.request.worker, target.worker) &&
|
|
961
|
-
(binding.request
|
|
962
|
-
binding.request
|
|
963
|
-
binding.request
|
|
1038
|
+
(admissionEvidence(binding.request) === undefined || (admissionEvidence(binding.request)?.sessionId === target.sessionId &&
|
|
1039
|
+
admissionEvidence(binding.request)?.workAssignment === target.expectedWork &&
|
|
1040
|
+
admissionEvidence(binding.request)?.baseHead === target.baseHead)) &&
|
|
964
1041
|
(binding.request.existingGoalRecovery === undefined ||
|
|
965
1042
|
(binding.request.existingGoalRecovery.sessionId === target.sessionId &&
|
|
966
1043
|
binding.request.existingGoalRecovery.workAssignment === target.expectedWork &&
|
|
@@ -987,7 +1064,7 @@ const admissionState = (database, target) => {
|
|
|
987
1064
|
entry.goalFamily?.canonicalGoalId === target.goalId ||
|
|
988
1065
|
entry.agentHierarchy?.agent.agentId === target.worker.agentId) || lanes.entries.some(({ claim }) => claim.laneId === target.laneId || claim.goalId === target.goalId ||
|
|
989
1066
|
claim.worktree === target.worktree) || authority.latest.some(({ request }) => request.worker.agentId === target.worker.agentId) ||
|
|
990
|
-
bindings.some(({ request }) => request
|
|
1067
|
+
bindings.some(({ request }) => admissionEvidence(request)?.sessionId === target.sessionId ||
|
|
991
1068
|
request.existingGoalRecovery?.sessionId === target.sessionId);
|
|
992
1069
|
if (conflicts) {
|
|
993
1070
|
return { _tag: "conflict", target, reason: "durable PR, goal, lane, worker, or worktree is already bound" };
|
|
@@ -1124,8 +1201,7 @@ const recoveryState = (database, target) => {
|
|
|
1124
1201
|
goal.connectTarget !== null ||
|
|
1125
1202
|
(goal.agentHierarchy !== undefined && goal.agentHierarchy !== null) ||
|
|
1126
1203
|
(goal.review?.url !== null && goal.review?.url !== undefined && goal.review.url !== target.reviewUrl) ||
|
|
1127
|
-
goal.state
|
|
1128
|
-
goal.state === "deployed")) {
|
|
1204
|
+
isTerminalWorkState(goal.state))) {
|
|
1129
1205
|
return conflict("canonical goal history is linked, terminal, or belongs to another owner");
|
|
1130
1206
|
}
|
|
1131
1207
|
const bindingRows = Schema.decodeUnknownSync(AgentBindingRows)(database
|
|
@@ -1158,7 +1234,7 @@ const recoveryState = (database, target) => {
|
|
|
1158
1234
|
operations.claims.some(({ goalId, laneId, worktree }) => goalId === target.goalId || laneId === target.laneId || worktree === target.worktree) ||
|
|
1159
1235
|
bindings.some(({ request }) => request.laneId === target.laneId ||
|
|
1160
1236
|
request.worker.agentId === target.worker.agentId ||
|
|
1161
|
-
request
|
|
1237
|
+
admissionEvidence(request)?.sessionId === target.sessionId ||
|
|
1162
1238
|
request.existingGoalRecovery?.sessionId === target.sessionId) ||
|
|
1163
1239
|
decisions.some(({ record }) => {
|
|
1164
1240
|
const handoff = Schema.decodeUnknownSync(WorkDecisionHandoff)(JSON.parse(record));
|
|
@@ -1186,6 +1262,140 @@ const readRecoveryState = (database, target) => {
|
|
|
1186
1262
|
throw cause;
|
|
1187
1263
|
}
|
|
1188
1264
|
};
|
|
1265
|
+
const ReassignmentApprovalRow = Schema.Struct({ approvalJobId: Schema.String, goalId: Schema.String });
|
|
1266
|
+
const ReconcilerEventRow = Schema.Struct({ eventId: Schema.String, goalId: Schema.String });
|
|
1267
|
+
const ObservedFactRow = Schema.Struct({
|
|
1268
|
+
subject: Schema.String,
|
|
1269
|
+
observationId: Schema.String,
|
|
1270
|
+
observedAt: Schema.Number,
|
|
1271
|
+
confirmedAt: Schema.Number,
|
|
1272
|
+
record: Schema.String
|
|
1273
|
+
});
|
|
1274
|
+
const StoredFactRow = Schema.Struct({ observationId: Schema.String, confirmedAt: Schema.Number });
|
|
1275
|
+
const FactTotalsRow = Schema.Struct({ count: Schema.Number, bytes: Schema.Number });
|
|
1276
|
+
const EvictionRow = Schema.Struct({ subject: Schema.String, bytes: Schema.Number });
|
|
1277
|
+
const canonicalObservation = (observation) => observation._tag === "agent"
|
|
1278
|
+
? { ...observation, host: asciiLower(observation.host) }
|
|
1279
|
+
: { ...observation, repository: asciiLower(observation.repository) };
|
|
1280
|
+
/**
|
|
1281
|
+
* Applies each observation in one transaction, then evicts the oldest rows
|
|
1282
|
+
* until facts and failures are each within their bounds.
|
|
1283
|
+
*
|
|
1284
|
+
* - The same facts again keep their first-seen time and move `confirmed_at`.
|
|
1285
|
+
* - Different facts replace the stored ones, unless the stored ones were
|
|
1286
|
+
* confirmed at or after this observation (then it is stale).
|
|
1287
|
+
* - A good read ends the subject's run of failures. A failure starts a run,
|
|
1288
|
+
* or keeps the running one's `since`; one older than the last good read is stale.
|
|
1289
|
+
*/
|
|
1290
|
+
const writeObservations = (database, prepared) => {
|
|
1291
|
+
database.exec("BEGIN IMMEDIATE");
|
|
1292
|
+
try {
|
|
1293
|
+
const readFact = database.prepare(`SELECT observation_id AS observationId, confirmed_at AS confirmedAt
|
|
1294
|
+
FROM work_observed_facts WHERE subject = ?`);
|
|
1295
|
+
const upsertFact = database.prepare(`INSERT INTO work_observed_facts (subject, observation_id, observed_at, confirmed_at, record)
|
|
1296
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1297
|
+
ON CONFLICT (subject) DO UPDATE SET observation_id = excluded.observation_id,
|
|
1298
|
+
observed_at = excluded.observed_at, confirmed_at = excluded.confirmed_at, record = excluded.record`);
|
|
1299
|
+
const confirmFact = database.prepare("UPDATE work_observed_facts SET confirmed_at = max(confirmed_at, ?) WHERE subject = ?");
|
|
1300
|
+
// A good read ends a run of failures only if it is newer than the run's
|
|
1301
|
+
// latest failure.
|
|
1302
|
+
const clearFailures = database.prepare("DELETE FROM work_observed_failures WHERE subject = ? AND last_at <= ?");
|
|
1303
|
+
// We keep only the run's first and latest failure, so a run split by a
|
|
1304
|
+
// delayed good read restarts at its latest failure: a real failed read.
|
|
1305
|
+
const trimFailures = database.prepare("UPDATE work_observed_failures SET since = last_at WHERE subject = ? AND since <= ? AND last_at > ?");
|
|
1306
|
+
const endFailures = {
|
|
1307
|
+
run: (subject, observedAt) => {
|
|
1308
|
+
clearFailures.run(subject, observedAt);
|
|
1309
|
+
trimFailures.run(subject, observedAt, observedAt);
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
// `since` is the earliest failure of the run; source and reason come from
|
|
1313
|
+
// the latest one, whatever order the failures arrive in.
|
|
1314
|
+
const recordFailure = database.prepare(`INSERT INTO work_observed_failures (subject, source, reason, since, last_at) VALUES (?, ?, ?, ?, ?)
|
|
1315
|
+
ON CONFLICT (subject) DO UPDATE SET
|
|
1316
|
+
since = min(since, excluded.since),
|
|
1317
|
+
source = CASE WHEN excluded.last_at >= last_at THEN excluded.source ELSE source END,
|
|
1318
|
+
reason = CASE WHEN excluded.last_at >= last_at THEN excluded.reason ELSE reason END,
|
|
1319
|
+
last_at = max(last_at, excluded.last_at)`);
|
|
1320
|
+
const outcomes = prepared.map((item) => {
|
|
1321
|
+
if (item._tag === "future")
|
|
1322
|
+
return { _tag: "stale", subject: item.subject };
|
|
1323
|
+
const row = readFact.get(item.subject);
|
|
1324
|
+
const stored = row === undefined ? undefined : Schema.decodeUnknownSync(StoredFactRow)(row);
|
|
1325
|
+
if (item._tag === "unknown") {
|
|
1326
|
+
if (stored !== undefined && stored.confirmedAt >= item.observedAt) {
|
|
1327
|
+
return { _tag: "stale", subject: item.subject };
|
|
1328
|
+
}
|
|
1329
|
+
recordFailure.run(item.subject, item.source, item.reason, item.observedAt, item.observedAt);
|
|
1330
|
+
return { _tag: "unknown", reason: item.reason, subject: item.subject };
|
|
1331
|
+
}
|
|
1332
|
+
if (stored?.observationId === item.observationId) {
|
|
1333
|
+
confirmFact.run(item.observedAt, item.subject);
|
|
1334
|
+
endFailures.run(item.subject, item.observedAt);
|
|
1335
|
+
return { _tag: "unchanged", subject: item.subject };
|
|
1336
|
+
}
|
|
1337
|
+
if (stored !== undefined && stored.confirmedAt >= item.observedAt)
|
|
1338
|
+
return { _tag: "stale", subject: item.subject };
|
|
1339
|
+
upsertFact.run(item.subject, item.observationId, item.observedAt, item.observedAt, item.record);
|
|
1340
|
+
endFailures.run(item.subject, item.observedAt);
|
|
1341
|
+
return { _tag: "stored", subject: item.subject };
|
|
1342
|
+
});
|
|
1343
|
+
// Totals are read once and kept current as rows go, so eviction is one
|
|
1344
|
+
// indexed lookup and delete per row, not a full rescan per row.
|
|
1345
|
+
const evict = (table, age, payload) => {
|
|
1346
|
+
const size = `length(CAST(subject AS BLOB)) + length(CAST(${payload} AS BLOB))`;
|
|
1347
|
+
const totals = Schema.decodeUnknownSync(FactTotalsRow)(database.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(${size}), 0) AS bytes FROM ${table}`).get());
|
|
1348
|
+
const oldest = database.prepare(`SELECT subject, ${size} AS bytes FROM ${table} ORDER BY ${age} ASC, subject ASC LIMIT 1`);
|
|
1349
|
+
const remove = database.prepare(`DELETE FROM ${table} WHERE subject = ?`);
|
|
1350
|
+
let { bytes, count } = totals;
|
|
1351
|
+
let evicted = 0;
|
|
1352
|
+
while (count > workObservedFactMaxRecords || bytes > workObservedFactMaxBytes) {
|
|
1353
|
+
const row = Schema.decodeUnknownSync(EvictionRow)(oldest.get());
|
|
1354
|
+
remove.run(row.subject);
|
|
1355
|
+
count -= 1;
|
|
1356
|
+
bytes -= row.bytes;
|
|
1357
|
+
evicted += 1;
|
|
1358
|
+
}
|
|
1359
|
+
return evicted;
|
|
1360
|
+
};
|
|
1361
|
+
// The least recently read rows go first: a fact by its last confirmation,
|
|
1362
|
+
// a failure by its latest failed read.
|
|
1363
|
+
const evicted = evict("work_observed_facts", "confirmed_at", "record") +
|
|
1364
|
+
evict("work_observed_failures", "last_at", "reason");
|
|
1365
|
+
database.exec("COMMIT");
|
|
1366
|
+
return { evicted, outcomes };
|
|
1367
|
+
}
|
|
1368
|
+
catch (cause) {
|
|
1369
|
+
if (database.isTransaction)
|
|
1370
|
+
database.exec("ROLLBACK");
|
|
1371
|
+
throw cause;
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
const admissionSummary = (provenance) => provenance._tag === "approved"
|
|
1375
|
+
? `Prospective admission of an existing owner by approved Fleet job ${provenance.approvalJobId}`
|
|
1376
|
+
: `Admission of an observed worker by the reconciler from observation ${provenance.observationId}`;
|
|
1377
|
+
/** The binding-request key that records how a worker was admitted. */
|
|
1378
|
+
const admissionRecord = (provenance, evidence) => provenance._tag === "approved"
|
|
1379
|
+
? {
|
|
1380
|
+
prospectiveAdmission: {
|
|
1381
|
+
...evidence,
|
|
1382
|
+
approvalJobId: provenance.approvalJobId,
|
|
1383
|
+
approvalActor: provenance.approvalActor
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
: { observedAdmission: { ...evidence, actor: "reconciler", observationId: provenance.observationId } };
|
|
1387
|
+
/** Whether a stored binding was admitted by exactly this provenance and absence evidence (an exact replay). */
|
|
1388
|
+
const sameAdmission = (request, expectedAbsenceToken, provenance) => {
|
|
1389
|
+
if (provenance._tag === "approved") {
|
|
1390
|
+
const stored = request.prospectiveAdmission;
|
|
1391
|
+
return stored !== undefined &&
|
|
1392
|
+
stored.expectedAbsenceToken === expectedAbsenceToken &&
|
|
1393
|
+
stored.approvalJobId === provenance.approvalJobId && stored.approvalActor === provenance.approvalActor;
|
|
1394
|
+
}
|
|
1395
|
+
const stored = request.observedAdmission;
|
|
1396
|
+
return stored !== undefined &&
|
|
1397
|
+
stored.expectedAbsenceToken === expectedAbsenceToken && stored.observationId === provenance.observationId;
|
|
1398
|
+
};
|
|
1189
1399
|
export class WorkStore {
|
|
1190
1400
|
#database;
|
|
1191
1401
|
#cryptoService;
|
|
@@ -1289,6 +1499,13 @@ export class WorkStore {
|
|
|
1289
1499
|
goal_id TEXT NOT NULL,
|
|
1290
1500
|
record TEXT NOT NULL
|
|
1291
1501
|
);
|
|
1502
|
+
-- One row per approved abandonment, committed with the goal checkpoint whose
|
|
1503
|
+
-- event id equals its approval job id, so work_goal_events bounds it too.
|
|
1504
|
+
CREATE TABLE IF NOT EXISTS work_goal_abandonments (
|
|
1505
|
+
approval_job_id TEXT PRIMARY KEY,
|
|
1506
|
+
goal_id TEXT NOT NULL,
|
|
1507
|
+
record TEXT NOT NULL
|
|
1508
|
+
);
|
|
1292
1509
|
CREATE TABLE IF NOT EXISTS work_decision_handoffs (
|
|
1293
1510
|
handoff_id TEXT PRIMARY KEY,
|
|
1294
1511
|
session_id TEXT NOT NULL UNIQUE,
|
|
@@ -1312,9 +1529,32 @@ export class WorkStore {
|
|
|
1312
1529
|
END;
|
|
1313
1530
|
CREATE INDEX IF NOT EXISTS work_decision_handoffs_lane_time
|
|
1314
1531
|
ON work_decision_handoffs (lane_id, occurred_at, handoff_id);
|
|
1315
|
-
CREATE INDEX IF NOT EXISTS work_decision_handoffs_session
|
|
1532
|
+
CREATE UNIQUE INDEX IF NOT EXISTS work_decision_handoffs_session
|
|
1316
1533
|
ON work_decision_handoffs (session_id);
|
|
1534
|
+
CREATE TABLE IF NOT EXISTS work_observed_facts (
|
|
1535
|
+
subject TEXT PRIMARY KEY,
|
|
1536
|
+
observation_id TEXT NOT NULL,
|
|
1537
|
+
observed_at INTEGER NOT NULL,
|
|
1538
|
+
confirmed_at INTEGER NOT NULL,
|
|
1539
|
+
record TEXT NOT NULL
|
|
1540
|
+
);
|
|
1541
|
+
CREATE INDEX IF NOT EXISTS work_observed_facts_age
|
|
1542
|
+
ON work_observed_facts (confirmed_at, subject);
|
|
1543
|
+
CREATE TABLE IF NOT EXISTS work_reconciler_events (
|
|
1544
|
+
event_id TEXT PRIMARY KEY,
|
|
1545
|
+
goal_id TEXT NOT NULL
|
|
1546
|
+
);
|
|
1547
|
+
CREATE TABLE IF NOT EXISTS work_observed_failures (
|
|
1548
|
+
subject TEXT PRIMARY KEY,
|
|
1549
|
+
source TEXT NOT NULL,
|
|
1550
|
+
reason TEXT NOT NULL,
|
|
1551
|
+
since INTEGER NOT NULL,
|
|
1552
|
+
last_at INTEGER NOT NULL
|
|
1553
|
+
);
|
|
1554
|
+
CREATE INDEX IF NOT EXISTS work_observed_failures_age
|
|
1555
|
+
ON work_observed_failures (last_at, subject);
|
|
1317
1556
|
`);
|
|
1557
|
+
requireUniqueSessionIndex(database);
|
|
1318
1558
|
const columns = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ name: Schema.String })))(database.prepare("PRAGMA table_info(work_goal_events)").all());
|
|
1319
1559
|
if (!columns.some(({ name }) => name === "transaction_id")) {
|
|
1320
1560
|
database.exec("ALTER TABLE work_goal_events ADD COLUMN transaction_id TEXT");
|
|
@@ -1876,6 +2116,155 @@ export class WorkStore {
|
|
|
1876
2116
|
return yield* decision.error;
|
|
1877
2117
|
return decision.result;
|
|
1878
2118
|
});
|
|
2119
|
+
/**
|
|
2120
|
+
* Applies an approved `work.abandon` job: the goal, owned by exactly the
|
|
2121
|
+
* approved owner and still at the approved head, becomes `abandoned` with its
|
|
2122
|
+
* blocker cleared and a status activity naming the job. A goal with an
|
|
2123
|
+
* active lane, or already finished, is refused; nothing else is written. An
|
|
2124
|
+
* exact replay of the job returns the stored result.
|
|
2125
|
+
*/
|
|
2126
|
+
abandon = Effect.fn("WorkStore.abandon")(function* (request) {
|
|
2127
|
+
const decoded = yield* Schema.decodeUnknownEffect(WorkGoalAbandonment)(request).pipe(Effect.mapError(storeError("abandon.decode")));
|
|
2128
|
+
const jobId = decoded.approvalJobId;
|
|
2129
|
+
const observedAt = yield* Clock.currentTimeMillis;
|
|
2130
|
+
yield* this.secureFiles();
|
|
2131
|
+
const decision = yield* Effect.try({
|
|
2132
|
+
try: () => {
|
|
2133
|
+
let transaction = false;
|
|
2134
|
+
const reject = (error) => {
|
|
2135
|
+
this.#database.exec("ROLLBACK");
|
|
2136
|
+
transaction = false;
|
|
2137
|
+
return { _tag: "rejected", error };
|
|
2138
|
+
};
|
|
2139
|
+
try {
|
|
2140
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
2141
|
+
transaction = true;
|
|
2142
|
+
const priorRaw = this.#database
|
|
2143
|
+
.prepare(`SELECT approval_job_id AS approvalJobId, goal_id AS goalId, record
|
|
2144
|
+
FROM work_goal_abandonments WHERE approval_job_id = ?`)
|
|
2145
|
+
.get(jobId);
|
|
2146
|
+
if (priorRaw !== undefined) {
|
|
2147
|
+
const row = Schema.decodeUnknownSync(ReassignmentRow)(priorRaw);
|
|
2148
|
+
const prior = Schema.decodeUnknownSync(WorkGoalAbandoned)(JSON.parse(row.record));
|
|
2149
|
+
if (row.approvalJobId !== prior.abandonment.approvalJobId || row.goalId !== prior.abandonment.goalId) {
|
|
2150
|
+
return reject(new WorkStoreError({ cause: { prior, row }, operation: "abandon.replay.identity-mismatch" }));
|
|
2151
|
+
}
|
|
2152
|
+
if (!Equal.equals(prior.abandonment, decoded)) {
|
|
2153
|
+
return reject(new WorkGoalAbandonmentConflictError({ approvalJobId: jobId, reason: "payload_mismatch" }));
|
|
2154
|
+
}
|
|
2155
|
+
this.#database.exec("ROLLBACK");
|
|
2156
|
+
transaction = false;
|
|
2157
|
+
return { _tag: "abandoned", result: prior };
|
|
2158
|
+
}
|
|
2159
|
+
const collision = this.#database
|
|
2160
|
+
.prepare(`SELECT 1 FROM work_goal_events WHERE event_id = ?
|
|
2161
|
+
UNION ALL SELECT 1 FROM work_lane_operations WHERE operation_id = ?
|
|
2162
|
+
UNION ALL SELECT 1 FROM work_agent_bindings WHERE dispatch_request_id = ? LIMIT 1`)
|
|
2163
|
+
.get(jobId, jobId, jobId);
|
|
2164
|
+
if (collision !== undefined) {
|
|
2165
|
+
return reject(new WorkGoalAbandonmentConflictError({ approvalJobId: jobId, reason: "identifier_in_use" }));
|
|
2166
|
+
}
|
|
2167
|
+
const eventRows = Schema.decodeUnknownSync(Schema.Array(AgentBindingGoalEventRow))(this.#database
|
|
2168
|
+
.prepare(`SELECT event_id AS eventId, goal_id AS goalId, occurred_at AS occurredAt, record
|
|
2169
|
+
FROM work_goal_events ORDER BY occurred_at ASC, event_id ASC LIMIT ?`)
|
|
2170
|
+
.all(workHistoryMaxEvents + 1));
|
|
2171
|
+
const history = [];
|
|
2172
|
+
for (const row of eventRows) {
|
|
2173
|
+
const event = decodeAgentBindingGoalEvent(row, "abandon.history");
|
|
2174
|
+
if (event._tag === "invalid")
|
|
2175
|
+
return reject(event.error);
|
|
2176
|
+
history.push(event.checkpoint);
|
|
2177
|
+
}
|
|
2178
|
+
const head = history.filter(({ goal }) => goal.id === decoded.goalId).at(-1);
|
|
2179
|
+
if (head === undefined ||
|
|
2180
|
+
head.eventId !== decoded.expectedGoalEventId ||
|
|
2181
|
+
head.goal.updatedAt !== decoded.expectedGoalUpdatedAt) {
|
|
2182
|
+
return reject(new WorkGoalRevisionConflictError({
|
|
2183
|
+
goalId: decoded.goalId,
|
|
2184
|
+
expectedEventId: decoded.expectedGoalEventId,
|
|
2185
|
+
expectedUpdatedAt: decoded.expectedGoalUpdatedAt,
|
|
2186
|
+
actualEventId: head?.eventId ?? null,
|
|
2187
|
+
actualUpdatedAt: head?.goal.updatedAt ?? null
|
|
2188
|
+
}));
|
|
2189
|
+
}
|
|
2190
|
+
if (!Equal.equals(head.goal.owner, decoded.owner)) {
|
|
2191
|
+
return reject(new WorkGoalOwnerMismatchError({
|
|
2192
|
+
goalId: decoded.goalId,
|
|
2193
|
+
laneId: null,
|
|
2194
|
+
expectedOwner: { id: decoded.owner.id, name: decoded.owner.name },
|
|
2195
|
+
actualOwner: { id: head.goal.owner.id, name: head.goal.owner.name }
|
|
2196
|
+
}));
|
|
2197
|
+
}
|
|
2198
|
+
if (isTerminalWorkState(head.goal.state)) {
|
|
2199
|
+
return reject(new WorkGoalTerminalError({ goalId: decoded.goalId, state: head.goal.state }));
|
|
2200
|
+
}
|
|
2201
|
+
const ledger = readValidatedLaneLedger(this.#database, "abandon.lane");
|
|
2202
|
+
if (ledger._tag === "invalid")
|
|
2203
|
+
return reject(ledger.error);
|
|
2204
|
+
const active = ledger.entries.find(({ claim }) => claim.goalId === decoded.goalId && claim.phase !== "shipped");
|
|
2205
|
+
if (active !== undefined) {
|
|
2206
|
+
return reject(new WorkGoalLaneActiveError({ goalId: decoded.goalId, laneId: active.claim.laneId }));
|
|
2207
|
+
}
|
|
2208
|
+
// occurredAt equals updatedAt for every valid checkpoint; both bound the ordering defensively.
|
|
2209
|
+
const at = Math.max(observedAt, head.occurredAt + 1, head.goal.updatedAt + 1);
|
|
2210
|
+
const existingActivity = head.goal.activity ?? [];
|
|
2211
|
+
const goal = Schema.decodeUnknownSync(WorkGoal)({
|
|
2212
|
+
...withoutBlockers(head.goal),
|
|
2213
|
+
state: "abandoned",
|
|
2214
|
+
// A full activity list keeps the owner's entries and takes no note; the
|
|
2215
|
+
// checkpoint's event id and the abandonments table still name the job.
|
|
2216
|
+
activity: existingActivity.length >= goalActivityLimit ? existingActivity : [
|
|
2217
|
+
...existingActivity,
|
|
2218
|
+
{
|
|
2219
|
+
id: jobId,
|
|
2220
|
+
kind: "status",
|
|
2221
|
+
summary: workAbandonActivitySummary(decoded, jobId, decoded.approvalHash),
|
|
2222
|
+
occurredAt: at
|
|
2223
|
+
}
|
|
2224
|
+
],
|
|
2225
|
+
updatedAt: at
|
|
2226
|
+
});
|
|
2227
|
+
const checkpoint = Schema.decodeUnknownSync(WorkGoalCheckpoint)({
|
|
2228
|
+
version: "herdr.work.event.v1",
|
|
2229
|
+
eventId: jobId,
|
|
2230
|
+
occurredAt: at,
|
|
2231
|
+
goal
|
|
2232
|
+
});
|
|
2233
|
+
const result = Schema.decodeUnknownSync(WorkGoalAbandoned)({ abandonment: decoded, checkpoint });
|
|
2234
|
+
const familyError = validateGoalFamilyHistory([...history, checkpoint]);
|
|
2235
|
+
if (familyError !== undefined)
|
|
2236
|
+
return reject(familyError);
|
|
2237
|
+
const capacity = agentBindingAdmissionError({
|
|
2238
|
+
history,
|
|
2239
|
+
candidate: checkpoint,
|
|
2240
|
+
operationCount: 0,
|
|
2241
|
+
operationBytes: 0,
|
|
2242
|
+
candidateOperationBytes: 0
|
|
2243
|
+
});
|
|
2244
|
+
if (capacity !== undefined)
|
|
2245
|
+
return reject(capacity);
|
|
2246
|
+
this.#database
|
|
2247
|
+
.prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)")
|
|
2248
|
+
.run(checkpoint.eventId, checkpoint.goal.id, checkpoint.occurredAt, JSON.stringify(checkpoint));
|
|
2249
|
+
this.#database
|
|
2250
|
+
.prepare("INSERT INTO work_goal_abandonments (approval_job_id, goal_id, record) VALUES (?, ?, ?)")
|
|
2251
|
+
.run(jobId, decoded.goalId, JSON.stringify(result));
|
|
2252
|
+
this.#database.exec("COMMIT");
|
|
2253
|
+
transaction = false;
|
|
2254
|
+
return { _tag: "abandoned", result };
|
|
2255
|
+
}
|
|
2256
|
+
catch (cause) {
|
|
2257
|
+
if (transaction)
|
|
2258
|
+
this.#database.exec("ROLLBACK");
|
|
2259
|
+
throw cause;
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
catch: storeError("abandon.transaction")
|
|
2263
|
+
});
|
|
2264
|
+
if (decision._tag === "rejected")
|
|
2265
|
+
return yield* decision.error;
|
|
2266
|
+
return decision.result;
|
|
2267
|
+
});
|
|
1879
2268
|
admissionPreflight = Effect.fn("WorkStore.admissionPreflight")(function* (target) {
|
|
1880
2269
|
const decoded = yield* Schema.decodeUnknownEffect(WorkAdmissionTarget)(target).pipe(Effect.mapError(storeError("admission.preflight.decode")));
|
|
1881
2270
|
const state = yield* Effect.try({
|
|
@@ -1888,8 +2277,27 @@ export class WorkStore {
|
|
|
1888
2277
|
const preflight = { _tag: "prospective", target: state.target, absenceToken };
|
|
1889
2278
|
return preflight;
|
|
1890
2279
|
});
|
|
2280
|
+
/**
|
|
2281
|
+
* Admits an existing owner after an approved Fleet job. Its absence token is
|
|
2282
|
+
* re-checked inside the write; an exact replay returns the stored link.
|
|
2283
|
+
*/
|
|
1891
2284
|
admitExistingOwner = Effect.fn("WorkStore.admitExistingOwner")(function* (request) {
|
|
1892
2285
|
const decoded = yield* Schema.decodeUnknownEffect(WorkProspectiveAdmission)(request).pipe(Effect.mapError(storeError("admission.decode")));
|
|
2286
|
+
return yield* this.#admit(decoded, {
|
|
2287
|
+
_tag: "approved",
|
|
2288
|
+
approvalActor: decoded.approvalActor,
|
|
2289
|
+
approvalJobId: decoded.approvalJobId
|
|
2290
|
+
});
|
|
2291
|
+
});
|
|
2292
|
+
/**
|
|
2293
|
+
* Admits a worker the reconciler observed, without an approval: the same
|
|
2294
|
+
* write and checks as `admitExistingOwner`, credited to the observation.
|
|
2295
|
+
*/
|
|
2296
|
+
admitObserved = Effect.fn("WorkStore.admitObserved")(function* (request) {
|
|
2297
|
+
const decoded = yield* Schema.decodeUnknownEffect(WorkObservedAdmission)(request).pipe(Effect.mapError(storeError("admission.decode")));
|
|
2298
|
+
return yield* this.#admit(decoded, { _tag: "observed", observationId: decoded.observationId });
|
|
2299
|
+
});
|
|
2300
|
+
#admit = Effect.fn("WorkStore.admit")(function* (decoded, provenance) {
|
|
1893
2301
|
const target = Schema.decodeUnknownSync(WorkAdmissionTarget)(decoded);
|
|
1894
2302
|
const now = yield* Clock.currentTimeMillis;
|
|
1895
2303
|
yield* this.secureFiles();
|
|
@@ -1913,13 +2321,10 @@ export class WorkStore {
|
|
|
1913
2321
|
const state = admissionState(this.#database, target);
|
|
1914
2322
|
if (state._tag === "existing") {
|
|
1915
2323
|
const existing = state.link;
|
|
1916
|
-
const provenance = existing.binding.request.prospectiveAdmission;
|
|
1917
2324
|
this.#database.exec("ROLLBACK");
|
|
1918
2325
|
transaction = false;
|
|
1919
2326
|
return existing.binding.request.dispatchRequestId === decoded.operationId &&
|
|
1920
|
-
|
|
1921
|
-
provenance?.approvalJobId === decoded.approvalJobId &&
|
|
1922
|
-
provenance.approvalActor === decoded.approvalActor &&
|
|
2327
|
+
sameAdmission(existing.binding.request, decoded.expectedAbsenceToken, provenance) &&
|
|
1923
2328
|
existing.goal.title === decoded.title &&
|
|
1924
2329
|
existing.goal.summary === decoded.summary &&
|
|
1925
2330
|
existing.goal.detail === decoded.detail
|
|
@@ -1962,7 +2367,7 @@ export class WorkStore {
|
|
|
1962
2367
|
activity: [{
|
|
1963
2368
|
id: `${decoded.operationId}.admission`,
|
|
1964
2369
|
kind: "status",
|
|
1965
|
-
summary:
|
|
2370
|
+
summary: admissionSummary(provenance),
|
|
1966
2371
|
occurredAt: createdAt
|
|
1967
2372
|
}],
|
|
1968
2373
|
review: { state: "requested", summary: null, updatedAt: createdAt, url: decoded.reviewUrl },
|
|
@@ -1996,14 +2401,12 @@ export class WorkStore {
|
|
|
1996
2401
|
laneId: decoded.laneId,
|
|
1997
2402
|
expectedRevision: 0,
|
|
1998
2403
|
worker: decoded.worker,
|
|
1999
|
-
|
|
2404
|
+
...admissionRecord(provenance, {
|
|
2000
2405
|
sessionId: decoded.sessionId,
|
|
2001
2406
|
workAssignment: decoded.expectedWork,
|
|
2002
2407
|
baseHead: decoded.baseHead,
|
|
2003
|
-
expectedAbsenceToken: decoded.expectedAbsenceToken
|
|
2004
|
-
|
|
2005
|
-
approvalActor: decoded.approvalActor
|
|
2006
|
-
}
|
|
2408
|
+
expectedAbsenceToken: decoded.expectedAbsenceToken
|
|
2409
|
+
})
|
|
2007
2410
|
},
|
|
2008
2411
|
lane,
|
|
2009
2412
|
checkpoint: {
|
|
@@ -2050,7 +2453,7 @@ export class WorkStore {
|
|
|
2050
2453
|
_tag: "rejected",
|
|
2051
2454
|
error: capacity ?? new WorkProjectionError({
|
|
2052
2455
|
cause: decoded,
|
|
2053
|
-
detail: "
|
|
2456
|
+
detail: "admission exceeds Work capacity",
|
|
2054
2457
|
reason: "capacity_exceeded"
|
|
2055
2458
|
})
|
|
2056
2459
|
};
|
|
@@ -2197,7 +2600,7 @@ export class WorkStore {
|
|
|
2197
2600
|
if (currentDecision._tag === "invalid")
|
|
2198
2601
|
return reject(currentDecision.error);
|
|
2199
2602
|
const current = currentDecision.checkpoint;
|
|
2200
|
-
if (current.goal.state === "completed") {
|
|
2603
|
+
if (current.goal.state === "completed" || current.goal.state === "abandoned") {
|
|
2201
2604
|
return reject(new WorkAgentBindingAuthorityError({
|
|
2202
2605
|
actualRevision: lane.revision,
|
|
2203
2606
|
expectedRevision: decoded.expectedRevision,
|
|
@@ -2334,8 +2737,26 @@ export class WorkStore {
|
|
|
2334
2737
|
const entries = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ dispatchRequestId: WorkAgentBindingRequest.fields.dispatchRequestId })))(rows).pipe(Effect.mapError(storeError("agent-binding.lane.rows")));
|
|
2335
2738
|
return entries.map(({ dispatchRequestId }) => dispatchRequestId);
|
|
2336
2739
|
});
|
|
2337
|
-
append =
|
|
2740
|
+
append = (event) => this.appendAt(event, null).pipe(
|
|
2741
|
+
// Only an expected head can raise it; a plain append never checks one.
|
|
2742
|
+
Effect.catchTag("WorkGoalRevisionConflictError", Effect.die));
|
|
2743
|
+
/**
|
|
2744
|
+
* Appends one checkpoint. With `expectedHead`, the goal's latest checkpoint
|
|
2745
|
+
* must still be that one when the write commits (an exact replay always
|
|
2746
|
+
* succeeds), so a writer that planned from the head cannot bury a newer
|
|
2747
|
+
* owner checkpoint.
|
|
2748
|
+
*/
|
|
2749
|
+
appendAt = Effect.fn("WorkStore.append")(function* (event, guard) {
|
|
2338
2750
|
const decoded = yield* Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("append.decode")));
|
|
2751
|
+
// The reconciler's event and activity ids are its provenance; no other
|
|
2752
|
+
// writer may use them.
|
|
2753
|
+
if (guard === null && decoded.eventId.startsWith(reconcilerEventPrefix)) {
|
|
2754
|
+
return yield* new WorkProjectionError({
|
|
2755
|
+
cause: decoded.eventId,
|
|
2756
|
+
detail: `event ids starting with ${reconcilerEventPrefix} are reserved for the reconciler`,
|
|
2757
|
+
reason: "malformed"
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2339
2760
|
yield* this.secureFiles();
|
|
2340
2761
|
const decision = yield* Effect.try({
|
|
2341
2762
|
try: () => {
|
|
@@ -2352,12 +2773,37 @@ export class WorkStore {
|
|
|
2352
2773
|
.prepare(`SELECT record FROM work_goal_events
|
|
2353
2774
|
WHERE event_id = ? OR (goal_id = ? AND occurred_at = ?)`)
|
|
2354
2775
|
.all(decoded.eventId, decoded.goal.id, decoded.occurredAt)).map(({ record }) => Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(record)));
|
|
2355
|
-
if (collisions.length > 0) {
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2776
|
+
if (collisions.length > 0 && collisions.every((existing) => Equal.equals(existing, decoded))) {
|
|
2777
|
+
this.#database.exec("ROLLBACK");
|
|
2778
|
+
transaction = false;
|
|
2779
|
+
return { _tag: "replayed", event: decoded };
|
|
2780
|
+
}
|
|
2781
|
+
if (guard !== null) {
|
|
2782
|
+
// Planned from this head and this fact: if either moved, the plan is stale.
|
|
2783
|
+
const headRow = this.#database.prepare(`SELECT record FROM work_goal_events WHERE goal_id = ?
|
|
2784
|
+
ORDER BY occurred_at DESC, event_id DESC LIMIT 1`).get(decoded.goal.id);
|
|
2785
|
+
const head = headRow === undefined ? null : Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(Schema.decodeUnknownSync(StoredEventRow)(headRow).record));
|
|
2786
|
+
const factRow = this.#database.prepare("SELECT observation_id AS observationId FROM work_observed_facts WHERE subject = ?").get(guard.fact.subject);
|
|
2787
|
+
const fact = factRow === undefined ? null : Schema.decodeUnknownSync(ObservationIdRow)(factRow);
|
|
2788
|
+
if (head?.eventId !== guard.head.eventId || fact?.observationId !== guard.fact.observationId) {
|
|
2789
|
+
return reject(new WorkGoalRevisionConflictError({
|
|
2790
|
+
actualEventId: head?.eventId ?? null,
|
|
2791
|
+
actualUpdatedAt: head?.goal.updatedAt ?? null,
|
|
2792
|
+
expectedEventId: guard.head.eventId,
|
|
2793
|
+
expectedUpdatedAt: guard.head.goal.updatedAt,
|
|
2794
|
+
goalId: decoded.goal.id
|
|
2795
|
+
}));
|
|
2796
|
+
}
|
|
2797
|
+
const count = Schema.decodeUnknownSync(CountRow)(this.#database.prepare("SELECT COUNT(*) AS count FROM work_goal_events").get()).count;
|
|
2798
|
+
if (count + 1 > workHistoryMaxEvents - guard.reserve) {
|
|
2799
|
+
return reject(new WorkProjectionError({
|
|
2800
|
+
cause: count,
|
|
2801
|
+
detail: `the reconciler leaves ${guard.reserve} of ${workHistoryMaxEvents} checkpoints free`,
|
|
2802
|
+
reason: "capacity_exceeded"
|
|
2803
|
+
}));
|
|
2360
2804
|
}
|
|
2805
|
+
}
|
|
2806
|
+
if (collisions.length > 0) {
|
|
2361
2807
|
return reject(new WorkCheckpointConflictError({
|
|
2362
2808
|
eventId: decoded.eventId,
|
|
2363
2809
|
goalId: decoded.goal.id,
|
|
@@ -2412,6 +2858,11 @@ export class WorkStore {
|
|
|
2412
2858
|
}
|
|
2413
2859
|
}
|
|
2414
2860
|
const result = this.#database.prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)").run(decoded.eventId, decoded.goal.id, decoded.occurredAt, JSON.stringify(decoded));
|
|
2861
|
+
// The reconciler's own record of what it wrote: its authorship and its
|
|
2862
|
+
// once-per-goal rule come from this table, never from an id's spelling.
|
|
2863
|
+
if (guard !== null) {
|
|
2864
|
+
this.#database.prepare("INSERT INTO work_reconciler_events (event_id, goal_id) VALUES (?, ?)").run(decoded.eventId, decoded.goal.id);
|
|
2865
|
+
}
|
|
2415
2866
|
this.#database.exec("COMMIT");
|
|
2416
2867
|
transaction = false;
|
|
2417
2868
|
return { _tag: "inserted", changes: result.changes };
|
|
@@ -2443,6 +2894,14 @@ export class WorkStore {
|
|
|
2443
2894
|
}
|
|
2444
2895
|
const transaction = yield* Schema.decodeUnknownEffect(TransactionId)(transactionId).pipe(Effect.mapError(storeError("appendMany.decode.transaction")));
|
|
2445
2896
|
const decoded = yield* Effect.forEach(events, (event) => Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("appendMany.decode.event"))));
|
|
2897
|
+
const reserved = decoded.find(({ eventId }) => eventId.startsWith(reconcilerEventPrefix));
|
|
2898
|
+
if (reserved !== undefined) {
|
|
2899
|
+
return yield* new WorkProjectionError({
|
|
2900
|
+
cause: reserved.eventId,
|
|
2901
|
+
detail: `event ids starting with ${reconcilerEventPrefix} are reserved for the reconciler`,
|
|
2902
|
+
reason: "malformed"
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2446
2905
|
if (decoded.length === 0) {
|
|
2447
2906
|
return yield* new WorkProjectionError({
|
|
2448
2907
|
cause: events,
|
|
@@ -2944,7 +3403,7 @@ export class WorkStore {
|
|
|
2944
3403
|
goal.goalFamily?.role !== "canonical" ||
|
|
2945
3404
|
goal.goalFamily.canonicalGoalId !== goal.id)
|
|
2946
3405
|
return rejectLink("missing_provenance");
|
|
2947
|
-
if (goal.state
|
|
3406
|
+
if (isTerminalWorkState(goal.state))
|
|
2948
3407
|
return rejectLink("terminal_goal");
|
|
2949
3408
|
if (!Equal.equals(goal.owner, reconciliation.expectedOwner))
|
|
2950
3409
|
return rejectLink("owner_mismatch");
|
|
@@ -3360,9 +3819,18 @@ export class WorkStore {
|
|
|
3360
3819
|
phase, revision, record
|
|
3361
3820
|
FROM work_lane_operations ORDER BY operation_id ASC
|
|
3362
3821
|
LIMIT ?`).all(workLaneOperationMaxRecords + 1);
|
|
3822
|
+
const facts = this.#database.prepare(`SELECT subject, observation_id AS observationId, observed_at AS observedAt,
|
|
3823
|
+
confirmed_at AS confirmedAt, record
|
|
3824
|
+
FROM work_observed_facts ORDER BY subject ASC
|
|
3825
|
+
LIMIT ?`).all(workObservedFactMaxRecords + 1);
|
|
3826
|
+
const failures = this.#database.prepare(`SELECT subject, source, reason, since, last_at AS lastAt
|
|
3827
|
+
FROM work_observed_failures ORDER BY subject ASC LIMIT ?`).all(workObservedFactMaxRecords + 1);
|
|
3828
|
+
const reconcilerRows = this.#database.prepare("SELECT event_id AS eventId, goal_id AS goalId FROM work_reconciler_events ORDER BY event_id ASC LIMIT ?").all(workHistoryMaxEvents + 1);
|
|
3829
|
+
const reassignments = this.#database.prepare(`SELECT approval_job_id AS approvalJobId, goal_id AS goalId FROM work_goal_reassignments
|
|
3830
|
+
UNION ALL SELECT approval_job_id AS approvalJobId, goal_id AS goalId FROM work_goal_abandonments`).all();
|
|
3363
3831
|
this.#database.exec("COMMIT");
|
|
3364
3832
|
inTransaction = false;
|
|
3365
|
-
return { bindings, events, laneOperations };
|
|
3833
|
+
return { bindings, events, facts, failures, laneOperations, reassignments, reconcilerRows };
|
|
3366
3834
|
}
|
|
3367
3835
|
catch (cause) {
|
|
3368
3836
|
if (inTransaction)
|
|
@@ -3387,6 +3855,23 @@ export class WorkStore {
|
|
|
3387
3855
|
const eventById = new Map(eventRows.map((row) => [row.eventId, row]));
|
|
3388
3856
|
const laneByOperation = new Map(laneRows.map((row) => [row.operationId, row]));
|
|
3389
3857
|
let logicalObservedAt = null;
|
|
3858
|
+
// An approved job's activity id is its operation (recovery), the operation
|
|
3859
|
+
// plus `.admission` (prospective admission), or the job id (reassignment).
|
|
3860
|
+
const reassignmentRows = yield* Schema.decodeUnknownEffect(Schema.Array(ReassignmentApprovalRow))(source.reassignments).pipe(Effect.mapError(storeError("snapshot-input.decode-reassignments")));
|
|
3861
|
+
// An approved reassignment or abandonment may be stamped past the clock
|
|
3862
|
+
// (one past a future-dated head); like a binding, it moves logical time so
|
|
3863
|
+
// a default snapshot shows what the approved job wrote.
|
|
3864
|
+
for (const { approvalJobId } of reassignmentRows) {
|
|
3865
|
+
const written = eventById.get(approvalJobId);
|
|
3866
|
+
if (written !== undefined)
|
|
3867
|
+
logicalObservedAt = Math.max(logicalObservedAt ?? 0, written.occurredAt);
|
|
3868
|
+
}
|
|
3869
|
+
const approvals = reassignmentRows.map(({ approvalJobId, goalId }) => ({
|
|
3870
|
+
activityId: approvalJobId,
|
|
3871
|
+
approvalJobId,
|
|
3872
|
+
goalId
|
|
3873
|
+
}));
|
|
3874
|
+
const observedAdmissions = [];
|
|
3390
3875
|
for (const row of bindingRows) {
|
|
3391
3876
|
const binding = yield* Effect.try({
|
|
3392
3877
|
try: () => Schema.decodeUnknownSync(WorkAgentBinding)(JSON.parse(row.record)),
|
|
@@ -3407,8 +3892,173 @@ export class WorkStore {
|
|
|
3407
3892
|
if (readbackError !== undefined)
|
|
3408
3893
|
return yield* readbackError;
|
|
3409
3894
|
logicalObservedAt = Math.max(logicalObservedAt ?? 0, binding.checkpoint.occurredAt);
|
|
3895
|
+
const goalId = binding.checkpoint.goal.id;
|
|
3896
|
+
const { dispatchRequestId, existingGoalRecovery, observedAdmission, ownerReassignment, prospectiveAdmission } = binding.request;
|
|
3897
|
+
if (observedAdmission !== undefined) {
|
|
3898
|
+
observedAdmissions.push({ eventId: `${dispatchRequestId}.admission`, goalId });
|
|
3899
|
+
}
|
|
3900
|
+
if (prospectiveAdmission !== undefined) {
|
|
3901
|
+
approvals.push({
|
|
3902
|
+
activityId: `${dispatchRequestId}.admission`,
|
|
3903
|
+
approvalJobId: prospectiveAdmission.approvalJobId,
|
|
3904
|
+
goalId
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
if (existingGoalRecovery !== undefined) {
|
|
3908
|
+
approvals.push({ activityId: dispatchRequestId, approvalJobId: existingGoalRecovery.approvalJobId, goalId });
|
|
3909
|
+
}
|
|
3910
|
+
if (ownerReassignment !== undefined) {
|
|
3911
|
+
approvals.push({
|
|
3912
|
+
activityId: ownerReassignment.approvalJobId,
|
|
3913
|
+
approvalJobId: ownerReassignment.approvalJobId,
|
|
3914
|
+
goalId
|
|
3915
|
+
});
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
const factRows = yield* Schema.decodeUnknownEffect(Schema.Array(ObservedFactRow))(source.facts).pipe(Effect.mapError(storeError("snapshot-input.decode-fact-rows")));
|
|
3919
|
+
const failures = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservedFailure))(source.failures).pipe(Effect.mapError(storeError("snapshot-input.decode-failures")));
|
|
3920
|
+
if (factRows.length > workObservedFactMaxRecords || failures.length > workObservedFactMaxRecords) {
|
|
3921
|
+
return yield* new WorkStoreError({ cause: factRows.length, operation: "snapshot-input.fact-capacity" });
|
|
3922
|
+
}
|
|
3923
|
+
const facts = yield* Effect.forEach(factRows, (row) => Effect.try({
|
|
3924
|
+
try: () => Schema.decodeUnknownSync(WorkObservedFact)({
|
|
3925
|
+
confirmedAt: row.confirmedAt,
|
|
3926
|
+
observation: JSON.parse(row.record),
|
|
3927
|
+
observationId: row.observationId,
|
|
3928
|
+
observedAt: row.observedAt,
|
|
3929
|
+
subject: row.subject
|
|
3930
|
+
}),
|
|
3931
|
+
catch: storeError("snapshot-input.decode-fact")
|
|
3932
|
+
}));
|
|
3933
|
+
// A row is filed under its own facts' subject; anything else is a corrupt row.
|
|
3934
|
+
const misfiled = facts.find((fact) => fact.subject !== observationSubject(fact.observation));
|
|
3935
|
+
if (misfiled !== undefined) {
|
|
3936
|
+
return yield* new WorkStoreError({ cause: misfiled.subject, operation: "snapshot-input.fact-subject" });
|
|
3937
|
+
}
|
|
3938
|
+
const reconcilerEvents = yield* Schema.decodeUnknownEffect(Schema.Array(ReconcilerEventRow))(source.reconcilerRows)
|
|
3939
|
+
.pipe(Effect.mapError(storeError("snapshot-input.decode-reconciler-events")));
|
|
3940
|
+
// Each current activity's origin: the checkpoint that started its current
|
|
3941
|
+
// unchanged run. An activity that disappears, or reads differently, starts
|
|
3942
|
+
// a new run, so a later owner rewrite or re-creation is never credited to
|
|
3943
|
+
// whoever wrote an earlier version.
|
|
3944
|
+
const activityOrigins = new Map();
|
|
3945
|
+
const previousByGoal = new Map();
|
|
3946
|
+
const ordered = events.toSorted((left, right) => left.occurredAt - right.occurredAt || (left.eventId < right.eventId ? -1 : left.eventId > right.eventId ? 1 : 0));
|
|
3947
|
+
for (const event of ordered) {
|
|
3948
|
+
const previous = previousByGoal.get(event.goal.id) ?? new Map();
|
|
3949
|
+
const current = new Map((event.goal.activity ?? []).map((activity) => [activity.id, activity]));
|
|
3950
|
+
for (const [id, activity] of current) {
|
|
3951
|
+
const before = previous.get(id);
|
|
3952
|
+
const unchanged = before !== undefined && before.kind === activity.kind &&
|
|
3953
|
+
before.summary === activity.summary &&
|
|
3954
|
+
before.occurredAt === activity.occurredAt;
|
|
3955
|
+
if (!unchanged)
|
|
3956
|
+
activityOrigins.set(`${event.goal.id}\u0000${id}`, { activity, eventId: event.eventId });
|
|
3957
|
+
}
|
|
3958
|
+
previousByGoal.set(event.goal.id, current);
|
|
3959
|
+
}
|
|
3960
|
+
return {
|
|
3961
|
+
activityOrigins,
|
|
3962
|
+
approvals,
|
|
3963
|
+
events,
|
|
3964
|
+
facts,
|
|
3965
|
+
failures,
|
|
3966
|
+
logicalObservedAt,
|
|
3967
|
+
observedAdmissions,
|
|
3968
|
+
reconcilerEvents
|
|
3969
|
+
};
|
|
3970
|
+
});
|
|
3971
|
+
reconcile = Effect.fn("WorkStore.reconcile")(function* () {
|
|
3972
|
+
const source = yield* this.snapshotInput();
|
|
3973
|
+
if (workHistoryMaxEvents - source.events.length < workReconcilerHeadroom) {
|
|
3974
|
+
return yield* new WorkProjectionError({
|
|
3975
|
+
cause: source.events.length,
|
|
3976
|
+
detail: `the reconciler leaves ${workReconcilerHeadroom} of ${workHistoryMaxEvents} checkpoints free`,
|
|
3977
|
+
reason: "capacity_exceeded"
|
|
3978
|
+
});
|
|
3410
3979
|
}
|
|
3411
|
-
|
|
3980
|
+
// A goal is stamped by the reconciler at most once, whatever its facts
|
|
3981
|
+
// look like later: a reopened goal stays the owner's.
|
|
3982
|
+
const stamped = new Map(source.reconcilerEvents.map(({ eventId, goalId }) => [goalId, eventId]));
|
|
3983
|
+
const cryptoService = this.#cryptoService;
|
|
3984
|
+
const now = yield* Clock.currentTimeMillis;
|
|
3985
|
+
const appendAt = (checkpoint, guard) => this.appendAt(checkpoint, guard);
|
|
3986
|
+
return yield* Effect.forEach(terminalCandidates(source.events, source.facts), Effect.fnUntraced(function* (candidate) {
|
|
3987
|
+
const goalId = candidate.head.goal.id;
|
|
3988
|
+
const previous = stamped.get(goalId);
|
|
3989
|
+
if (previous !== undefined)
|
|
3990
|
+
return { _tag: "recorded", eventId: previous, goalId };
|
|
3991
|
+
const digest = yield* cryptoService.digest("SHA-256", utf8.encode(`${candidate.fact.observationId}\u0000${goalId}`)).pipe(Effect.mapError(storeError("reconcile.digest")));
|
|
3992
|
+
const eventId = `${reconcilerEventPrefix}${Hex.encode(digest)}`;
|
|
3993
|
+
const checkpoint = terminalCheckpoint(candidate, eventId, now);
|
|
3994
|
+
// A goal that cannot take its terminal checkpoint (an activity already
|
|
3995
|
+
// holds the reconciler's id, a timestamp past the bound) is left for
|
|
3996
|
+
// its owner: reported as a conflict, never failing the other goals.
|
|
3997
|
+
if (Result.isFailure(checkpoint))
|
|
3998
|
+
return { _tag: "conflict", goalId };
|
|
3999
|
+
return yield* appendAt(checkpoint.success, {
|
|
4000
|
+
fact: { observationId: candidate.fact.observationId, subject: candidate.fact.subject },
|
|
4001
|
+
head: candidate.head,
|
|
4002
|
+
reserve: workReconcilerHeadroom
|
|
4003
|
+
}).pipe(Effect.as({
|
|
4004
|
+
_tag: "applied",
|
|
4005
|
+
eventId,
|
|
4006
|
+
goalId,
|
|
4007
|
+
state: candidate.pullRequest.state === "merged" ? "completed" : "abandoned"
|
|
4008
|
+
}), Effect.catchTag("WorkGoalRevisionConflictError", () => Effect.succeed({ _tag: "conflict", goalId })));
|
|
4009
|
+
}));
|
|
4010
|
+
});
|
|
4011
|
+
observe = Effect.fn("WorkStore.observe")(function* (envelopes) {
|
|
4012
|
+
const decoded = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservationEnvelope).check(Schema.isMaxLength(workObservedFactMaxRecords)))(envelopes).pipe(Effect.mapError(storeError("observe.decode")));
|
|
4013
|
+
const cryptoService = this.#cryptoService;
|
|
4014
|
+
// An observation from further ahead than clock skew allows would outrank
|
|
4015
|
+
// every real reading until wall time caught up, so it is skipped as stale.
|
|
4016
|
+
const clockNow = yield* Clock.currentTimeMillis;
|
|
4017
|
+
const latestAllowed = clockNow + workObservationMaxSkewMillis;
|
|
4018
|
+
const prepared = yield* Effect.forEach(decoded, Effect.fnUntraced(function* (envelope) {
|
|
4019
|
+
// Composite subjects are bounded on their own; check before anything is written.
|
|
4020
|
+
const subject = yield* Schema.decodeUnknownEffect(WorkObservationSubject)(observationSubject(envelope.observation)).pipe(Effect.mapError(storeError("observe.subject")));
|
|
4021
|
+
if (envelope.observedAt > latestAllowed)
|
|
4022
|
+
return { _tag: "future", subject };
|
|
4023
|
+
if (envelope.observation._tag === "unknown") {
|
|
4024
|
+
if (canonicalSubject(envelope.observation.source, envelope.observation.subject) === null) {
|
|
4025
|
+
return yield* new WorkStoreError({ cause: envelope.observation, operation: "observe.subject" });
|
|
4026
|
+
}
|
|
4027
|
+
return {
|
|
4028
|
+
_tag: "unknown",
|
|
4029
|
+
// Within the skew allowance, a reading from slightly ahead counts
|
|
4030
|
+
// as now, so it is visible to a snapshot taken now.
|
|
4031
|
+
observedAt: Math.min(envelope.observedAt, clockNow),
|
|
4032
|
+
reason: envelope.observation.reason,
|
|
4033
|
+
source: envelope.observation.source,
|
|
4034
|
+
subject
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
// Case-insensitive identities are lowercased so a spelling change is the
|
|
4038
|
+
// same fact; encoding emits keys in schema field order, so equal facts
|
|
4039
|
+
// give equal text and one id.
|
|
4040
|
+
const record = JSON.stringify(Schema.encodeSync(WorkObservationEnvelope)({
|
|
4041
|
+
...envelope,
|
|
4042
|
+
observation: canonicalObservation(envelope.observation)
|
|
4043
|
+
})
|
|
4044
|
+
.observation);
|
|
4045
|
+
const digest = yield* cryptoService.digest("SHA-256", utf8.encode(record)).pipe(Effect.mapError(storeError("observe.digest")));
|
|
4046
|
+
return {
|
|
4047
|
+
_tag: "fact",
|
|
4048
|
+
observationId: Hex.encode(digest),
|
|
4049
|
+
observedAt: Math.min(envelope.observedAt, clockNow),
|
|
4050
|
+
record,
|
|
4051
|
+
subject
|
|
4052
|
+
};
|
|
4053
|
+
}));
|
|
4054
|
+
yield* this.secureFiles();
|
|
4055
|
+
const report = yield* Effect.try({
|
|
4056
|
+
try: () => writeObservations(this.#database, prepared),
|
|
4057
|
+
catch: storeError("observe.write")
|
|
4058
|
+
});
|
|
4059
|
+
// The write may have created the WAL siblings; they get the same private mode.
|
|
4060
|
+
yield* this.secureFiles();
|
|
4061
|
+
return report;
|
|
3412
4062
|
});
|
|
3413
4063
|
secureFiles() {
|
|
3414
4064
|
return this.#secureFiles;
|