@zq-silk/yui 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +35 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +610 -201
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +24 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +85 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +60 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +413 -61
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +238 -22
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +549 -42
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/boundedRpc.js +475 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/setup/setupCommand.js +27 -4
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +769 -1
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +705 -0
- package/dist/storage/sqliteStore.js +1695 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +298 -0
- package/dist/storage/taskStore.js +982 -21
- package/dist/storage/upgrade/homeClassification.js +157 -12
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +343 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +2 -1
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { validateConfiguredAgent } from "../agent/agent.js";
|
|
6
|
-
import {
|
|
6
|
+
import { validateCapabilityGrant } from "../grant/capabilityGrant.js";
|
|
7
|
+
import { validateReleaseWorkflow } from "../release/releaseWorkflow.js";
|
|
8
|
+
import { reconciliationIntervalMilliseconds, resolveLeaderNextActionMode, resolveResourcesGcAutoQuarantine, resolveResourcesGcMode } from "../config/yuiConfig.js";
|
|
7
9
|
import { resolveTimeZone } from "../output/timePresentation.js";
|
|
8
10
|
import { mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
|
|
9
11
|
import { validateInputRequest } from "../input/inputRequest.js";
|
|
10
12
|
import { validateRoleSessionSet } from "../executor/agentExecutor.js";
|
|
11
13
|
import { validateTaskMessage } from "../message/message.js";
|
|
12
14
|
import { validateAgentRun } from "../run/agentRun.js";
|
|
15
|
+
import { FileSessionOwnerRegistry } from "../runtime/sessionOwnerRegistry.js";
|
|
13
16
|
import { validateReviewConfig } from "../review/reviewConfig.js";
|
|
14
17
|
import { validateReviewRound } from "../review/reviewRound.js";
|
|
15
18
|
import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
|
|
@@ -18,6 +21,8 @@ import { generateHomeIdentity, validateHomeIdentity } from "../repository/homeId
|
|
|
18
21
|
import { validateAgentProfile } from "../profile/agentProfile.js";
|
|
19
22
|
import { validateChangeSet } from "../integration/changeSet.js";
|
|
20
23
|
import { validateIntegrationAttempt } from "../integration/integrationAttempt.js";
|
|
24
|
+
import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
|
|
25
|
+
import { CURRENT_DURABLE_JOB_SCHEMA_VERSION, validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
|
|
21
26
|
import { validateGlobalRole, validateTaskRole } from "../role/role.js";
|
|
22
27
|
import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../scheduler/leaderFailure.js";
|
|
23
28
|
import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../scheduler/operatorNotification.js";
|
|
@@ -26,6 +31,7 @@ import { formatAgentRunReceiptId, TASK_RECORD_ID_PREFIXES, validateTaskRecordRef
|
|
|
26
31
|
import { workItemExecutionGroupById, validateWorkItem } from "../workItem/workItem.js";
|
|
27
32
|
import { isExecutionGroupTransition, validateExecutionGroup } from "../execution/executionGroup.js";
|
|
28
33
|
import { managedWorkspaceKey, validateManagedWorkspace } from "../worktree/managedWorkspace.js";
|
|
34
|
+
import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
|
|
29
35
|
import { writeTextFileAtomically } from "./durableFile.js";
|
|
30
36
|
import { assertHomeWritable } from "./upgradeFence.js";
|
|
31
37
|
import { CURRENT_AGGREGATE_SCHEMA_VERSION, requireCompatibleStorageSchema, requireStorageSchema, writeCurrentStorageManifest } from "./storageSchema.js";
|
|
@@ -54,13 +60,15 @@ export const CURRENT_TASK_ROLE_SCHEMA_VERSION = 3;
|
|
|
54
60
|
export const CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION = 2;
|
|
55
61
|
export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 9;
|
|
56
62
|
export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 4;
|
|
57
|
-
export const CURRENT_CHANGE_SET_SCHEMA_VERSION =
|
|
58
|
-
export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION =
|
|
59
|
-
export const CURRENT_MESSAGE_SCHEMA_VERSION =
|
|
63
|
+
export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 3;
|
|
64
|
+
export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 4;
|
|
65
|
+
export const CURRENT_MESSAGE_SCHEMA_VERSION = 3;
|
|
60
66
|
export const CURRENT_INPUT_REQUEST_SCHEMA_VERSION = 2;
|
|
61
67
|
export const CURRENT_DECISION_SCHEMA_VERSION = 1;
|
|
62
68
|
export const CURRENT_MILESTONE_SCHEMA_VERSION = 1;
|
|
63
69
|
export const CURRENT_EVENT_SCHEMA_VERSION = 2;
|
|
70
|
+
export const CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION = 1;
|
|
71
|
+
export const CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION = 1;
|
|
64
72
|
export const CURRENT_WORK_MAILBOX_SCHEMA_VERSION = 1;
|
|
65
73
|
export const CURRENT_ROLE_AGENT_SESSION_SCHEMA_VERSION = 3;
|
|
66
74
|
export const CURRENT_PENDING_WAKEUP_SCHEMA_VERSION = 1;
|
|
@@ -80,7 +88,7 @@ export function executionLaneActiveRunKey(executionGroupId, executionLaneId) {
|
|
|
80
88
|
function encodeLaneKeyPart(value) {
|
|
81
89
|
return encodeURIComponent(value).replace(/:/gu, "%3A");
|
|
82
90
|
}
|
|
83
|
-
function executionLaneActiveRunKeyParts(key) {
|
|
91
|
+
export function executionLaneActiveRunKeyParts(key) {
|
|
84
92
|
const match = /^\/execution-lane\/([^:]+):([^:]+)$/u.exec(key);
|
|
85
93
|
if (match === null)
|
|
86
94
|
return null;
|
|
@@ -95,23 +103,40 @@ function executionLaneActiveRunKeyParts(key) {
|
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
105
|
/** The schema version of each persisted `state.json#/tasks/*` aggregate. */
|
|
98
|
-
export const CURRENT_STORED_TASK_SCHEMA_VERSION =
|
|
106
|
+
export const CURRENT_STORED_TASK_SCHEMA_VERSION = 16;
|
|
99
107
|
/**
|
|
100
108
|
* Persisted nested-record versions consumed by this store's strict parser.
|
|
101
109
|
* Keep these named at the storage boundary so the upgrade record-axis map can
|
|
102
110
|
* assert it is classifying the same bytes the store reads and writes.
|
|
103
111
|
*/
|
|
104
112
|
export const CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION = 4;
|
|
105
|
-
|
|
113
|
+
/**
|
|
114
|
+
* v7 combines optional Issue 04 retry/receipt fields and Issue 05 Leader
|
|
115
|
+
* actionability fields. All are optional, so the v6→v7 migration is a
|
|
116
|
+
* version-only rewrite.
|
|
117
|
+
*/
|
|
118
|
+
export const CURRENT_AGENT_RUN_SCHEMA_VERSION = 7;
|
|
119
|
+
export const CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION = 1;
|
|
106
120
|
export class FileTaskStore {
|
|
107
121
|
rootDir;
|
|
108
122
|
#transaction = null;
|
|
109
123
|
#readCache = null;
|
|
110
124
|
#normalizeState;
|
|
125
|
+
#sessionOwnerRegistry;
|
|
111
126
|
constructor(rootDir, options = {}) {
|
|
112
127
|
this.rootDir = rootDir;
|
|
113
128
|
this.#normalizeState = options.normalizeState;
|
|
114
129
|
this.#requireReadableSchema();
|
|
130
|
+
const snapshot = options.initialStateSnapshot;
|
|
131
|
+
if (snapshot !== undefined) {
|
|
132
|
+
// The one-snapshot open already fenced these bytes; run the same strict
|
|
133
|
+
// parser the lazy path would, then warm the cache under the snapshot's
|
|
134
|
+
// fingerprint. A later external writer still invalidates it on next read.
|
|
135
|
+
this.#readCache = {
|
|
136
|
+
fingerprint: snapshot.fingerprint,
|
|
137
|
+
state: this.#parseState(snapshot.raw)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
115
140
|
}
|
|
116
141
|
rootDirectory() { return this.rootDir; }
|
|
117
142
|
transaction(execute) {
|
|
@@ -135,6 +160,34 @@ export class FileTaskStore {
|
|
|
135
160
|
}
|
|
136
161
|
});
|
|
137
162
|
}
|
|
163
|
+
withRuntimeEventTransaction(execute) {
|
|
164
|
+
return this.transaction(() => execute());
|
|
165
|
+
}
|
|
166
|
+
async transactionAsync(execute) {
|
|
167
|
+
if (this.#transaction !== null)
|
|
168
|
+
return execute(this);
|
|
169
|
+
const release = acquireStorageLock(this.rootDir);
|
|
170
|
+
try {
|
|
171
|
+
const state = this.#readCachedState();
|
|
172
|
+
this.#transaction = { state, baseRevision: state.revision, dirty: false };
|
|
173
|
+
try {
|
|
174
|
+
const result = await execute(this);
|
|
175
|
+
if (this.#transaction.dirty)
|
|
176
|
+
this.#commit(state, this.#transaction.baseRevision);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.#readCache = null;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
this.#transaction = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
release();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
138
191
|
getConfig() { return clone(this.#state().config); }
|
|
139
192
|
getHomeIdentity() { return clone(this.#state().homeIdentity); }
|
|
140
193
|
saveConfig(config) {
|
|
@@ -325,8 +378,38 @@ export class FileTaskStore {
|
|
|
325
378
|
state.tasks[stored.id] = aggregate;
|
|
326
379
|
});
|
|
327
380
|
}
|
|
328
|
-
listTasks() {
|
|
381
|
+
listTasks() {
|
|
382
|
+
// Clone only the Task headers, not the whole stored aggregate (events,
|
|
383
|
+
// runs, messages): a scheduler pass lists Tasks per phase, and cloning
|
|
384
|
+
// each aggregate's full event history turned every phase into a 32 MiB
|
|
385
|
+
// projection. Callers that need a Task's events read them explicitly.
|
|
386
|
+
return Object.values(this.#state().tasks)
|
|
387
|
+
.map((aggregate) => clone(aggregate.task))
|
|
388
|
+
.sort((left, right) => numericCompare(left.id, right.id));
|
|
389
|
+
}
|
|
390
|
+
getStateRevision() { return this.#state().revision; }
|
|
329
391
|
getTask(id) { return optional(this.#state().tasks[id]?.task); }
|
|
392
|
+
readNextActionFacts(taskId) {
|
|
393
|
+
const aggregate = this.#state().tasks[taskId];
|
|
394
|
+
if (aggregate === undefined)
|
|
395
|
+
return null;
|
|
396
|
+
const agentRuns = values(aggregate.agentRuns, "id");
|
|
397
|
+
return {
|
|
398
|
+
task: {
|
|
399
|
+
id: aggregate.task.id,
|
|
400
|
+
status: aggregate.task.status,
|
|
401
|
+
projectBindings: aggregate.task.projectBindings
|
|
402
|
+
},
|
|
403
|
+
workItems: values(aggregate.workItems, "id"),
|
|
404
|
+
changeSets: values(aggregate.changeSets, "id"),
|
|
405
|
+
integrations: values(aggregate.integrationAttempts, "id"),
|
|
406
|
+
reviewRounds: values(aggregate.reviewRounds, "id"),
|
|
407
|
+
openInputRequests: values(aggregate.inputRequests, "id")
|
|
408
|
+
.filter((request) => request.status === "open"),
|
|
409
|
+
activeRuns: agentRuns.filter((run) => run.status === "active"),
|
|
410
|
+
leaderRuns: agentRuns.filter((run) => run.roleName === "leader")
|
|
411
|
+
};
|
|
412
|
+
}
|
|
330
413
|
getReviewConfig() {
|
|
331
414
|
return optional(this.#state().config.review);
|
|
332
415
|
}
|
|
@@ -352,7 +435,7 @@ export class FileTaskStore {
|
|
|
352
435
|
throw new StorageRecordError(`ChangeSet belongs to another Task: ${stored.taskId}.`);
|
|
353
436
|
}
|
|
354
437
|
const aggregate = this.#requireTaskForWrite(taskId);
|
|
355
|
-
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === stored.headCommit);
|
|
438
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === stored.headCommit);
|
|
356
439
|
if (evidenceRound !== undefined) {
|
|
357
440
|
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${stored.headCommit} cannot become a ChangeSet.`);
|
|
358
441
|
}
|
|
@@ -399,7 +482,7 @@ export class FileTaskStore {
|
|
|
399
482
|
if (changeSet.projectId !== stored.projectId) {
|
|
400
483
|
throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${changeSetId}.`);
|
|
401
484
|
}
|
|
402
|
-
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
|
|
485
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === changeSet.headCommit);
|
|
403
486
|
if (evidenceRound !== undefined) {
|
|
404
487
|
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
|
|
405
488
|
}
|
|
@@ -425,6 +508,105 @@ export class FileTaskStore {
|
|
|
425
508
|
getIntegrationAttempt(taskId, integrationId) {
|
|
426
509
|
return optional(this.#state().tasks[taskId]?.integrationAttempts[integrationId]);
|
|
427
510
|
}
|
|
511
|
+
nextIntegrationQueueEntryId(taskId) {
|
|
512
|
+
return this.#nextTaskRecordId(taskId, "integrationQueue");
|
|
513
|
+
}
|
|
514
|
+
saveIntegrationQueueEntry(taskId, entry) {
|
|
515
|
+
const stored = identified(entry, CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, "id", entry.id, "Integration queue entry");
|
|
516
|
+
validateIntegrationQueueEntry(stored);
|
|
517
|
+
if (stored.taskId !== taskId) {
|
|
518
|
+
throw new StorageRecordError(`Integration queue entry belongs to another Task: ${stored.taskId}.`);
|
|
519
|
+
}
|
|
520
|
+
const aggregate = this.#requireTaskForWrite(taskId);
|
|
521
|
+
if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
|
|
522
|
+
throw new StorageRecordError(`Integration queue Project does not match Task: ${stored.id}.`);
|
|
523
|
+
}
|
|
524
|
+
const changeSet = aggregate.changeSets[stored.changeSetId];
|
|
525
|
+
if (changeSet === undefined) {
|
|
526
|
+
throw new StorageRecordError(`Integration queue ChangeSet not found: ${stored.changeSetId}.`);
|
|
527
|
+
}
|
|
528
|
+
if (changeSet.projectId !== stored.projectId) {
|
|
529
|
+
throw new StorageRecordError(`Integration queue ChangeSet belongs to another Project: ${stored.changeSetId}.`);
|
|
530
|
+
}
|
|
531
|
+
const existing = aggregate.integrationQueue[stored.id];
|
|
532
|
+
if (existing !== undefined) {
|
|
533
|
+
if (Date.parse(stored.updatedAt) < Date.parse(existing.updatedAt)) {
|
|
534
|
+
throw new StorageRecordError(`Integration queue entry updatedAt cannot move backwards: ${stored.id}.`);
|
|
535
|
+
}
|
|
536
|
+
if (!validIntegrationQueueTransition(existing, stored)) {
|
|
537
|
+
throw new StorageRecordError(`Integration queue entry transition is invalid: ${stored.id}.`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
this.#mutate((state) => {
|
|
541
|
+
const task = state.tasks[taskId];
|
|
542
|
+
observeTaskRecordId(task, "integrationQueue", stored.id);
|
|
543
|
+
task.integrationQueue[stored.id] = stored;
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
listIntegrationQueueEntries(taskId) {
|
|
547
|
+
return values(this.#requireTask(taskId).integrationQueue, "id");
|
|
548
|
+
}
|
|
549
|
+
getIntegrationQueueEntry(taskId, entryId) {
|
|
550
|
+
return optional(this.#state().tasks[taskId]?.integrationQueue[entryId]);
|
|
551
|
+
}
|
|
552
|
+
nextDurableJobId(taskId) {
|
|
553
|
+
return this.#nextTaskRecordId(taskId, "durableJob");
|
|
554
|
+
}
|
|
555
|
+
saveDurableJob(taskId, job) {
|
|
556
|
+
const stored = identified(job, CURRENT_DURABLE_JOB_SCHEMA_VERSION, "id", job.id, "DurableJob");
|
|
557
|
+
validateDurableJob(stored);
|
|
558
|
+
if (stored.taskId !== taskId) {
|
|
559
|
+
throw new StorageRecordError(`DurableJob belongs to another Task: ${stored.taskId}.`);
|
|
560
|
+
}
|
|
561
|
+
const aggregate = this.#requireTaskForWrite(taskId);
|
|
562
|
+
if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
|
|
563
|
+
throw new StorageRecordError(`DurableJob Project does not match Task: ${stored.id}.`);
|
|
564
|
+
}
|
|
565
|
+
const existing = aggregate.durableJobs[stored.id];
|
|
566
|
+
if (existing !== undefined) {
|
|
567
|
+
if (Date.parse(stored.updatedAt) < Date.parse(existing.updatedAt)) {
|
|
568
|
+
throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${stored.id}.`);
|
|
569
|
+
}
|
|
570
|
+
if (!validDurableJobTransition(existing, stored)) {
|
|
571
|
+
throw new StorageRecordError(`DurableJob transition is invalid: ${stored.id}.`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
this.#mutate((state) => {
|
|
575
|
+
const task = state.tasks[taskId];
|
|
576
|
+
observeTaskRecordId(task, "durableJob", stored.id);
|
|
577
|
+
task.durableJobs[stored.id] = stored;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
listDurableJobs(taskId) {
|
|
581
|
+
return values(this.#requireTask(taskId).durableJobs, "id");
|
|
582
|
+
}
|
|
583
|
+
getDurableJob(taskId, jobId) {
|
|
584
|
+
return optional(this.#state().tasks[taskId]?.durableJobs[jobId]);
|
|
585
|
+
}
|
|
586
|
+
findDurableJobByIdempotencyKey(taskId, key) {
|
|
587
|
+
const aggregate = this.#state().tasks[taskId];
|
|
588
|
+
if (aggregate === undefined)
|
|
589
|
+
return null;
|
|
590
|
+
const found = Object.values(aggregate.durableJobs)
|
|
591
|
+
.find((job) => job.idempotencyKey === key);
|
|
592
|
+
return optional(found);
|
|
593
|
+
}
|
|
594
|
+
listAllDurableJobs() {
|
|
595
|
+
const all = [];
|
|
596
|
+
for (const aggregate of Object.values(this.#state().tasks)) {
|
|
597
|
+
all.push(...Object.values(aggregate.durableJobs));
|
|
598
|
+
}
|
|
599
|
+
return all.map((job) => clone(job));
|
|
600
|
+
}
|
|
601
|
+
hasActiveDurableJobs() {
|
|
602
|
+
for (const aggregate of Object.values(this.#state().tasks)) {
|
|
603
|
+
for (const job of Object.values(aggregate.durableJobs)) {
|
|
604
|
+
if (job.status === "queued" || job.status === "running")
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
428
610
|
saveRole(taskId, role) {
|
|
429
611
|
const aggregate = this.#requireTaskForWrite(taskId);
|
|
430
612
|
const stored = identified(role, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", role.name, "Task Role");
|
|
@@ -564,6 +746,42 @@ export class FileTaskStore {
|
|
|
564
746
|
const set = this.getRoleSessionSet(taskId, roleName);
|
|
565
747
|
return set === null ? null : optional(set.sessions[set.activeAgentId]);
|
|
566
748
|
}
|
|
749
|
+
getJobCallerKeyHash(taskId, roleName, agentId) {
|
|
750
|
+
const hashes = this.#state().tasks[taskId]?.jobCallerKeyHashes;
|
|
751
|
+
if (hashes === undefined)
|
|
752
|
+
return null;
|
|
753
|
+
return optional(hashes[jobCallerKeyHashKey(roleName, agentId)]);
|
|
754
|
+
}
|
|
755
|
+
setJobCallerKeyHash(taskId, roleName, agentId, hash) {
|
|
756
|
+
this.#requireTaskForWrite(taskId);
|
|
757
|
+
if (!/^[a-f0-9]{64}$/u.test(hash)) {
|
|
758
|
+
throw new StorageRecordError(`Job caller key hash is invalid: ${taskId}/${roleName}.`);
|
|
759
|
+
}
|
|
760
|
+
this.#mutate((state) => {
|
|
761
|
+
state.tasks[taskId].jobCallerKeyHashes[jobCallerKeyHashKey(roleName, agentId)] = hash;
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
saveSessionOwner(identity) {
|
|
765
|
+
this.#sessionOwners().record(identity);
|
|
766
|
+
}
|
|
767
|
+
getSessionOwner(launchId) {
|
|
768
|
+
return this.#sessionOwners().get(launchId);
|
|
769
|
+
}
|
|
770
|
+
listSessionOwners() {
|
|
771
|
+
return this.#sessionOwners().list();
|
|
772
|
+
}
|
|
773
|
+
listSessionOwnersForOwner(owner) {
|
|
774
|
+
return this.#sessionOwners().listForOwner(owner);
|
|
775
|
+
}
|
|
776
|
+
removeSessionOwner(launchId) {
|
|
777
|
+
this.#sessionOwners().remove(launchId);
|
|
778
|
+
}
|
|
779
|
+
#sessionOwners() {
|
|
780
|
+
if (this.#sessionOwnerRegistry === undefined) {
|
|
781
|
+
this.#sessionOwnerRegistry = new FileSessionOwnerRegistry(this.rootDir);
|
|
782
|
+
}
|
|
783
|
+
return this.#sessionOwnerRegistry;
|
|
784
|
+
}
|
|
567
785
|
nextWorkItemId(taskId) {
|
|
568
786
|
return this.#nextTaskRecordId(taskId, "workItem");
|
|
569
787
|
}
|
|
@@ -617,6 +835,21 @@ export class FileTaskStore {
|
|
|
617
835
|
}
|
|
618
836
|
getAgentRun(taskId, id) { return optional(this.#state().tasks[taskId]?.agentRuns[id]); }
|
|
619
837
|
listAgentRuns(taskId) { return values(this.#requireTask(taskId).agentRuns, "id"); }
|
|
838
|
+
listPendingProviderRetries() {
|
|
839
|
+
// The legacy File store can answer the empty case without a scan fallback.
|
|
840
|
+
// If durable retry state exists, the db-only capability must fail closed
|
|
841
|
+
// instead of silently losing the Controller's wake deadline.
|
|
842
|
+
for (const task of this.listTasks()) {
|
|
843
|
+
if (task.status !== "active")
|
|
844
|
+
continue;
|
|
845
|
+
for (const run of this.listAgentRuns(task.id)) {
|
|
846
|
+
if (run.status === "active" && run.providerRetry?.nextAttemptAt !== undefined) {
|
|
847
|
+
throw new StorageRecordError("Provider retry in place requires the SQLite backend; run `yui update` to migrate this Home.");
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return [];
|
|
852
|
+
}
|
|
620
853
|
saveAgentRun(run) {
|
|
621
854
|
const stored = identified(run, CURRENT_AGENT_RUN_SCHEMA_VERSION, "id", run.id, "Agent run");
|
|
622
855
|
validateAgentRun(stored);
|
|
@@ -690,6 +923,18 @@ export class FileTaskStore {
|
|
|
690
923
|
task.reviewRounds[stored.id] = stored;
|
|
691
924
|
});
|
|
692
925
|
}
|
|
926
|
+
nextReviewFindingId(taskId) {
|
|
927
|
+
throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
|
|
928
|
+
}
|
|
929
|
+
getReviewFinding(taskId, reviewFindingId) {
|
|
930
|
+
throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
|
|
931
|
+
}
|
|
932
|
+
listReviewFindings(taskId) {
|
|
933
|
+
throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
|
|
934
|
+
}
|
|
935
|
+
saveReviewFinding(taskId, finding) {
|
|
936
|
+
throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
|
|
937
|
+
}
|
|
693
938
|
getActiveAgentRun(taskId, roleName) {
|
|
694
939
|
const aggregate = this.#state().tasks[taskId];
|
|
695
940
|
const pointer = aggregate?.activeRuns[roleName];
|
|
@@ -933,6 +1178,209 @@ export class FileTaskStore {
|
|
|
933
1178
|
});
|
|
934
1179
|
}
|
|
935
1180
|
listEvents(taskId) { return values(this.#requireTask(taskId).events, "id"); }
|
|
1181
|
+
removeEvents(taskId, eventIds) {
|
|
1182
|
+
if (eventIds.length === 0)
|
|
1183
|
+
return 0;
|
|
1184
|
+
let removed = 0;
|
|
1185
|
+
this.#mutate((state) => {
|
|
1186
|
+
const aggregate = requireTaskFromState(state, taskId);
|
|
1187
|
+
for (const id of eventIds) {
|
|
1188
|
+
if (aggregate.events[id] !== undefined) {
|
|
1189
|
+
delete aggregate.events[id];
|
|
1190
|
+
removed++;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
return removed;
|
|
1195
|
+
}
|
|
1196
|
+
nextCapabilityGrantId(taskId) {
|
|
1197
|
+
return this.#nextTaskRecordId(taskId, "capabilityGrant");
|
|
1198
|
+
}
|
|
1199
|
+
saveCapabilityGrant(taskId, grant) {
|
|
1200
|
+
const stored = storedCapabilityGrant(grant);
|
|
1201
|
+
if (stored.taskId !== taskId) {
|
|
1202
|
+
throw new StorageRecordError(`Capability grant belongs to another Task: ${stored.taskId}`);
|
|
1203
|
+
}
|
|
1204
|
+
this.#mutate((state) => {
|
|
1205
|
+
const aggregate = requireTaskFromState(state, taskId);
|
|
1206
|
+
const existing = aggregate.capabilityGrants[stored.id];
|
|
1207
|
+
if (existing === undefined) {
|
|
1208
|
+
if (stored.revokedAt !== undefined) {
|
|
1209
|
+
throw new StorageRecordError(`Capability grant must start unrevoked: ${stored.id}`);
|
|
1210
|
+
}
|
|
1211
|
+
if (stored.usesUsed !== 0) {
|
|
1212
|
+
throw new StorageRecordError(`Capability grant must start unused: ${stored.id}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
else if (!isValidCapabilityGrantTransition(existing, stored)) {
|
|
1216
|
+
throw new StorageRecordError(`Capability grant cannot be overwritten: ${taskId}/${stored.id}`);
|
|
1217
|
+
}
|
|
1218
|
+
observeTaskRecordId(aggregate, "capabilityGrant", stored.id);
|
|
1219
|
+
aggregate.capabilityGrants[stored.id] = stored;
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
listCapabilityGrants(taskId) {
|
|
1223
|
+
return values(this.#requireTask(taskId).capabilityGrants, "id");
|
|
1224
|
+
}
|
|
1225
|
+
getCapabilityGrant(taskId, grantId) {
|
|
1226
|
+
return this.#requireTask(taskId).capabilityGrants[grantId] ?? null;
|
|
1227
|
+
}
|
|
1228
|
+
nextReleaseWorkflowId(taskId) {
|
|
1229
|
+
return this.#nextTaskRecordId(taskId, "releaseWorkflow");
|
|
1230
|
+
}
|
|
1231
|
+
saveReleaseWorkflow(taskId, workflow) {
|
|
1232
|
+
const stored = storedReleaseWorkflow(workflow);
|
|
1233
|
+
if (stored.taskId !== taskId) {
|
|
1234
|
+
throw new StorageRecordError(`Release workflow belongs to another Task: ${stored.taskId}`);
|
|
1235
|
+
}
|
|
1236
|
+
this.#mutate((state) => {
|
|
1237
|
+
const aggregate = requireTaskFromState(state, taskId);
|
|
1238
|
+
const existing = aggregate.releaseWorkflows[stored.id];
|
|
1239
|
+
if (existing !== undefined && !isValidReleaseWorkflowTransition(existing, stored)) {
|
|
1240
|
+
throw new StorageRecordError(`Release workflow cannot be overwritten: ${taskId}/${stored.id}`);
|
|
1241
|
+
}
|
|
1242
|
+
observeTaskRecordId(aggregate, "releaseWorkflow", stored.id);
|
|
1243
|
+
aggregate.releaseWorkflows[stored.id] = stored;
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
listReleaseWorkflows(taskId) {
|
|
1247
|
+
return values(this.#requireTask(taskId).releaseWorkflows, "id");
|
|
1248
|
+
}
|
|
1249
|
+
getReleaseWorkflow(taskId, workflowId) {
|
|
1250
|
+
return this.#requireTask(taskId).releaseWorkflows[workflowId] ?? null;
|
|
1251
|
+
}
|
|
1252
|
+
// -- Gate artifacts (Issue 08) ---------------------------------------------
|
|
1253
|
+
// FileTaskStore delegates to the file-backed artifact namespace under
|
|
1254
|
+
// `<home>/artifacts/gates/`. This is the transitional path for Homes that
|
|
1255
|
+
// have not yet migrated to SQLite; the SqliteTaskStore stores the same
|
|
1256
|
+
// records and logs in `gate_artifacts` / `gate_artifact_logs`.
|
|
1257
|
+
#gateArtifactRecordPath(projectId, key) {
|
|
1258
|
+
return join(this.rootDir, "artifacts", "gates", projectId, `${key}.json`);
|
|
1259
|
+
}
|
|
1260
|
+
#gateArtifactLogsRoot(projectId, key) {
|
|
1261
|
+
return join(this.rootDir, "artifacts", "gates", projectId, key);
|
|
1262
|
+
}
|
|
1263
|
+
saveGateArtifact(artifact, logs) {
|
|
1264
|
+
validateGateArtifact(artifact);
|
|
1265
|
+
const recordPath = this.#gateArtifactRecordPath(artifact.projectId, artifact.key);
|
|
1266
|
+
const logsRoot = this.#gateArtifactLogsRoot(artifact.projectId, artifact.key);
|
|
1267
|
+
this.#mutate(() => {
|
|
1268
|
+
writeTextFileAtomically(recordPath, `${JSON.stringify(artifact, null, 2)}\n`);
|
|
1269
|
+
mkdirSync(logsRoot, { recursive: true, mode: 0o700 });
|
|
1270
|
+
for (const [stepName, content] of logs) {
|
|
1271
|
+
const logPath = join(logsRoot, stepName);
|
|
1272
|
+
const tmpPath = `${logPath}.tmp-${process.pid}`;
|
|
1273
|
+
writeFileSync(tmpPath, content, { mode: 0o600 });
|
|
1274
|
+
renameSync(tmpPath, logPath);
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
touchGateArtifact(artifact) {
|
|
1279
|
+
validateGateArtifact(artifact);
|
|
1280
|
+
const recordPath = this.#gateArtifactRecordPath(artifact.projectId, artifact.key);
|
|
1281
|
+
if (!existsSync(recordPath)) {
|
|
1282
|
+
throw new StorageRecordError(`Gate artifact not found for touch: ${artifact.key}`);
|
|
1283
|
+
}
|
|
1284
|
+
writeTextFileAtomically(recordPath, `${JSON.stringify(artifact, null, 2)}\n`);
|
|
1285
|
+
}
|
|
1286
|
+
getGateArtifact(projectId, key) {
|
|
1287
|
+
const path = this.#gateArtifactRecordPath(projectId, key);
|
|
1288
|
+
if (!existsSync(path))
|
|
1289
|
+
return null;
|
|
1290
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1291
|
+
return validateGateArtifact(parsed);
|
|
1292
|
+
}
|
|
1293
|
+
findGateArtifactByIdentity(identity) {
|
|
1294
|
+
return this.getGateArtifact(identity.projectId, gateArtifactKey(identity));
|
|
1295
|
+
}
|
|
1296
|
+
findL2GateArtifactsForCommit(query) {
|
|
1297
|
+
const root = join(this.rootDir, "artifacts", "gates", query.projectId);
|
|
1298
|
+
if (!existsSync(root))
|
|
1299
|
+
return [];
|
|
1300
|
+
const results = [];
|
|
1301
|
+
for (const entry of readdirSync(root)) {
|
|
1302
|
+
if (!entry.endsWith(".json"))
|
|
1303
|
+
continue;
|
|
1304
|
+
let artifact;
|
|
1305
|
+
try {
|
|
1306
|
+
artifact = this.getGateArtifact(query.projectId, entry.slice(0, -".json".length));
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
if (artifact === null
|
|
1312
|
+
|| artifact.level !== "L2"
|
|
1313
|
+
|| artifact.status !== "complete"
|
|
1314
|
+
|| artifact.outcome !== "succeeded"
|
|
1315
|
+
|| artifact.commit !== query.commit
|
|
1316
|
+
|| artifact.planDigest !== query.planDigest
|
|
1317
|
+
|| artifact.toolchainDigest !== query.toolchainDigest
|
|
1318
|
+
|| artifact.boundary?.targetRef !== query.targetRef) {
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
results.push(artifact);
|
|
1322
|
+
}
|
|
1323
|
+
return results;
|
|
1324
|
+
}
|
|
1325
|
+
getGateArtifactLogs(artifactKey) {
|
|
1326
|
+
// The artifact key is content-addressed; we need the projectId to build
|
|
1327
|
+
// the path. Callers that have the artifact should use
|
|
1328
|
+
// getGateArtifactLogsForArtifact; this fallback scans all projects.
|
|
1329
|
+
const gatesRoot = join(this.rootDir, "artifacts", "gates");
|
|
1330
|
+
if (!existsSync(gatesRoot))
|
|
1331
|
+
return new Map();
|
|
1332
|
+
for (const projectId of readdirSync(gatesRoot)) {
|
|
1333
|
+
const logsRoot = join(gatesRoot, projectId, artifactKey);
|
|
1334
|
+
if (existsSync(logsRoot) && statSync(logsRoot).isDirectory()) {
|
|
1335
|
+
return this.#readGateArtifactLogs(logsRoot);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return new Map();
|
|
1339
|
+
}
|
|
1340
|
+
#readGateArtifactLogs(logsRoot) {
|
|
1341
|
+
const logs = new Map();
|
|
1342
|
+
if (!existsSync(logsRoot))
|
|
1343
|
+
return logs;
|
|
1344
|
+
for (const entry of readdirSync(logsRoot)) {
|
|
1345
|
+
const fullPath = join(logsRoot, entry);
|
|
1346
|
+
if (statSync(fullPath).isFile()) {
|
|
1347
|
+
logs.set(entry, readFileSync(fullPath));
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return logs;
|
|
1351
|
+
}
|
|
1352
|
+
pruneGateArtifacts(projectId, options) {
|
|
1353
|
+
const root = join(this.rootDir, "artifacts", "gates", projectId);
|
|
1354
|
+
if (!existsSync(root))
|
|
1355
|
+
return Object.freeze({ retained: 0, deleted: 0 });
|
|
1356
|
+
let retained = 0;
|
|
1357
|
+
let deleted = 0;
|
|
1358
|
+
for (const entry of readdirSync(root)) {
|
|
1359
|
+
if (!entry.endsWith(".json"))
|
|
1360
|
+
continue;
|
|
1361
|
+
const key = entry.slice(0, -".json".length);
|
|
1362
|
+
let artifact;
|
|
1363
|
+
try {
|
|
1364
|
+
const loaded = this.getGateArtifact(projectId, key);
|
|
1365
|
+
if (loaded === null)
|
|
1366
|
+
continue;
|
|
1367
|
+
artifact = loaded;
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
retained += 1;
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
const age = options.now.getTime() - Date.parse(artifact.lastUsedAt);
|
|
1374
|
+
if (options.isReferenced(key) || age < options.ttlMs) {
|
|
1375
|
+
retained += 1;
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
rmSync(this.#gateArtifactRecordPath(projectId, key), { force: true });
|
|
1379
|
+
rmSync(this.#gateArtifactLogsRoot(projectId, key), { recursive: true, force: true });
|
|
1380
|
+
deleted += 1;
|
|
1381
|
+
}
|
|
1382
|
+
return Object.freeze({ retained, deleted });
|
|
1383
|
+
}
|
|
936
1384
|
getWorkMailbox(target) {
|
|
937
1385
|
return optional(this.#state().mailboxes[mailboxTargetKey(target)]);
|
|
938
1386
|
}
|
|
@@ -1134,7 +1582,14 @@ export class FileTaskStore {
|
|
|
1134
1582
|
writeCurrentStorageManifest(this.rootDir);
|
|
1135
1583
|
this.#normalizeState = undefined;
|
|
1136
1584
|
}
|
|
1137
|
-
|
|
1585
|
+
// Keep the state we just wrote as the warm read cache. The atomic write
|
|
1586
|
+
// produced a new fingerprint, so a concurrent external writer is still
|
|
1587
|
+
// detected on the next read; our own mutations no longer re-parse the
|
|
1588
|
+
// whole Home to observe state they already held under the write lock.
|
|
1589
|
+
this.#readCache = {
|
|
1590
|
+
fingerprint: stateFileFingerprint(join(this.rootDir, STORAGE_STATE_FILE)),
|
|
1591
|
+
state
|
|
1592
|
+
};
|
|
1138
1593
|
}
|
|
1139
1594
|
#parseState(raw) {
|
|
1140
1595
|
return parseState(this.#normalizeState?.(raw) ?? raw);
|
|
@@ -1155,7 +1610,13 @@ export class FileTaskStore {
|
|
|
1155
1610
|
requireCompatibleStorageSchema(this.rootDir);
|
|
1156
1611
|
}
|
|
1157
1612
|
}
|
|
1158
|
-
|
|
1613
|
+
/**
|
|
1614
|
+
* The on-disk identity of a `state.json` the store's read cache is keyed on.
|
|
1615
|
+
* The one-snapshot current-Home open fences its single read with the same
|
|
1616
|
+
* fingerprint, so a writer that changes the file between the fence and the
|
|
1617
|
+
* store's next read is detected exactly like a normal cache invalidation.
|
|
1618
|
+
*/
|
|
1619
|
+
export function stateFileFingerprint(path) {
|
|
1159
1620
|
const stat = statSync(path, { bigint: true });
|
|
1160
1621
|
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
1161
1622
|
}
|
|
@@ -1165,6 +1626,15 @@ export class StorageRecordError extends Error {
|
|
|
1165
1626
|
export class StorageConflictError extends Error {
|
|
1166
1627
|
constructor(message) { super(message); this.name = "StorageConflictError"; }
|
|
1167
1628
|
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Raised by the persistence worker when an `AbortSignal` cancels an in-flight
|
|
1631
|
+
* command batch. The open transaction is rolled back; the database is unchanged.
|
|
1632
|
+
* Already-committed transactions are not undone (their effects are idempotent
|
|
1633
|
+
* and semantically owned by the caller, design §3.1).
|
|
1634
|
+
*/
|
|
1635
|
+
export class StorageCancelledError extends Error {
|
|
1636
|
+
constructor(message) { super(message); this.name = "StorageCancelledError"; }
|
|
1637
|
+
}
|
|
1168
1638
|
export function resolveYuiHome(env) {
|
|
1169
1639
|
return env.YUI_HOME === undefined || env.YUI_HOME.length === 0
|
|
1170
1640
|
? join(homedir(), ".yui")
|
|
@@ -1186,6 +1656,10 @@ function emptyState() {
|
|
|
1186
1656
|
mailboxes: {}
|
|
1187
1657
|
};
|
|
1188
1658
|
}
|
|
1659
|
+
/** rr13: Durable map key for a Session's job caller key hash. */
|
|
1660
|
+
function jobCallerKeyHashKey(roleName, agentId) {
|
|
1661
|
+
return `${roleName}\0${agentId}`;
|
|
1662
|
+
}
|
|
1189
1663
|
function emptyStoredTask(task) {
|
|
1190
1664
|
return {
|
|
1191
1665
|
schemaVersion: CURRENT_STORED_TASK_SCHEMA_VERSION,
|
|
@@ -1194,9 +1668,12 @@ function emptyStoredTask(task) {
|
|
|
1194
1668
|
brief: null,
|
|
1195
1669
|
changeSets: {},
|
|
1196
1670
|
integrationAttempts: {},
|
|
1671
|
+
integrationQueue: {},
|
|
1672
|
+
durableJobs: {},
|
|
1197
1673
|
roles: {},
|
|
1198
1674
|
managedWorkspaces: {},
|
|
1199
1675
|
roleSessionSets: {},
|
|
1676
|
+
jobCallerKeyHashes: {},
|
|
1200
1677
|
workItems: {},
|
|
1201
1678
|
agentRuns: {},
|
|
1202
1679
|
reviewRounds: {},
|
|
@@ -1206,6 +1683,8 @@ function emptyStoredTask(task) {
|
|
|
1206
1683
|
decisions: {},
|
|
1207
1684
|
milestones: {},
|
|
1208
1685
|
events: {},
|
|
1686
|
+
capabilityGrants: {},
|
|
1687
|
+
releaseWorkflows: {},
|
|
1209
1688
|
leaderFailure: null,
|
|
1210
1689
|
operatorNotification: null
|
|
1211
1690
|
};
|
|
@@ -1215,13 +1694,18 @@ function emptyTaskIdHighWaterMarks() {
|
|
|
1215
1694
|
workItem: 0,
|
|
1216
1695
|
agentRun: 0,
|
|
1217
1696
|
reviewRound: 0,
|
|
1697
|
+
reviewFinding: 0,
|
|
1218
1698
|
changeSet: 0,
|
|
1219
1699
|
integrationAttempt: 0,
|
|
1700
|
+
integrationQueue: 0,
|
|
1701
|
+
durableJob: 0,
|
|
1220
1702
|
message: 0,
|
|
1221
1703
|
inputRequest: 0,
|
|
1222
1704
|
decision: 0,
|
|
1223
1705
|
milestone: 0,
|
|
1224
|
-
event: 0
|
|
1706
|
+
event: 0,
|
|
1707
|
+
capabilityGrant: 0,
|
|
1708
|
+
releaseWorkflow: 0
|
|
1225
1709
|
};
|
|
1226
1710
|
}
|
|
1227
1711
|
function nextTaskRecordSequence(aggregate, taskId, kind) {
|
|
@@ -1397,9 +1881,12 @@ function parseStoredTask(value, taskId) {
|
|
|
1397
1881
|
"brief",
|
|
1398
1882
|
"changeSets",
|
|
1399
1883
|
"integrationAttempts",
|
|
1884
|
+
"integrationQueue",
|
|
1885
|
+
"durableJobs",
|
|
1400
1886
|
"roles",
|
|
1401
1887
|
"managedWorkspaces",
|
|
1402
1888
|
"roleSessionSets",
|
|
1889
|
+
"jobCallerKeyHashes",
|
|
1403
1890
|
"workItems",
|
|
1404
1891
|
"agentRuns",
|
|
1405
1892
|
"reviewRounds",
|
|
@@ -1409,6 +1896,8 @@ function parseStoredTask(value, taskId) {
|
|
|
1409
1896
|
"decisions",
|
|
1410
1897
|
"milestones",
|
|
1411
1898
|
"events",
|
|
1899
|
+
"capabilityGrants",
|
|
1900
|
+
"releaseWorkflows",
|
|
1412
1901
|
"leaderFailure",
|
|
1413
1902
|
"operatorNotification"
|
|
1414
1903
|
], `Task aggregate ${taskId}`);
|
|
@@ -1428,6 +1917,25 @@ function parseStoredTask(value, taskId) {
|
|
|
1428
1917
|
validateIntegrationAttempt(attempt);
|
|
1429
1918
|
return attempt;
|
|
1430
1919
|
}, "integrationAttempts");
|
|
1920
|
+
parseMap(aggregate.integrationQueue, (record, key) => {
|
|
1921
|
+
const entry = identified(record, CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, "id", key, "Integration queue entry");
|
|
1922
|
+
if (entry.taskId !== taskId) {
|
|
1923
|
+
throw new StorageRecordError(`Integration queue entry belongs to another Task: ${entry.taskId}.`);
|
|
1924
|
+
}
|
|
1925
|
+
if (aggregate.changeSets[entry.changeSetId] === undefined) {
|
|
1926
|
+
throw new StorageRecordError(`Integration queue entry ChangeSet not found: ${entry.changeSetId}.`);
|
|
1927
|
+
}
|
|
1928
|
+
validateIntegrationQueueEntry(entry);
|
|
1929
|
+
return entry;
|
|
1930
|
+
}, "integrationQueue");
|
|
1931
|
+
parseMap(aggregate.durableJobs, (record, key) => {
|
|
1932
|
+
const job = identified(record, CURRENT_DURABLE_JOB_SCHEMA_VERSION, "id", key, "DurableJob");
|
|
1933
|
+
if (job.taskId !== taskId) {
|
|
1934
|
+
throw new StorageRecordError(`DurableJob belongs to another Task: ${job.taskId}.`);
|
|
1935
|
+
}
|
|
1936
|
+
validateDurableJob(job);
|
|
1937
|
+
return job;
|
|
1938
|
+
}, "durableJobs");
|
|
1431
1939
|
versioned(aggregate, CURRENT_STORED_TASK_SCHEMA_VERSION, `Task aggregate ${taskId}`);
|
|
1432
1940
|
validateTaskIdHighWaterMarks(aggregate.idHighWaterMarks, taskId);
|
|
1433
1941
|
validateTask(identified(aggregate.task, CURRENT_TASK_SCHEMA_VERSION, "id", taskId, "Task"));
|
|
@@ -1453,6 +1961,12 @@ function parseStoredTask(value, taskId) {
|
|
|
1453
1961
|
}, "managedWorkspaces");
|
|
1454
1962
|
parseMap(aggregate.roleSessionSets, (record, key) => { const set = taskSessions(record); if (set.owner.taskId !== taskId || set.owner.roleName !== key)
|
|
1455
1963
|
throw new StorageRecordError(`Task Role session set identity is inconsistent: ${taskId}/${key}`); return set; }, "roleSessionSets");
|
|
1964
|
+
parseMap(aggregate.jobCallerKeyHashes, (hash, key) => {
|
|
1965
|
+
if (typeof hash !== "string" || !/^[a-f0-9]{64}$/u.test(hash)) {
|
|
1966
|
+
throw new StorageRecordError(`Job caller key hash is invalid: ${taskId}/${key}.`);
|
|
1967
|
+
}
|
|
1968
|
+
return hash;
|
|
1969
|
+
}, "jobCallerKeyHashes");
|
|
1456
1970
|
parseMap(aggregate.workItems, (record, key) => {
|
|
1457
1971
|
const item = identified(record, CURRENT_WORK_ITEM_SCHEMA_VERSION, "id", key, "Work item");
|
|
1458
1972
|
if (item.taskId !== taskId) {
|
|
@@ -1539,6 +2053,26 @@ function parseStoredTask(value, taskId) {
|
|
|
1539
2053
|
}
|
|
1540
2054
|
return event;
|
|
1541
2055
|
}, "events");
|
|
2056
|
+
parseMap(aggregate.capabilityGrants, (record, key) => {
|
|
2057
|
+
const grant = storedCapabilityGrant(record);
|
|
2058
|
+
if (grant.id !== key) {
|
|
2059
|
+
throw new StorageRecordError(`Capability grant identity is inconsistent: ${key}.`);
|
|
2060
|
+
}
|
|
2061
|
+
if (grant.taskId !== taskId) {
|
|
2062
|
+
throw new StorageRecordError(`Capability grant belongs to another Task: ${grant.taskId}`);
|
|
2063
|
+
}
|
|
2064
|
+
return grant;
|
|
2065
|
+
}, "capabilityGrants");
|
|
2066
|
+
parseMap(aggregate.releaseWorkflows, (record, key) => {
|
|
2067
|
+
const workflow = storedReleaseWorkflow(record);
|
|
2068
|
+
if (workflow.id !== key) {
|
|
2069
|
+
throw new StorageRecordError(`Release workflow identity is inconsistent: ${key}.`);
|
|
2070
|
+
}
|
|
2071
|
+
if (workflow.taskId !== taskId) {
|
|
2072
|
+
throw new StorageRecordError(`Release workflow belongs to another Task: ${workflow.taskId}`);
|
|
2073
|
+
}
|
|
2074
|
+
return workflow;
|
|
2075
|
+
}, "releaseWorkflows");
|
|
1542
2076
|
for (const [key, label] of [["leaderFailure", "Leader failure"], ["operatorNotification", "Operator notification"]]) {
|
|
1543
2077
|
const record = aggregate[key];
|
|
1544
2078
|
if (record !== null) {
|
|
@@ -1556,13 +2090,20 @@ function validateTaskIdHighWaterCoverage(aggregate, taskId) {
|
|
|
1556
2090
|
workItem: aggregate.workItems,
|
|
1557
2091
|
agentRun: aggregate.agentRuns,
|
|
1558
2092
|
reviewRound: aggregate.reviewRounds,
|
|
2093
|
+
// Issue 06 dbonly: review findings are SQLite-native; the file aggregate
|
|
2094
|
+
// never carries them, so coverage is trivially empty.
|
|
2095
|
+
reviewFinding: {},
|
|
1559
2096
|
changeSet: aggregate.changeSets,
|
|
1560
2097
|
integrationAttempt: aggregate.integrationAttempts,
|
|
2098
|
+
integrationQueue: aggregate.integrationQueue,
|
|
2099
|
+
durableJob: aggregate.durableJobs,
|
|
1561
2100
|
message: aggregate.messages,
|
|
1562
2101
|
inputRequest: aggregate.inputRequests,
|
|
1563
2102
|
decision: aggregate.decisions,
|
|
1564
2103
|
milestone: aggregate.milestones,
|
|
1565
|
-
event: aggregate.events
|
|
2104
|
+
event: aggregate.events,
|
|
2105
|
+
capabilityGrant: aggregate.capabilityGrants,
|
|
2106
|
+
releaseWorkflow: aggregate.releaseWorkflows
|
|
1566
2107
|
};
|
|
1567
2108
|
for (const kind of Object.keys(TASK_RECORD_ID_PREFIXES)) {
|
|
1568
2109
|
const pattern = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`);
|
|
@@ -1586,12 +2127,15 @@ function observeTaskRecordId(aggregate, kind, id) {
|
|
|
1586
2127
|
const sequence = Number.parseInt(match[1], 10);
|
|
1587
2128
|
aggregate.idHighWaterMarks[kind] = Math.max(aggregate.idHighWaterMarks[kind], sequence);
|
|
1588
2129
|
}
|
|
1589
|
-
function validateYuiConfig(config) {
|
|
2130
|
+
export function validateYuiConfig(config) {
|
|
1590
2131
|
try {
|
|
1591
2132
|
reconciliationIntervalMilliseconds(config.reconciliationIntervalSeconds);
|
|
1592
2133
|
resolveTimeZone(config.timeZone);
|
|
1593
2134
|
if (config.review !== undefined)
|
|
1594
2135
|
validateReviewConfig(config.review);
|
|
2136
|
+
resolveLeaderNextActionMode(config.leaderNextActionMode);
|
|
2137
|
+
resolveResourcesGcMode(config.resourcesGcMode);
|
|
2138
|
+
resolveResourcesGcAutoQuarantine(config.resourcesGcAutoQuarantine);
|
|
1595
2139
|
}
|
|
1596
2140
|
catch (error) {
|
|
1597
2141
|
throw new StorageRecordError(error instanceof Error ? error.message : "Yui reconciliation interval is invalid.");
|
|
@@ -1732,6 +2276,372 @@ function storedTaskEvent(value) {
|
|
|
1732
2276
|
requireTimestamp(event.createdAt, "Task event createdAt");
|
|
1733
2277
|
return event;
|
|
1734
2278
|
}
|
|
2279
|
+
export function storedCapabilityGrant(value) {
|
|
2280
|
+
const grant = versioned(value, CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION, "Capability grant");
|
|
2281
|
+
const fields = [
|
|
2282
|
+
"schemaVersion", "id", "taskId", "granter", "scope", "actions",
|
|
2283
|
+
"parameterBounds", "usesUsed", "irreversibilityCeiling", "createdAt", "updatedAt"
|
|
2284
|
+
];
|
|
2285
|
+
if (grant.expiresAt !== undefined)
|
|
2286
|
+
fields.push("expiresAt");
|
|
2287
|
+
if (grant.maxUses !== undefined)
|
|
2288
|
+
fields.push("maxUses");
|
|
2289
|
+
if (grant.useReservations !== undefined)
|
|
2290
|
+
fields.push("useReservations");
|
|
2291
|
+
if (grant.revokedAt !== undefined)
|
|
2292
|
+
fields.push("revokedAt");
|
|
2293
|
+
if (grant.revokedBy !== undefined)
|
|
2294
|
+
fields.push("revokedBy");
|
|
2295
|
+
exact(grant, fields, "Capability grant");
|
|
2296
|
+
requireRecordIdentity(grant.id, "Capability grant id");
|
|
2297
|
+
requireRecordIdentity(grant.taskId, "Capability grant Task id");
|
|
2298
|
+
validateTaskRecordReference({ taskId: grant.taskId, localId: grant.id }, "capabilityGrant");
|
|
2299
|
+
requireNormalizedText(grant.granter, "Capability grant granter");
|
|
2300
|
+
storedCapabilityGrantScope(grant.scope, grant.taskId);
|
|
2301
|
+
if (!Array.isArray(grant.actions) || grant.actions.length === 0) {
|
|
2302
|
+
throw new StorageRecordError("Capability grant actions must be a non-empty array.");
|
|
2303
|
+
}
|
|
2304
|
+
const actions = grant.actions.map((action) => requireNormalizedText(action, "Capability grant action"));
|
|
2305
|
+
if (new Set(actions).size !== actions.length) {
|
|
2306
|
+
throw new StorageRecordError("Capability grant actions must be unique.");
|
|
2307
|
+
}
|
|
2308
|
+
const bounds = object(grant.parameterBounds, "Capability grant parameterBounds");
|
|
2309
|
+
for (const [name, allowed] of Object.entries(bounds)) {
|
|
2310
|
+
requireRecordIdentity(name, "Capability grant parameter");
|
|
2311
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
2312
|
+
throw new StorageRecordError(`Capability grant parameter bound must list allowed values: ${name}.`);
|
|
2313
|
+
}
|
|
2314
|
+
const values = allowed.map((entry) => requireNormalizedText(entry, `Capability grant parameter ${name} value`));
|
|
2315
|
+
if (new Set(values).size !== values.length) {
|
|
2316
|
+
throw new StorageRecordError(`Capability grant parameter bound values must be unique: ${name}.`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
if (grant.expiresAt !== undefined) {
|
|
2320
|
+
requireTimestamp(grant.expiresAt, "Capability grant expiresAt");
|
|
2321
|
+
}
|
|
2322
|
+
if (grant.maxUses !== undefined
|
|
2323
|
+
&& (!Number.isSafeInteger(grant.maxUses) || grant.maxUses < 1)) {
|
|
2324
|
+
throw new StorageRecordError("Capability grant maxUses must be a positive integer.");
|
|
2325
|
+
}
|
|
2326
|
+
if (!Number.isSafeInteger(grant.usesUsed) || grant.usesUsed < 0) {
|
|
2327
|
+
throw new StorageRecordError("Capability grant usesUsed must be a non-negative integer.");
|
|
2328
|
+
}
|
|
2329
|
+
if (grant.maxUses !== undefined && grant.usesUsed > grant.maxUses) {
|
|
2330
|
+
throw new StorageRecordError("Capability grant usesUsed cannot exceed maxUses.");
|
|
2331
|
+
}
|
|
2332
|
+
if (!["none", "reversible", "irreversible"].includes(grant.irreversibilityCeiling)) {
|
|
2333
|
+
throw new StorageRecordError(`Capability grant irreversibility ceiling is invalid: ${String(grant.irreversibilityCeiling)}.`);
|
|
2334
|
+
}
|
|
2335
|
+
if ((grant.revokedAt === undefined) !== (grant.revokedBy === undefined)) {
|
|
2336
|
+
throw new StorageRecordError("Capability grant revocation requires both revokedAt and revokedBy.");
|
|
2337
|
+
}
|
|
2338
|
+
if (grant.revokedAt !== undefined) {
|
|
2339
|
+
requireTimestamp(grant.revokedAt, "Capability grant revokedAt");
|
|
2340
|
+
requireNormalizedText(grant.revokedBy, "Capability grant revokedBy");
|
|
2341
|
+
}
|
|
2342
|
+
requireTimestamp(grant.createdAt, "Capability grant createdAt");
|
|
2343
|
+
requireTimestamp(grant.updatedAt, "Capability grant updatedAt");
|
|
2344
|
+
try {
|
|
2345
|
+
return validateCapabilityGrant(grant);
|
|
2346
|
+
}
|
|
2347
|
+
catch (error) {
|
|
2348
|
+
throw new StorageRecordError(error instanceof Error ? error.message : String(error));
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
function storedCapabilityGrantScope(scope, taskId) {
|
|
2352
|
+
const value = object(scope, "Capability grant scope");
|
|
2353
|
+
const fields = [];
|
|
2354
|
+
if (value.taskId !== undefined)
|
|
2355
|
+
fields.push("taskId");
|
|
2356
|
+
if (value.projectIds !== undefined)
|
|
2357
|
+
fields.push("projectIds");
|
|
2358
|
+
if (value.repositories !== undefined)
|
|
2359
|
+
fields.push("repositories");
|
|
2360
|
+
if (value.packages !== undefined)
|
|
2361
|
+
fields.push("packages");
|
|
2362
|
+
if (value.homePath !== undefined)
|
|
2363
|
+
fields.push("homePath");
|
|
2364
|
+
exact(value, fields, "Capability grant scope");
|
|
2365
|
+
if (fields.length === 0) {
|
|
2366
|
+
throw new StorageRecordError(`Capability grant scope requires at least one selector: ${taskId}.`);
|
|
2367
|
+
}
|
|
2368
|
+
if (value.taskId !== undefined) {
|
|
2369
|
+
requireRecordIdentity(value.taskId, "Capability grant scope taskId");
|
|
2370
|
+
}
|
|
2371
|
+
if (value.projectIds !== undefined) {
|
|
2372
|
+
if (!Array.isArray(value.projectIds) || value.projectIds.length === 0) {
|
|
2373
|
+
throw new StorageRecordError("Capability grant scope projectIds must be a non-empty array.");
|
|
2374
|
+
}
|
|
2375
|
+
const projectIds = value.projectIds.map((entry) => requireRecordIdentity(entry, "Capability grant scope Project"));
|
|
2376
|
+
if (new Set(projectIds).size !== projectIds.length) {
|
|
2377
|
+
throw new StorageRecordError("Capability grant scope Project ids must be unique.");
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
if (value.repositories !== undefined) {
|
|
2381
|
+
if (!Array.isArray(value.repositories) || value.repositories.length === 0) {
|
|
2382
|
+
throw new StorageRecordError("Capability grant scope repositories must be a non-empty array.");
|
|
2383
|
+
}
|
|
2384
|
+
const repositories = value.repositories.map((entry) => {
|
|
2385
|
+
const repository = object(entry, "Capability grant scope repository");
|
|
2386
|
+
exact(repository, ["owner", "name"], "Capability grant scope repository");
|
|
2387
|
+
return {
|
|
2388
|
+
owner: requireNormalizedText(repository.owner, "Capability grant scope repository owner"),
|
|
2389
|
+
name: requireNormalizedText(repository.name, "Capability grant scope repository name")
|
|
2390
|
+
};
|
|
2391
|
+
});
|
|
2392
|
+
const keys = new Set(repositories.map(({ owner, name }) => `${owner}/${name}`));
|
|
2393
|
+
if (keys.size !== repositories.length) {
|
|
2394
|
+
throw new StorageRecordError("Capability grant scope repositories must be unique.");
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
if (value.packages !== undefined) {
|
|
2398
|
+
if (!Array.isArray(value.packages) || value.packages.length === 0) {
|
|
2399
|
+
throw new StorageRecordError("Capability grant scope packages must be a non-empty array.");
|
|
2400
|
+
}
|
|
2401
|
+
const packages = value.packages.map((entry) => requireNormalizedText(entry, "Capability grant scope package"));
|
|
2402
|
+
if (new Set(packages).size !== packages.length) {
|
|
2403
|
+
throw new StorageRecordError("Capability grant scope packages must be unique.");
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
if (value.homePath !== undefined) {
|
|
2407
|
+
requireNormalizedText(value.homePath, "Capability grant scope homePath");
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
export function isValidCapabilityGrantTransition(existing, candidate) {
|
|
2411
|
+
if (existing.revokedAt !== undefined) {
|
|
2412
|
+
// Idempotent re-revoke: the domain returns the revoked record unchanged.
|
|
2413
|
+
return isDeepStrictEqual(candidate, existing);
|
|
2414
|
+
}
|
|
2415
|
+
const immutable = candidate.id === existing.id
|
|
2416
|
+
&& candidate.taskId === existing.taskId
|
|
2417
|
+
&& candidate.granter === existing.granter
|
|
2418
|
+
&& isDeepStrictEqual(candidate.scope, existing.scope)
|
|
2419
|
+
&& isDeepStrictEqual(candidate.actions, existing.actions)
|
|
2420
|
+
&& isDeepStrictEqual(candidate.parameterBounds, existing.parameterBounds)
|
|
2421
|
+
&& candidate.expiresAt === existing.expiresAt
|
|
2422
|
+
&& candidate.maxUses === existing.maxUses
|
|
2423
|
+
&& candidate.irreversibilityCeiling === existing.irreversibilityCeiling
|
|
2424
|
+
&& candidate.createdAt === existing.createdAt;
|
|
2425
|
+
if (!immutable)
|
|
2426
|
+
return false;
|
|
2427
|
+
if (candidate.revokedAt !== undefined) {
|
|
2428
|
+
// Revocation consumes no uses and records no reservations.
|
|
2429
|
+
return candidate.usesUsed === existing.usesUsed
|
|
2430
|
+
&& isDeepStrictEqual(candidate.useReservations, existing.useReservations)
|
|
2431
|
+
&& Date.parse(candidate.updatedAt) >= Date.parse(existing.updatedAt);
|
|
2432
|
+
}
|
|
2433
|
+
// A use record must advance the counter (compare-and-swap): a stale equal
|
|
2434
|
+
// increment from a concurrent reader is rejected, so two workflows cannot
|
|
2435
|
+
// spend the same maxUses slot. Reservations are append-only, one per use.
|
|
2436
|
+
return candidate.usesUsed > existing.usesUsed
|
|
2437
|
+
&& reservationsAppendOnly(existing.useReservations, candidate.useReservations)
|
|
2438
|
+
&& Date.parse(candidate.updatedAt) >= Date.parse(existing.updatedAt);
|
|
2439
|
+
}
|
|
2440
|
+
function reservationsAppendOnly(existing, candidate) {
|
|
2441
|
+
if (existing === undefined || existing.length === 0)
|
|
2442
|
+
return true;
|
|
2443
|
+
if (candidate === undefined || candidate.length < existing.length)
|
|
2444
|
+
return false;
|
|
2445
|
+
return existing.every((key, index) => candidate[index] === key);
|
|
2446
|
+
}
|
|
2447
|
+
export function storedReleaseWorkflow(value) {
|
|
2448
|
+
const workflow = versioned(value, CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION, "Release workflow");
|
|
2449
|
+
const fields = [
|
|
2450
|
+
"schemaVersion", "id", "taskId", "grantId", "source", "plan", "steps",
|
|
2451
|
+
"createdAt", "updatedAt"
|
|
2452
|
+
];
|
|
2453
|
+
exact(workflow, fields, "Release workflow");
|
|
2454
|
+
requireRecordIdentity(workflow.id, "Release workflow id");
|
|
2455
|
+
requireRecordIdentity(workflow.taskId, "Release workflow Task id");
|
|
2456
|
+
validateTaskRecordReference({ taskId: workflow.taskId, localId: workflow.id }, "releaseWorkflow");
|
|
2457
|
+
requireNormalizedText(workflow.grantId, "Release workflow grantId");
|
|
2458
|
+
storedReleaseWorkflowSource(workflow.source);
|
|
2459
|
+
if (!Array.isArray(workflow.plan) || workflow.plan.length === 0) {
|
|
2460
|
+
throw new StorageRecordError("Release workflow plan must be a non-empty array.");
|
|
2461
|
+
}
|
|
2462
|
+
const planIds = new Set();
|
|
2463
|
+
for (const entry of workflow.plan) {
|
|
2464
|
+
const plan = object(entry, "Release workflow plan entry");
|
|
2465
|
+
const planFields = ["id", "kind", "idempotencyKey"];
|
|
2466
|
+
if (plan.params !== undefined)
|
|
2467
|
+
planFields.push("params");
|
|
2468
|
+
if (plan.irreversibility !== undefined)
|
|
2469
|
+
planFields.push("irreversibility");
|
|
2470
|
+
exact(plan, planFields, "Release workflow plan entry");
|
|
2471
|
+
const planId = requireRecordIdentity(plan.id, "Release step id");
|
|
2472
|
+
if (planIds.has(planId)) {
|
|
2473
|
+
throw new StorageRecordError(`Release workflow plan ids must be unique: ${planId}.`);
|
|
2474
|
+
}
|
|
2475
|
+
planIds.add(planId);
|
|
2476
|
+
if (!RELEASE_WORKFLOW_KINDS.has(plan.kind)) {
|
|
2477
|
+
throw new StorageRecordError(`Release step kind is invalid: ${String(plan.kind)}.`);
|
|
2478
|
+
}
|
|
2479
|
+
requireNormalizedText(plan.idempotencyKey, "Release step idempotencyKey");
|
|
2480
|
+
if (plan.params !== undefined) {
|
|
2481
|
+
const params = object(plan.params, "Release step params");
|
|
2482
|
+
for (const [name, paramValue] of Object.entries(params)) {
|
|
2483
|
+
requireRecordIdentity(name, "Release step param");
|
|
2484
|
+
requireNormalizedText(paramValue, `Release step param ${name}`);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
if (plan.irreversibility !== undefined
|
|
2488
|
+
&& !["none", "reversible", "irreversible"].includes(plan.irreversibility)) {
|
|
2489
|
+
throw new StorageRecordError(`Release step irreversibility is invalid: ${String(plan.irreversibility)}.`);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
const steps = object(workflow.steps, "Release workflow steps");
|
|
2493
|
+
for (const planId of planIds) {
|
|
2494
|
+
if (!Object.hasOwn(steps, planId)) {
|
|
2495
|
+
throw new StorageRecordError(`Release workflow step record is missing: ${planId}.`);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
for (const key of Object.keys(steps)) {
|
|
2499
|
+
if (!planIds.has(key)) {
|
|
2500
|
+
throw new StorageRecordError(`Release workflow step record has no plan entry: ${key}.`);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
for (const planId of planIds) {
|
|
2504
|
+
storedReleaseStep(steps[planId], planId);
|
|
2505
|
+
}
|
|
2506
|
+
requireTimestamp(workflow.createdAt, "Release workflow createdAt");
|
|
2507
|
+
requireTimestamp(workflow.updatedAt, "Release workflow updatedAt");
|
|
2508
|
+
try {
|
|
2509
|
+
return validateReleaseWorkflow(workflow);
|
|
2510
|
+
}
|
|
2511
|
+
catch (error) {
|
|
2512
|
+
throw new StorageRecordError(error instanceof Error ? error.message : String(error));
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
function storedReleaseWorkflowSource(source) {
|
|
2516
|
+
const value = object(source, "Release workflow source");
|
|
2517
|
+
const sourceFields = ["repository", "commit"];
|
|
2518
|
+
if (value.artifact !== undefined)
|
|
2519
|
+
sourceFields.push("artifact");
|
|
2520
|
+
exact(value, sourceFields, "Release workflow source");
|
|
2521
|
+
const repository = object(value.repository, "Release workflow source repository");
|
|
2522
|
+
exact(repository, ["owner", "name"], "Release workflow source repository");
|
|
2523
|
+
requireNormalizedText(repository.owner, "Release workflow source repository owner");
|
|
2524
|
+
requireNormalizedText(repository.name, "Release workflow source repository name");
|
|
2525
|
+
requireNormalizedText(value.commit, "Release workflow source commit");
|
|
2526
|
+
if (value.artifact !== undefined) {
|
|
2527
|
+
const artifact = object(value.artifact, "Release workflow source artifact");
|
|
2528
|
+
exact(artifact, ["name", "integrity"], "Release workflow source artifact");
|
|
2529
|
+
requireNormalizedText(artifact.name, "Release workflow source artifact name");
|
|
2530
|
+
requireNormalizedText(artifact.integrity, "Release workflow source artifact integrity");
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
function storedReleaseStep(step, planId) {
|
|
2534
|
+
const value = object(step, `Release step record ${planId}`);
|
|
2535
|
+
const stepFields = ["planId", "status", "attempts", "logs"];
|
|
2536
|
+
if (value.externalId !== undefined)
|
|
2537
|
+
stepFields.push("externalId");
|
|
2538
|
+
if (value.externalIdentity !== undefined)
|
|
2539
|
+
stepFields.push("externalIdentity");
|
|
2540
|
+
if (value.lastAttemptAt !== undefined)
|
|
2541
|
+
stepFields.push("lastAttemptAt");
|
|
2542
|
+
if (value.terminalAt !== undefined)
|
|
2543
|
+
stepFields.push("terminalAt");
|
|
2544
|
+
exact(value, stepFields, `Release step record ${planId}`);
|
|
2545
|
+
requireNormalizedText(value.planId, `Release step planId ${planId}`);
|
|
2546
|
+
if (value.planId !== planId) {
|
|
2547
|
+
throw new StorageRecordError(`Release step record planId ${String(value.planId)} does not match its key: ${planId}.`);
|
|
2548
|
+
}
|
|
2549
|
+
if (!RELEASE_WORKFLOW_STATUSES.has(value.status)) {
|
|
2550
|
+
throw new StorageRecordError(`Release step status is invalid: ${String(value.status)}.`);
|
|
2551
|
+
}
|
|
2552
|
+
if (!Number.isSafeInteger(value.attempts) || value.attempts < 0) {
|
|
2553
|
+
throw new StorageRecordError(`Release step attempts must be a non-negative integer: ${planId}.`);
|
|
2554
|
+
}
|
|
2555
|
+
if (value.externalId !== undefined) {
|
|
2556
|
+
requireNormalizedText(value.externalId, `Release step externalId ${planId}`);
|
|
2557
|
+
}
|
|
2558
|
+
if (value.externalIdentity !== undefined) {
|
|
2559
|
+
const identity = object(value.externalIdentity, `Release step externalIdentity ${planId}`);
|
|
2560
|
+
exact(identity, ["kind", "value"], `Release step externalIdentity ${planId}`);
|
|
2561
|
+
requireNormalizedText(identity.kind, `Release step externalIdentity kind ${planId}`);
|
|
2562
|
+
requireNormalizedText(identity.value, `Release step externalIdentity value ${planId}`);
|
|
2563
|
+
}
|
|
2564
|
+
// An `unknown` step without an externalIdentity is a crash-recovery state:
|
|
2565
|
+
// the process died during executeStep after the effect may have landed, but
|
|
2566
|
+
// before an identity was recorded. The domain validation permits it and the
|
|
2567
|
+
// engine fails closed (unconfirmed) on resume; the store must persist it.
|
|
2568
|
+
if (!Array.isArray(value.logs)) {
|
|
2569
|
+
throw new StorageRecordError(`Release step logs must be an array: ${planId}.`);
|
|
2570
|
+
}
|
|
2571
|
+
for (const line of value.logs) {
|
|
2572
|
+
requireNormalizedText(line, `Release step log ${planId}`);
|
|
2573
|
+
}
|
|
2574
|
+
if (value.lastAttemptAt !== undefined) {
|
|
2575
|
+
requireTimestamp(value.lastAttemptAt, `Release step lastAttemptAt ${planId}`);
|
|
2576
|
+
}
|
|
2577
|
+
if (value.status === "running" && value.lastAttemptAt === undefined) {
|
|
2578
|
+
throw new StorageRecordError(`Release step running status requires lastAttemptAt: ${planId}.`);
|
|
2579
|
+
}
|
|
2580
|
+
if (value.terminalAt !== undefined) {
|
|
2581
|
+
requireTimestamp(value.terminalAt, `Release step terminalAt ${planId}`);
|
|
2582
|
+
}
|
|
2583
|
+
if ((value.status === "succeeded" || value.status === "skipped") && value.terminalAt === undefined) {
|
|
2584
|
+
throw new StorageRecordError(`Release step ${String(value.status)} status requires terminalAt: ${planId}.`);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
const RELEASE_WORKFLOW_KINDS = new Set([
|
|
2588
|
+
"pr-create-or-reuse", "ci-confirm", "merge", "version-tag",
|
|
2589
|
+
"npm-publish", "fresh-install-smoke", "cli-update",
|
|
2590
|
+
"controller-replace", "project-migrate", "post-verify"
|
|
2591
|
+
]);
|
|
2592
|
+
const RELEASE_WORKFLOW_STATUSES = new Set([
|
|
2593
|
+
"pending", "running", "succeeded", "failed", "unknown", "skipped"
|
|
2594
|
+
]);
|
|
2595
|
+
/**
|
|
2596
|
+
* Workflow records only move forward in time: a save with an older updatedAt
|
|
2597
|
+
* than the stored record is rejected. Equal updatedAt permits idempotent
|
|
2598
|
+
* re-saves of the same record. The exact source and the predeclared plan are
|
|
2599
|
+
* immutable after create, and each step status may only follow the release
|
|
2600
|
+
* state machine (no rewinds that could re-trigger a side effect).
|
|
2601
|
+
*/
|
|
2602
|
+
export function isValidReleaseWorkflowTransition(existing, candidate) {
|
|
2603
|
+
if (Date.parse(candidate.updatedAt) < Date.parse(existing.updatedAt))
|
|
2604
|
+
return false;
|
|
2605
|
+
if (!isDeepStrictEqual(candidate.source, existing.source))
|
|
2606
|
+
return false;
|
|
2607
|
+
if (!isDeepStrictEqual(candidate.plan, existing.plan))
|
|
2608
|
+
return false;
|
|
2609
|
+
const existingKeys = Object.keys(existing.steps);
|
|
2610
|
+
const candidateKeys = Object.keys(candidate.steps);
|
|
2611
|
+
if (existingKeys.length !== candidateKeys.length)
|
|
2612
|
+
return false;
|
|
2613
|
+
for (const key of existingKeys) {
|
|
2614
|
+
const from = existing.steps[key];
|
|
2615
|
+
const to = candidate.steps[key];
|
|
2616
|
+
if (from === undefined || to === undefined)
|
|
2617
|
+
return false;
|
|
2618
|
+
if (!isLegalStepTransition(from.status, to.status))
|
|
2619
|
+
return false;
|
|
2620
|
+
if (to.attempts < from.attempts)
|
|
2621
|
+
return false;
|
|
2622
|
+
}
|
|
2623
|
+
return true;
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* The legal step-status transitions. Self-transitions are always allowed
|
|
2627
|
+
* (idempotent re-saves). `pending -> failed` is permitted because the engine
|
|
2628
|
+
* records an authorization denial atomically (start then fail in one save).
|
|
2629
|
+
* `failed -> succeeded` is permitted only via an authoritative query that
|
|
2630
|
+
* proves the effect landed (confirmFailedStep).
|
|
2631
|
+
*/
|
|
2632
|
+
const LEGAL_STEP_TRANSITIONS = {
|
|
2633
|
+
pending: ["running", "skipped", "failed"],
|
|
2634
|
+
running: ["running", "succeeded", "failed", "unknown"],
|
|
2635
|
+
failed: ["running", "succeeded"],
|
|
2636
|
+
unknown: ["running", "succeeded"],
|
|
2637
|
+
succeeded: [],
|
|
2638
|
+
skipped: []
|
|
2639
|
+
};
|
|
2640
|
+
function isLegalStepTransition(from, to) {
|
|
2641
|
+
if (from === to)
|
|
2642
|
+
return true;
|
|
2643
|
+
return LEGAL_STEP_TRANSITIONS[from]?.includes(to) ?? false;
|
|
2644
|
+
}
|
|
1735
2645
|
function isValidDecisionSupersession(existing, candidate) {
|
|
1736
2646
|
return existing.status === "active"
|
|
1737
2647
|
&& candidate.status === "superseded"
|
|
@@ -1857,7 +2767,7 @@ function values(records, identity) {
|
|
|
1857
2767
|
return Object.values(records).map(clone).sort((left, right) => numericCompare(typeof identity === "function" ? identity(left) : String(left[identity]), typeof identity === "function" ? identity(right) : String(right[identity])));
|
|
1858
2768
|
}
|
|
1859
2769
|
function numericCompare(left, right) { return left.localeCompare(right, undefined, { numeric: true }); }
|
|
1860
|
-
function pendingWakeupProjection(mailbox) {
|
|
2770
|
+
export function pendingWakeupProjection(mailbox) {
|
|
1861
2771
|
if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
|
|
1862
2772
|
|| mailbox.pending === null) {
|
|
1863
2773
|
return null;
|
|
@@ -2034,7 +2944,7 @@ function validateCanonicalTaskReferences(state, aggregate) {
|
|
|
2034
2944
|
if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === changeSet.projectId)) {
|
|
2035
2945
|
throw new StorageRecordError(`ChangeSet Project does not match Task: ${changeSet.id}.`);
|
|
2036
2946
|
}
|
|
2037
|
-
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
|
|
2947
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === changeSet.headCommit);
|
|
2038
2948
|
if (evidenceRound !== undefined) {
|
|
2039
2949
|
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become a ChangeSet.`);
|
|
2040
2950
|
}
|
|
@@ -2051,7 +2961,7 @@ function validateCanonicalTaskReferences(state, aggregate) {
|
|
|
2051
2961
|
if (changeSet.projectId !== integration.projectId) {
|
|
2052
2962
|
throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${integration.id}/${changeSetId}.`);
|
|
2053
2963
|
}
|
|
2054
|
-
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
|
|
2964
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === changeSet.headCommit);
|
|
2055
2965
|
if (evidenceRound !== undefined) {
|
|
2056
2966
|
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
|
|
2057
2967
|
}
|
|
@@ -2200,11 +3110,38 @@ function validIntegrationTransition(before, after) {
|
|
|
2200
3110
|
running: ["running", "blocked", "validating", "failed"],
|
|
2201
3111
|
blocked: ["blocked", "validating", "failed"],
|
|
2202
3112
|
validating: ["validating", "committed", "failed"],
|
|
2203
|
-
committed: ["committed"],
|
|
3113
|
+
committed: ["committed", "superseded"],
|
|
3114
|
+
superseded: ["superseded"],
|
|
2204
3115
|
failed: ["failed"]
|
|
2205
3116
|
};
|
|
2206
3117
|
return allowed[before.status].includes(after.status);
|
|
2207
3118
|
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Storage-level defence in depth for the integration queue state machine: the
|
|
3121
|
+
* identity fields and the check/evidence lists are immutable once written, and
|
|
3122
|
+
* a status may only move along the queue's legal transitions. The service
|
|
3123
|
+
* owns the CAS claim; this rejects a stale or forged write that slipped past it.
|
|
3124
|
+
*/
|
|
3125
|
+
function validIntegrationQueueTransition(before, after) {
|
|
3126
|
+
if (before.id !== after.id
|
|
3127
|
+
|| before.taskId !== after.taskId
|
|
3128
|
+
|| before.projectId !== after.projectId
|
|
3129
|
+
|| before.changeSetId !== after.changeSetId
|
|
3130
|
+
|| before.targetRef !== after.targetRef
|
|
3131
|
+
|| !isDeepStrictEqual(before.checkCommands, after.checkCommands)
|
|
3132
|
+
|| !isDeepStrictEqual(before.evidenceRefs, after.evidenceRefs)
|
|
3133
|
+
|| before.createdAt !== after.createdAt)
|
|
3134
|
+
return false;
|
|
3135
|
+
const allowed = {
|
|
3136
|
+
queued: ["queued", "running", "validated", "superseded"],
|
|
3137
|
+
running: ["running", "conflicted", "committed"],
|
|
3138
|
+
conflicted: ["conflicted", "running", "committed", "queued", "superseded"],
|
|
3139
|
+
validated: ["validated", "running", "queued", "superseded"],
|
|
3140
|
+
committed: ["committed"],
|
|
3141
|
+
superseded: ["superseded"]
|
|
3142
|
+
};
|
|
3143
|
+
return allowed[before.status].includes(after.status);
|
|
3144
|
+
}
|
|
2208
3145
|
function assertAcyclicWorkItems(items) {
|
|
2209
3146
|
const visiting = new Set();
|
|
2210
3147
|
const visited = new Set();
|
|
@@ -2527,7 +3464,13 @@ function validReviewRoundTransition(existing, candidate) {
|
|
|
2527
3464
|
|| !isDeepStrictEqual(existing.taskCandidate, candidate.taskCandidate)
|
|
2528
3465
|
|| !sameTaskFinalReviewContract(existing.taskFinalReviewContract, candidate.taskFinalReviewContract)
|
|
2529
3466
|
|| !compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup)
|
|
2530
|
-
|
|
3467
|
+
// Issue 06: a Leader retry resets a failed Task-final Round to pending;
|
|
3468
|
+
// the retry is itself a Leader request, so requestedBy may change from
|
|
3469
|
+
// the original policy/contract value to "leader".
|
|
3470
|
+
|| (existing.requestedBy !== candidate.requestedBy
|
|
3471
|
+
&& !(existing.status === "failed"
|
|
3472
|
+
&& candidate.status === "pending"
|
|
3473
|
+
&& (candidate.scope ?? "work-item") === "task"))
|
|
2531
3474
|
|| existing.createdAt !== candidate.createdAt)
|
|
2532
3475
|
return false;
|
|
2533
3476
|
if (existing.status === "pending") {
|
|
@@ -2568,6 +3511,24 @@ function validReviewRoundTransition(existing, candidate) {
|
|
|
2568
3511
|
&& existing.reviewerRunId === candidate.reviewerRunId
|
|
2569
3512
|
&& isDeepStrictEqual(existing.workspace, candidate.workspace);
|
|
2570
3513
|
}
|
|
3514
|
+
// Issue 06: a failed Task-final execution attempt may be reset to pending
|
|
3515
|
+
// under the same semantic Round ID. AgentRun history remains the attempt
|
|
3516
|
+
// trail; terminal Review metadata is cleared by retryTaskReviewRound.
|
|
3517
|
+
if (existing.status === "failed"
|
|
3518
|
+
&& candidate.status === "pending"
|
|
3519
|
+
&& (candidate.scope ?? "work-item") === "task") {
|
|
3520
|
+
return candidate.reviewerRunId === undefined
|
|
3521
|
+
&& candidate.summary === undefined
|
|
3522
|
+
&& candidate.report === undefined
|
|
3523
|
+
&& candidate.checks === undefined
|
|
3524
|
+
&& candidate.evidenceCommit === undefined
|
|
3525
|
+
&& candidate.endedAt === undefined
|
|
3526
|
+
&& candidate.workspaceDisposition === undefined
|
|
3527
|
+
&& (existing.workspace === undefined
|
|
3528
|
+
|| candidate.workspace === undefined
|
|
3529
|
+
|| isDeepStrictEqual(existing.workspace, candidate.workspace))
|
|
3530
|
+
&& compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup);
|
|
3531
|
+
}
|
|
2571
3532
|
if (existing.status === candidate.status
|
|
2572
3533
|
&& (existing.status === "completed" || existing.status === "failed")) {
|
|
2573
3534
|
const { workspaceDisposition: _existingDisposition, ...existingResult } = existing;
|