@zq-silk/yui 0.2.0 → 0.4.2
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/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -4,34 +4,119 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { validateConfiguredAgent } from "../agent/agent.js";
|
|
6
6
|
import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
|
|
7
|
+
import { resolveTimeZone } from "../output/timePresentation.js";
|
|
8
|
+
import { mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
|
|
7
9
|
import { validateInputRequest } from "../input/inputRequest.js";
|
|
8
10
|
import { validateRoleSessionSet } from "../executor/agentExecutor.js";
|
|
9
11
|
import { validateTaskMessage } from "../message/message.js";
|
|
10
12
|
import { validateAgentRun } from "../run/agentRun.js";
|
|
11
|
-
import {
|
|
13
|
+
import { validateReviewConfig } from "../review/reviewConfig.js";
|
|
14
|
+
import { validateReviewRound } from "../review/reviewRound.js";
|
|
15
|
+
import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
|
|
16
|
+
import { assertProjectCatalog, validateProject } from "../repository/project.js";
|
|
17
|
+
import { validateAgentProfile } from "../profile/agentProfile.js";
|
|
18
|
+
import { validateChangeSet } from "../integration/changeSet.js";
|
|
19
|
+
import { validateIntegrationAttempt } from "../integration/integrationAttempt.js";
|
|
12
20
|
import { validateGlobalRole, validateTaskRole } from "../role/role.js";
|
|
21
|
+
import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../scheduler/leaderFailure.js";
|
|
22
|
+
import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../scheduler/operatorNotification.js";
|
|
13
23
|
import { validateTask } from "../task/task.js";
|
|
14
|
-
import {
|
|
24
|
+
import { formatAgentRunReceiptId, TASK_RECORD_ID_PREFIXES, validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
25
|
+
import { workItemExecutionGroupById, validateWorkItem } from "../workItem/workItem.js";
|
|
26
|
+
import { isExecutionGroupTransition, validateExecutionGroup } from "../execution/executionGroup.js";
|
|
27
|
+
import { managedWorkspaceKey, validateManagedWorkspace } from "../worktree/managedWorkspace.js";
|
|
15
28
|
import { writeTextFileAtomically } from "./durableFile.js";
|
|
16
|
-
import {
|
|
29
|
+
import { assertHomeWritable } from "./upgradeFence.js";
|
|
30
|
+
import { CURRENT_AGGREGATE_SCHEMA_VERSION, requireCompatibleStorageSchema, requireStorageSchema, writeCurrentStorageManifest } from "./storageSchema.js";
|
|
17
31
|
export const STORAGE_STATE_FILE = "state.json";
|
|
32
|
+
/** The root StorageState schema is the persisted aggregate document version. */
|
|
33
|
+
export const CURRENT_STORAGE_STATE_SCHEMA_VERSION = CURRENT_AGGREGATE_SCHEMA_VERSION;
|
|
34
|
+
export const CURRENT_CONFIG_SCHEMA_VERSION = 1;
|
|
35
|
+
export const CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION = 3;
|
|
36
|
+
/**
|
|
37
|
+
* Persisted StorageState/StoredTask family versions owned by this boundary.
|
|
38
|
+
*
|
|
39
|
+
* Keep these names next to the strict parser/writer so the upgrade record map
|
|
40
|
+
* can classify exactly the bytes this store accepts and emits. Nested session
|
|
41
|
+
* members and the PendingWakeup projection are included as parser boundaries
|
|
42
|
+
* below, but are not independent record-axis families.
|
|
43
|
+
*/
|
|
44
|
+
export const CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION = 2;
|
|
45
|
+
export const CURRENT_PROJECT_SCHEMA_VERSION = 2;
|
|
46
|
+
export const CURRENT_AGENT_PROFILE_SCHEMA_VERSION = 2;
|
|
47
|
+
export const CURRENT_GLOBAL_ROLE_SCHEMA_VERSION = 3;
|
|
48
|
+
export const CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION = 3;
|
|
49
|
+
export const CURRENT_TASK_SCHEMA_VERSION = 3;
|
|
50
|
+
export const CURRENT_TASK_BRIEF_SCHEMA_VERSION = 2;
|
|
51
|
+
export const CURRENT_TASK_ROLE_SCHEMA_VERSION = 3;
|
|
52
|
+
export const CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION = 2;
|
|
53
|
+
export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 9;
|
|
54
|
+
export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 4;
|
|
55
|
+
export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 2;
|
|
56
|
+
export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 2;
|
|
57
|
+
export const CURRENT_MESSAGE_SCHEMA_VERSION = 2;
|
|
58
|
+
export const CURRENT_INPUT_REQUEST_SCHEMA_VERSION = 2;
|
|
59
|
+
export const CURRENT_DECISION_SCHEMA_VERSION = 1;
|
|
60
|
+
export const CURRENT_MILESTONE_SCHEMA_VERSION = 1;
|
|
61
|
+
export const CURRENT_EVENT_SCHEMA_VERSION = 2;
|
|
62
|
+
export const CURRENT_WORK_MAILBOX_SCHEMA_VERSION = 1;
|
|
63
|
+
export const CURRENT_ROLE_AGENT_SESSION_SCHEMA_VERSION = 3;
|
|
64
|
+
export const CURRENT_PENDING_WAKEUP_SCHEMA_VERSION = 1;
|
|
18
65
|
const STORAGE_LOCK_DIRECTORY = ".state.lock";
|
|
19
66
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
20
67
|
const LOCK_RETRY_MS = 10;
|
|
21
68
|
export const COMPLETION_SHELLS = ["bash", "zsh", "fish"];
|
|
69
|
+
/**
|
|
70
|
+
* Lane-scoped active pointers live beside the legacy role pointers. The
|
|
71
|
+
* namespace starts with a slash because Role identities reject slashes; this
|
|
72
|
+
* makes the two key spaces disjoint even for legal Role names such as
|
|
73
|
+
* `lane:worker:1`.
|
|
74
|
+
*/
|
|
75
|
+
export function executionLaneActiveRunKey(executionGroupId, executionLaneId) {
|
|
76
|
+
return `/execution-lane/${encodeLaneKeyPart(executionGroupId)}:${encodeLaneKeyPart(executionLaneId)}`;
|
|
77
|
+
}
|
|
78
|
+
function encodeLaneKeyPart(value) {
|
|
79
|
+
return encodeURIComponent(value).replace(/:/gu, "%3A");
|
|
80
|
+
}
|
|
81
|
+
function executionLaneActiveRunKeyParts(key) {
|
|
82
|
+
const match = /^\/execution-lane\/([^:]+):([^:]+)$/u.exec(key);
|
|
83
|
+
if (match === null)
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
return {
|
|
87
|
+
executionGroupId: decodeURIComponent(match[1]),
|
|
88
|
+
executionLaneId: decodeURIComponent(match[2])
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The schema version of each persisted `state.json#/tasks/*` aggregate. */
|
|
96
|
+
export const CURRENT_STORED_TASK_SCHEMA_VERSION = 14;
|
|
97
|
+
/**
|
|
98
|
+
* Persisted nested-record versions consumed by this store's strict parser.
|
|
99
|
+
* Keep these named at the storage boundary so the upgrade record-axis map can
|
|
100
|
+
* assert it is classifying the same bytes the store reads and writes.
|
|
101
|
+
*/
|
|
102
|
+
export const CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION = 4;
|
|
103
|
+
export const CURRENT_AGENT_RUN_SCHEMA_VERSION = 6;
|
|
22
104
|
export class FileTaskStore {
|
|
23
105
|
rootDir;
|
|
24
106
|
#transaction = null;
|
|
25
|
-
|
|
107
|
+
#readCache = null;
|
|
108
|
+
#normalizeState;
|
|
109
|
+
constructor(rootDir, options = {}) {
|
|
26
110
|
this.rootDir = rootDir;
|
|
27
|
-
|
|
111
|
+
this.#normalizeState = options.normalizeState;
|
|
112
|
+
this.#requireReadableSchema();
|
|
28
113
|
}
|
|
29
114
|
rootDirectory() { return this.rootDir; }
|
|
30
115
|
transaction(execute) {
|
|
31
116
|
if (this.#transaction !== null)
|
|
32
117
|
return synchronousResult(execute(this));
|
|
33
118
|
return this.#withWriteLock(() => {
|
|
34
|
-
const state = this.#
|
|
119
|
+
const state = this.#readCachedState();
|
|
35
120
|
this.#transaction = { state, baseRevision: state.revision, dirty: false };
|
|
36
121
|
try {
|
|
37
122
|
const result = synchronousResult(execute(this));
|
|
@@ -39,6 +124,10 @@ export class FileTaskStore {
|
|
|
39
124
|
this.#commit(state, this.#transaction.baseRevision);
|
|
40
125
|
return result;
|
|
41
126
|
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.#readCache = null;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
42
131
|
finally {
|
|
43
132
|
this.#transaction = null;
|
|
44
133
|
}
|
|
@@ -46,12 +135,12 @@ export class FileTaskStore {
|
|
|
46
135
|
}
|
|
47
136
|
getConfig() { return clone(this.#state().config); }
|
|
48
137
|
saveConfig(config) {
|
|
49
|
-
const stored = versioned(config,
|
|
138
|
+
const stored = versioned(config, CURRENT_CONFIG_SCHEMA_VERSION, "Yui config");
|
|
50
139
|
validateYuiConfig(stored);
|
|
51
140
|
this.#mutate((state) => { state.config = stored; });
|
|
52
141
|
}
|
|
53
142
|
saveConfiguredAgent(agent) {
|
|
54
|
-
const stored = identified(agent,
|
|
143
|
+
const stored = identified(agent, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, "id", agent.id, "Configured Agent");
|
|
55
144
|
validateConfiguredAgent(stored);
|
|
56
145
|
this.#mutate((state) => { state.configuredAgents[stored.id] = stored; });
|
|
57
146
|
}
|
|
@@ -72,7 +161,7 @@ export class FileTaskStore {
|
|
|
72
161
|
...existing,
|
|
73
162
|
...clone(patch),
|
|
74
163
|
updatedAt: now.toISOString()
|
|
75
|
-
},
|
|
164
|
+
}, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, "Configured Agent");
|
|
76
165
|
const unchanged = isDeepStrictEqual({ ...existing, updatedAt: candidate.updatedAt }, candidate);
|
|
77
166
|
if (unchanged)
|
|
78
167
|
return { status: "unchanged", agent: existing };
|
|
@@ -85,36 +174,87 @@ export class FileTaskStore {
|
|
|
85
174
|
removeConfiguredAgent(id) {
|
|
86
175
|
return this.#remove((state) => state.configuredAgents, id);
|
|
87
176
|
}
|
|
88
|
-
|
|
89
|
-
return this.#nextGlobalId("
|
|
177
|
+
nextProjectId() {
|
|
178
|
+
return this.#nextGlobalId("project", (state) => Object.keys(state.projects));
|
|
90
179
|
}
|
|
91
|
-
|
|
92
|
-
const stored = identified(
|
|
93
|
-
|
|
94
|
-
this.#mutate((state) => {
|
|
180
|
+
saveProject(project) {
|
|
181
|
+
const stored = identified(project, CURRENT_PROJECT_SCHEMA_VERSION, "id", project.id, "Project");
|
|
182
|
+
validateProject(stored);
|
|
183
|
+
this.#mutate((state) => {
|
|
184
|
+
assertProjectCatalog([
|
|
185
|
+
...Object.values(state.projects).filter(({ id }) => id !== stored.id),
|
|
186
|
+
stored
|
|
187
|
+
]);
|
|
188
|
+
state.projects[stored.id] = stored;
|
|
189
|
+
});
|
|
95
190
|
}
|
|
96
|
-
|
|
191
|
+
createProjectIfAbsent(project) {
|
|
192
|
+
validateProject(project);
|
|
97
193
|
return this.transaction((store) => {
|
|
98
|
-
if (store.
|
|
194
|
+
if (store.getProject(project.id) !== null)
|
|
99
195
|
return null;
|
|
100
|
-
|
|
196
|
+
try {
|
|
197
|
+
assertProjectCatalog([...store.listProjects(), project]);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
101
200
|
return null;
|
|
102
|
-
|
|
103
|
-
|
|
201
|
+
}
|
|
202
|
+
store.saveProject(project);
|
|
203
|
+
return clone(project);
|
|
104
204
|
});
|
|
105
205
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
206
|
+
listProjects() { return values(this.#state().projects, "id"); }
|
|
207
|
+
getProject(id) { return optional(this.#state().projects[id]); }
|
|
208
|
+
removeProject(id) {
|
|
109
209
|
return this.transaction((store) => {
|
|
110
|
-
if (store.listTasks().some((task) => task.
|
|
111
|
-
throw new StorageRecordError(`
|
|
210
|
+
if (store.listTasks().some((task) => task.projectBindings.some((binding) => binding.projectId === id))) {
|
|
211
|
+
throw new StorageRecordError(`Project is still used by a Task: ${id}`);
|
|
112
212
|
}
|
|
113
|
-
return this.#remove((state) => state.
|
|
213
|
+
return this.#remove((state) => state.projects, id);
|
|
114
214
|
});
|
|
115
215
|
}
|
|
216
|
+
saveAgentProfile(profile) {
|
|
217
|
+
const stored = identified(profile, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, "id", profile.id, "Agent Profile");
|
|
218
|
+
validateAgentProfile(stored);
|
|
219
|
+
this.#mutate((state) => {
|
|
220
|
+
const existing = state.agentProfiles[stored.id];
|
|
221
|
+
if (existing !== undefined) {
|
|
222
|
+
if (stored.revision < existing.revision) {
|
|
223
|
+
throw new StorageRecordError(`Agent Profile revision cannot move backwards: ${stored.id}.`);
|
|
224
|
+
}
|
|
225
|
+
if (stored.revision === existing.revision
|
|
226
|
+
&& !isDeepStrictEqual(stored, existing)) {
|
|
227
|
+
throw new StorageRecordError(`Agent Profile revision cannot be overwritten: ${stored.id}/${stored.revision}.`);
|
|
228
|
+
}
|
|
229
|
+
if (stored.revision > existing.revision + 1) {
|
|
230
|
+
throw new StorageRecordError(`Agent Profile revision must be contiguous: ${stored.id}/${stored.revision}.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else if (stored.revision !== 1) {
|
|
234
|
+
throw new StorageRecordError(`A new Agent Profile must start at revision 1: ${stored.id}.`);
|
|
235
|
+
}
|
|
236
|
+
state.agentProfiles[stored.id] = stored;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
createAgentProfileIfAbsent(profile) {
|
|
240
|
+
return this.transaction((store) => {
|
|
241
|
+
if (store.getAgentProfile(profile.id) !== null)
|
|
242
|
+
return null;
|
|
243
|
+
store.saveAgentProfile(profile);
|
|
244
|
+
return clone(profile);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
listAgentProfiles() {
|
|
248
|
+
return values(this.#state().agentProfiles, "id");
|
|
249
|
+
}
|
|
250
|
+
getAgentProfile(id) {
|
|
251
|
+
return optional(this.#state().agentProfiles[id]);
|
|
252
|
+
}
|
|
253
|
+
removeAgentProfile(id) {
|
|
254
|
+
return this.#remove((state) => state.agentProfiles, id);
|
|
255
|
+
}
|
|
116
256
|
saveGlobalRole(role) {
|
|
117
|
-
const stored = identified(role,
|
|
257
|
+
const stored = identified(role, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, "name", role.name, "Global Role");
|
|
118
258
|
validateGlobalRole(stored);
|
|
119
259
|
const sessions = this.getGlobalRoleSessionSet(stored.name);
|
|
120
260
|
if (sessions !== null)
|
|
@@ -122,7 +262,7 @@ export class FileTaskStore {
|
|
|
122
262
|
this.#mutate((state) => { state.globalRoles[stored.name] = stored; });
|
|
123
263
|
}
|
|
124
264
|
saveGlobalRoleWithSessionSet(role, sessions) {
|
|
125
|
-
const storedRole = identified(role,
|
|
265
|
+
const storedRole = identified(role, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, "name", role.name, "Global Role");
|
|
126
266
|
validateGlobalRole(storedRole);
|
|
127
267
|
const storedSessions = sessions === null ? null : globalSessions(sessions);
|
|
128
268
|
if (storedSessions !== null)
|
|
@@ -170,10 +310,12 @@ export class FileTaskStore {
|
|
|
170
310
|
}
|
|
171
311
|
nextTaskId() { return this.#nextGlobalId("task", (state) => Object.keys(state.tasks)); }
|
|
172
312
|
saveTask(task) {
|
|
173
|
-
const stored = validateTask(identified(task,
|
|
313
|
+
const stored = validateTask(identified(task, CURRENT_TASK_SCHEMA_VERSION, "id", task.id, "Task"));
|
|
174
314
|
this.#mutate((state) => {
|
|
175
|
-
|
|
176
|
-
|
|
315
|
+
for (const binding of stored.projectBindings) {
|
|
316
|
+
if (state.projects[binding.projectId] === undefined) {
|
|
317
|
+
throw new StorageRecordError(`Task Project not found: ${binding.projectId}`);
|
|
318
|
+
}
|
|
177
319
|
}
|
|
178
320
|
const aggregate = state.tasks[stored.id] ?? emptyStoredTask(stored);
|
|
179
321
|
aggregate.task = stored;
|
|
@@ -182,6 +324,9 @@ export class FileTaskStore {
|
|
|
182
324
|
}
|
|
183
325
|
listTasks() { return values(this.#state().tasks, (aggregate) => aggregate.task.id).map((entry) => clone(entry.task)); }
|
|
184
326
|
getTask(id) { return optional(this.#state().tasks[id]?.task); }
|
|
327
|
+
getReviewConfig() {
|
|
328
|
+
return optional(this.#state().config.review);
|
|
329
|
+
}
|
|
185
330
|
getTaskBrief(taskId) {
|
|
186
331
|
return optional(this.#state().tasks[taskId]?.brief ?? undefined);
|
|
187
332
|
}
|
|
@@ -194,9 +339,92 @@ export class FileTaskStore {
|
|
|
194
339
|
this.#requireTaskForWrite(taskId);
|
|
195
340
|
this.#mutate((state) => { state.tasks[taskId].brief = null; });
|
|
196
341
|
}
|
|
342
|
+
nextChangeSetId(taskId) {
|
|
343
|
+
return this.#nextTaskRecordId(taskId, "changeSet");
|
|
344
|
+
}
|
|
345
|
+
saveChangeSet(taskId, changeSet) {
|
|
346
|
+
const stored = identifiedChangeSet(changeSet, changeSet.id);
|
|
347
|
+
validateChangeSet(stored);
|
|
348
|
+
if (stored.taskId !== taskId) {
|
|
349
|
+
throw new StorageRecordError(`ChangeSet belongs to another Task: ${stored.taskId}.`);
|
|
350
|
+
}
|
|
351
|
+
const aggregate = this.#requireTaskForWrite(taskId);
|
|
352
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === stored.headCommit);
|
|
353
|
+
if (evidenceRound !== undefined) {
|
|
354
|
+
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${stored.headCommit} cannot become a ChangeSet.`);
|
|
355
|
+
}
|
|
356
|
+
if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
|
|
357
|
+
throw new StorageRecordError(`ChangeSet Project does not match Task: ${stored.id}.`);
|
|
358
|
+
}
|
|
359
|
+
if (aggregate.workItems[stored.workItemId] === undefined) {
|
|
360
|
+
throw new StorageRecordError(`ChangeSet Work Item not found: ${stored.workItemId}.`);
|
|
361
|
+
}
|
|
362
|
+
const existing = aggregate.changeSets[stored.id];
|
|
363
|
+
if (existing !== undefined && !isDeepStrictEqual(existing, stored)) {
|
|
364
|
+
throw new StorageRecordError(`ChangeSet is immutable: ${stored.id}.`);
|
|
365
|
+
}
|
|
366
|
+
this.#mutate((state) => {
|
|
367
|
+
const task = state.tasks[taskId];
|
|
368
|
+
observeTaskRecordId(task, "changeSet", stored.id);
|
|
369
|
+
task.changeSets[stored.id] = stored;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
listChangeSets(taskId) {
|
|
373
|
+
return values(this.#requireTask(taskId).changeSets, "id");
|
|
374
|
+
}
|
|
375
|
+
getChangeSet(taskId, changeSetId) {
|
|
376
|
+
return optional(this.#state().tasks[taskId]?.changeSets[changeSetId]);
|
|
377
|
+
}
|
|
378
|
+
nextIntegrationAttemptId(taskId) {
|
|
379
|
+
return this.#nextTaskRecordId(taskId, "integrationAttempt");
|
|
380
|
+
}
|
|
381
|
+
saveIntegrationAttempt(taskId, attempt) {
|
|
382
|
+
const stored = identified(attempt, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, "id", attempt.id, "Integration Attempt");
|
|
383
|
+
validateIntegrationAttempt(stored);
|
|
384
|
+
if (stored.taskId !== taskId) {
|
|
385
|
+
throw new StorageRecordError(`Integration Attempt belongs to another Task: ${stored.taskId}.`);
|
|
386
|
+
}
|
|
387
|
+
const aggregate = this.#requireTaskForWrite(taskId);
|
|
388
|
+
if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
|
|
389
|
+
throw new StorageRecordError(`Integration Project does not match Task: ${stored.id}.`);
|
|
390
|
+
}
|
|
391
|
+
for (const changeSetId of stored.changeSetIds) {
|
|
392
|
+
const changeSet = aggregate.changeSets[changeSetId];
|
|
393
|
+
if (changeSet === undefined) {
|
|
394
|
+
throw new StorageRecordError(`Integration ChangeSet not found: ${changeSetId}.`);
|
|
395
|
+
}
|
|
396
|
+
if (changeSet.projectId !== stored.projectId) {
|
|
397
|
+
throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${changeSetId}.`);
|
|
398
|
+
}
|
|
399
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
|
|
400
|
+
if (evidenceRound !== undefined) {
|
|
401
|
+
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const existing = aggregate.integrationAttempts[stored.id];
|
|
405
|
+
if (existing !== undefined) {
|
|
406
|
+
if (Date.parse(stored.updatedAt) < Date.parse(existing.updatedAt)) {
|
|
407
|
+
throw new StorageRecordError(`Integration Attempt updatedAt cannot move backwards: ${stored.id}.`);
|
|
408
|
+
}
|
|
409
|
+
if (!validIntegrationTransition(existing, stored)) {
|
|
410
|
+
throw new StorageRecordError(`Integration Attempt transition is invalid: ${stored.id}.`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
this.#mutate((state) => {
|
|
414
|
+
const task = state.tasks[taskId];
|
|
415
|
+
observeTaskRecordId(task, "integrationAttempt", stored.id);
|
|
416
|
+
task.integrationAttempts[stored.id] = stored;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
listIntegrationAttempts(taskId) {
|
|
420
|
+
return values(this.#requireTask(taskId).integrationAttempts, "id");
|
|
421
|
+
}
|
|
422
|
+
getIntegrationAttempt(taskId, integrationId) {
|
|
423
|
+
return optional(this.#state().tasks[taskId]?.integrationAttempts[integrationId]);
|
|
424
|
+
}
|
|
197
425
|
saveRole(taskId, role) {
|
|
198
426
|
const aggregate = this.#requireTaskForWrite(taskId);
|
|
199
|
-
const stored = identified(role,
|
|
427
|
+
const stored = identified(role, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", role.name, "Task Role");
|
|
200
428
|
if (stored.taskId !== taskId)
|
|
201
429
|
throw new StorageRecordError(`Task Role belongs to another Task: ${stored.taskId}`);
|
|
202
430
|
validateTaskRole(stored);
|
|
@@ -208,7 +436,7 @@ export class FileTaskStore {
|
|
|
208
436
|
listRoles(taskId) { return values(this.#requireTask(taskId).roles, "name"); }
|
|
209
437
|
getRole(taskId, name) { return optional(this.#state().tasks[taskId]?.roles[name]); }
|
|
210
438
|
saveTaskRoleWithSessionSet(role, sessions) {
|
|
211
|
-
const storedRole = identified(role,
|
|
439
|
+
const storedRole = identified(role, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", role.name, "Task Role");
|
|
212
440
|
validateTaskRole(storedRole);
|
|
213
441
|
const storedSessions = taskSessions(sessions);
|
|
214
442
|
assertSessionsMatchRole(storedSessions, storedRole);
|
|
@@ -223,40 +451,91 @@ export class FileTaskStore {
|
|
|
223
451
|
removeTaskRole(taskId, name) {
|
|
224
452
|
return this.transaction(() => {
|
|
225
453
|
const aggregate = this.#requireTask(taskId);
|
|
226
|
-
if (aggregate.roleWorkspaces[name] !== undefined) {
|
|
227
|
-
throw new StorageRecordError(`Task Role workspace must be cleaned before removing the Role: ${taskId}/${name}`);
|
|
228
|
-
}
|
|
229
454
|
const removed = this.#remove(() => aggregate.roles, name);
|
|
230
|
-
this.#mutate(() => {
|
|
455
|
+
this.#mutate((state) => {
|
|
456
|
+
delete aggregate.roleSessionSets[name];
|
|
457
|
+
delete aggregate.activeRuns[name];
|
|
458
|
+
delete state.mailboxes[mailboxTargetKey({ kind: "role", taskId, roleName: name })];
|
|
459
|
+
});
|
|
231
460
|
return removed;
|
|
232
461
|
});
|
|
233
462
|
}
|
|
234
|
-
|
|
463
|
+
saveManagedWorkspace(workspace) {
|
|
464
|
+
const stored = versioned(workspace, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, "Managed workspace");
|
|
465
|
+
validateManagedWorkspace(stored);
|
|
466
|
+
const taskId = stored.owner.taskId;
|
|
235
467
|
const aggregate = this.#requireTaskForWrite(taskId);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
throw new StorageRecordError(`RoleWorkspace belongs to another Task: ${stored.taskId}`);
|
|
468
|
+
if (stored.owner.type === "work-item"
|
|
469
|
+
&& aggregate.workItems[stored.owner.workItemId] === undefined) {
|
|
470
|
+
throw new StorageRecordError(`Managed workspace WorkItem not found: ${taskId}/${stored.owner.workItemId}.`);
|
|
240
471
|
}
|
|
241
|
-
if (
|
|
242
|
-
|
|
472
|
+
if (stored.owner.type === "review-round"
|
|
473
|
+
&& aggregate.reviewRounds[stored.owner.reviewRoundId] === undefined) {
|
|
474
|
+
throw new StorageRecordError(`Managed workspace ReviewRound not found: ${taskId}/${stored.owner.reviewRoundId}.`);
|
|
243
475
|
}
|
|
244
|
-
if (
|
|
245
|
-
|
|
476
|
+
if (stored.owner.type === "integration-attempt"
|
|
477
|
+
&& aggregate.integrationAttempts[stored.owner.integrationAttemptId] === undefined) {
|
|
478
|
+
throw new StorageRecordError(`Managed workspace Integration Attempt not found: ${taskId}/${stored.owner.integrationAttemptId}.`);
|
|
479
|
+
}
|
|
480
|
+
if (stored.owner.type === "execution-lane") {
|
|
481
|
+
const groupOwner = stored.owner;
|
|
482
|
+
if (groupOwner.purpose === "execution") {
|
|
483
|
+
const item = groupOwner.workItemId === undefined
|
|
484
|
+
? undefined
|
|
485
|
+
: aggregate.workItems[groupOwner.workItemId];
|
|
486
|
+
if (item === undefined
|
|
487
|
+
|| workItemExecutionGroupById(item, groupOwner.executionGroupId) === undefined
|
|
488
|
+
|| !workItemExecutionGroupById(item, groupOwner.executionGroupId).lanes.some(({ id }) => id === groupOwner.executionLaneId)) {
|
|
489
|
+
throw new StorageRecordError(`Managed execution Lane WorkItem lineage is invalid: ${taskId}/${groupOwner.executionGroupId}/${groupOwner.executionLaneId}.`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const round = groupOwner.reviewRoundId === undefined
|
|
494
|
+
? undefined
|
|
495
|
+
: aggregate.reviewRounds[groupOwner.reviewRoundId];
|
|
496
|
+
if (round === undefined || round.executionGroup?.id !== groupOwner.executionGroupId
|
|
497
|
+
|| !round.executionGroup.lanes.some(({ id }) => id === groupOwner.executionLaneId)) {
|
|
498
|
+
throw new StorageRecordError(`Managed review Lane ReviewRound lineage is invalid: ${taskId}/${groupOwner.executionGroupId}/${groupOwner.executionLaneId}.`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
assertManagedWorkspaceReferences(aggregate, stored, "Managed workspace");
|
|
503
|
+
const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
|
|
504
|
+
if (stored.entries.some(({ projectId }) => !boundProjects.has(projectId))) {
|
|
505
|
+
throw new StorageRecordError(`Managed workspace Project does not match Task: ${taskId}.`);
|
|
246
506
|
}
|
|
247
507
|
this.#mutate((state) => {
|
|
248
|
-
state.tasks[taskId].
|
|
508
|
+
state.tasks[taskId].managedWorkspaces[managedWorkspaceKey(stored.owner)] = stored;
|
|
249
509
|
});
|
|
250
510
|
}
|
|
251
|
-
|
|
252
|
-
return values(this.#requireTask(taskId).
|
|
511
|
+
listManagedWorkspaces(taskId) {
|
|
512
|
+
return values(this.#requireTask(taskId).managedWorkspaces, (workspace) => managedWorkspaceKey(workspace.owner));
|
|
253
513
|
}
|
|
254
|
-
|
|
255
|
-
return
|
|
514
|
+
listManagedWorkspace(taskId) {
|
|
515
|
+
return this.listManagedWorkspaces(taskId);
|
|
256
516
|
}
|
|
257
|
-
|
|
258
|
-
this
|
|
259
|
-
|
|
517
|
+
getTaskWorkspace(taskId) {
|
|
518
|
+
return this.getManagedWorkspace({ type: "task", taskId });
|
|
519
|
+
}
|
|
520
|
+
getWorkItemWorkspace(taskId, workItemId) {
|
|
521
|
+
return this.getManagedWorkspace({ type: "work-item", taskId, workItemId });
|
|
522
|
+
}
|
|
523
|
+
getReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
524
|
+
return this.getManagedWorkspace({ type: "review-round", taskId, reviewRoundId });
|
|
525
|
+
}
|
|
526
|
+
getIntegrationWorkspace(taskId, integrationAttemptId) {
|
|
527
|
+
return this.getManagedWorkspace({
|
|
528
|
+
type: "integration-attempt",
|
|
529
|
+
taskId,
|
|
530
|
+
integrationAttemptId
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
getManagedWorkspace(owner) {
|
|
534
|
+
return optional(this.#state().tasks[owner.taskId]?.managedWorkspaces[managedWorkspaceKey(owner)]);
|
|
535
|
+
}
|
|
536
|
+
removeManagedWorkspace(owner) {
|
|
537
|
+
this.#requireTaskForWrite(owner.taskId);
|
|
538
|
+
return this.#remove((state) => state.tasks[owner.taskId].managedWorkspaces, managedWorkspaceKey(owner));
|
|
260
539
|
}
|
|
261
540
|
getRoleSessionSet(taskId, roleName) {
|
|
262
541
|
return optional(this.#state().tasks[taskId]?.roleSessionSets[roleName]);
|
|
@@ -282,26 +561,131 @@ export class FileTaskStore {
|
|
|
282
561
|
const set = this.getRoleSessionSet(taskId, roleName);
|
|
283
562
|
return set === null ? null : optional(set.sessions[set.activeAgentId]);
|
|
284
563
|
}
|
|
285
|
-
nextWorkItemId(
|
|
564
|
+
nextWorkItemId(taskId) {
|
|
565
|
+
return this.#nextTaskRecordId(taskId, "workItem");
|
|
566
|
+
}
|
|
286
567
|
getWorkItem(taskId, id) { return optional(this.#state().tasks[taskId]?.workItems[id]); }
|
|
287
|
-
findWorkItem(id) { return findUnique(this.#state(), "workItems", id, "Work item"); }
|
|
288
568
|
listWorkItems(taskId) { return values(this.#requireTask(taskId).workItems, "id"); }
|
|
289
569
|
saveWorkItem(taskId, item) {
|
|
290
|
-
const stored = identified(item,
|
|
570
|
+
const stored = identified(item, CURRENT_WORK_ITEM_SCHEMA_VERSION, "id", item.id, "Work item");
|
|
291
571
|
if (stored.taskId !== taskId)
|
|
292
572
|
throw new StorageRecordError(`Work item belongs to another Task: ${stored.taskId}`);
|
|
293
|
-
|
|
294
|
-
this.#
|
|
573
|
+
validateWorkItem(stored);
|
|
574
|
+
const aggregate = this.#requireTaskForWrite(taskId);
|
|
575
|
+
const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
|
|
576
|
+
if (stored.writeProjectIds.some((projectId) => !boundProjects.has(projectId))) {
|
|
577
|
+
throw new StorageRecordError(`Work Item writable Project does not belong to Task: ${stored.id}.`);
|
|
578
|
+
}
|
|
579
|
+
const writableProjects = new Set(stored.writeProjectIds);
|
|
580
|
+
if (stored.baseRefs?.some(({ projectId }) => !boundProjects.has(projectId))) {
|
|
581
|
+
throw new StorageRecordError(`Work Item base-ref Project does not belong to Task: ${stored.id}.`);
|
|
582
|
+
}
|
|
583
|
+
if (stored.baseRefs?.some(({ projectId }) => !writableProjects.has(projectId))) {
|
|
584
|
+
throw new StorageRecordError(`Work Item base-ref Project must be writable: ${stored.id}.`);
|
|
585
|
+
}
|
|
586
|
+
for (const dependencyId of stored.dependsOn) {
|
|
587
|
+
const dependency = this.getWorkItem(taskId, dependencyId);
|
|
588
|
+
if (dependency === null) {
|
|
589
|
+
throw new StorageRecordError(`Work Item dependency not found: ${dependencyId}.`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
assertAcyclicWorkItems({
|
|
593
|
+
...aggregate.workItems,
|
|
594
|
+
[stored.id]: stored
|
|
595
|
+
});
|
|
596
|
+
for (const candidate of stored.candidates) {
|
|
597
|
+
assertWorkItemCandidateReferences(aggregate, stored, candidate, "Work Item candidate");
|
|
598
|
+
}
|
|
599
|
+
const existing = this.getWorkItem(taskId, stored.id);
|
|
600
|
+
if (existing !== null && !validWorkItemTransition(existing, stored)) {
|
|
601
|
+
throw new StorageRecordError(`Work Item transition is invalid: ${stored.id}.`);
|
|
602
|
+
}
|
|
603
|
+
this.#mutate((state) => {
|
|
604
|
+
const task = state.tasks[taskId];
|
|
605
|
+
observeTaskRecordId(task, "workItem", stored.id);
|
|
606
|
+
task.workItems[stored.id] = stored;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
nextAgentRunId(taskId) {
|
|
610
|
+
return this.#nextTaskRecordId(taskId, "agentRun");
|
|
611
|
+
}
|
|
612
|
+
peekNextAgentRunId(taskId) {
|
|
613
|
+
return this.#peekTaskRecordId(taskId, "agentRun");
|
|
295
614
|
}
|
|
296
|
-
nextAgentRunId(_taskId) { return this.#nextGlobalId("agent-run", (state) => allKeys(state, "agentRuns")); }
|
|
297
615
|
getAgentRun(taskId, id) { return optional(this.#state().tasks[taskId]?.agentRuns[id]); }
|
|
298
|
-
findAgentRun(id) { return findUnique(this.#state(), "agentRuns", id, "Agent run"); }
|
|
299
616
|
listAgentRuns(taskId) { return values(this.#requireTask(taskId).agentRuns, "id"); }
|
|
300
617
|
saveAgentRun(run) {
|
|
301
|
-
const stored = identified(run,
|
|
618
|
+
const stored = identified(run, CURRENT_AGENT_RUN_SCHEMA_VERSION, "id", run.id, "Agent run");
|
|
302
619
|
validateAgentRun(stored);
|
|
303
|
-
this.#requireTaskForWrite(stored.taskId);
|
|
304
|
-
|
|
620
|
+
const aggregate = this.#requireTaskForWrite(stored.taskId);
|
|
621
|
+
if (stored.purpose === "review"
|
|
622
|
+
&& aggregate.task.projectBindings.length > 0
|
|
623
|
+
&& stored.workspace === undefined) {
|
|
624
|
+
throw new StorageRecordError(`A Project-backed review Agent run requires its ReviewRound workspace: ${stored.id}.`);
|
|
625
|
+
}
|
|
626
|
+
if (stored.reviewRoundId !== undefined) {
|
|
627
|
+
const round = aggregate.reviewRounds[stored.reviewRoundId];
|
|
628
|
+
if (round === undefined) {
|
|
629
|
+
throw new StorageRecordError(`Agent run ReviewRound not found: ${stored.reviewRoundId}.`);
|
|
630
|
+
}
|
|
631
|
+
const laneRole = round.executionGroup?.lanes.find(({ id }) => id === stored.executionLaneId)?.roleName;
|
|
632
|
+
if (round.workItemId !== stored.workItemId
|
|
633
|
+
|| (round.reviewerRoleName !== stored.roleName && laneRole !== stored.roleName)) {
|
|
634
|
+
throw new StorageRecordError(`Agent run does not match ReviewRound: ${stored.id}.`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
assertAgentRunExecutionReferences(aggregate, stored);
|
|
638
|
+
this.#mutate((state) => {
|
|
639
|
+
const task = state.tasks[stored.taskId];
|
|
640
|
+
observeTaskRecordId(task, "agentRun", stored.id);
|
|
641
|
+
task.agentRuns[stored.id] = stored;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
nextReviewRoundId(taskId) {
|
|
645
|
+
return this.#nextTaskRecordId(taskId, "reviewRound");
|
|
646
|
+
}
|
|
647
|
+
getReviewRound(taskId, reviewRoundId) {
|
|
648
|
+
return optional(this.#state().tasks[taskId]?.reviewRounds[reviewRoundId]);
|
|
649
|
+
}
|
|
650
|
+
listReviewRounds(taskId) {
|
|
651
|
+
return values(this.#requireTask(taskId).reviewRounds, "id");
|
|
652
|
+
}
|
|
653
|
+
saveReviewRound(taskId, round) {
|
|
654
|
+
const stored = identified(round, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, "id", round.id, "ReviewRound");
|
|
655
|
+
validateReviewRound(stored);
|
|
656
|
+
if (stored.taskId !== taskId) {
|
|
657
|
+
throw new StorageRecordError(`ReviewRound belongs to another Task: ${stored.taskId}.`);
|
|
658
|
+
}
|
|
659
|
+
const aggregate = this.#requireTaskForWrite(taskId);
|
|
660
|
+
const item = aggregate.workItems[stored.workItemId];
|
|
661
|
+
if (item === undefined) {
|
|
662
|
+
throw new StorageRecordError(`ReviewRound Work Item not found: ${stored.workItemId}.`);
|
|
663
|
+
}
|
|
664
|
+
const candidate = item.candidates.find(({ id }) => id === stored.candidateId);
|
|
665
|
+
if (candidate === undefined) {
|
|
666
|
+
throw new StorageRecordError(`ReviewRound Candidate not found: ${stored.candidateId}.`);
|
|
667
|
+
}
|
|
668
|
+
assertWorkItemCandidateReferences(aggregate, item, candidate, `ReviewRound candidate ${stored.id}`);
|
|
669
|
+
if ((stored.scope ?? "work-item") === "task"
|
|
670
|
+
&& !sameTaskFinalReviewContract(stored.taskFinalReviewContract, candidate.taskFinalReviewContract)) {
|
|
671
|
+
throw new StorageRecordError(`Task ReviewRound contract does not match its Candidate: ${stored.id}.`);
|
|
672
|
+
}
|
|
673
|
+
if (stored.reviewerRunId !== undefined) {
|
|
674
|
+
const reviewerRun = aggregate.agentRuns[stored.reviewerRunId];
|
|
675
|
+
if (reviewerRun !== undefined
|
|
676
|
+
&& (reviewerRun.reviewRoundId !== stored.id || reviewerRun.purpose !== "review")) {
|
|
677
|
+
throw new StorageRecordError(`ReviewRound Reviewer Run is invalid: ${stored.reviewerRunId}.`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const existing = aggregate.reviewRounds[stored.id];
|
|
681
|
+
if (existing !== undefined && !validReviewRoundTransition(existing, stored)) {
|
|
682
|
+
throw new StorageRecordError(`ReviewRound transition is invalid: ${stored.id}.`);
|
|
683
|
+
}
|
|
684
|
+
this.#mutate((state) => {
|
|
685
|
+
const task = state.tasks[taskId];
|
|
686
|
+
observeTaskRecordId(task, "reviewRound", stored.id);
|
|
687
|
+
task.reviewRounds[stored.id] = stored;
|
|
688
|
+
});
|
|
305
689
|
}
|
|
306
690
|
getActiveAgentRun(taskId, roleName) {
|
|
307
691
|
const aggregate = this.#state().tasks[taskId];
|
|
@@ -314,6 +698,10 @@ export class FileTaskStore {
|
|
|
314
698
|
return clone(run);
|
|
315
699
|
}
|
|
316
700
|
saveActiveAgentRun(run) {
|
|
701
|
+
if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
|
|
702
|
+
this.saveActiveExecutionLaneRun(run);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
317
705
|
if (run.status !== "active")
|
|
318
706
|
throw new StorageRecordError(`Active Agent run must have active status: ${run.id}`);
|
|
319
707
|
this.transaction((store) => {
|
|
@@ -321,36 +709,135 @@ export class FileTaskStore {
|
|
|
321
709
|
if (current !== null && current.id !== run.id) {
|
|
322
710
|
throw new StorageRecordError(`Role already has an active Agent run: ${run.taskId}/${run.roleName}`);
|
|
323
711
|
}
|
|
712
|
+
const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
|
|
713
|
+
if (sessions !== null && sessions.inFlight !== null && sessions.inFlight.runId !== run.id) {
|
|
714
|
+
throw new StorageRecordError(`Role still has an in-flight Turn: ${run.taskId}/${run.roleName}/${sessions.inFlight.runId}`);
|
|
715
|
+
}
|
|
324
716
|
store.saveAgentRun(run);
|
|
325
717
|
this.#mutate((state) => {
|
|
326
|
-
state.tasks[run.taskId].activeRuns[run.roleName] = {
|
|
718
|
+
state.tasks[run.taskId].activeRuns[run.roleName] = {
|
|
719
|
+
schemaVersion: CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION,
|
|
720
|
+
runId: run.id
|
|
721
|
+
};
|
|
327
722
|
});
|
|
328
723
|
});
|
|
329
724
|
}
|
|
330
725
|
clearActiveAgentRun(taskId, roleName) {
|
|
331
|
-
this.#mutate((state) => {
|
|
332
|
-
|
|
726
|
+
this.#mutate((state) => {
|
|
727
|
+
const task = state.tasks[taskId];
|
|
728
|
+
if (task === undefined)
|
|
729
|
+
return;
|
|
730
|
+
const rolePointer = task.activeRuns[roleName];
|
|
731
|
+
delete task.activeRuns[roleName];
|
|
732
|
+
// Older Controller paths only know the Role key. When that key points
|
|
733
|
+
// at a lane-backed Run, remove the matching lane pointer too; preserve
|
|
734
|
+
// every other lane for the same Role in a multi-lane group.
|
|
735
|
+
if (rolePointer === undefined)
|
|
736
|
+
return;
|
|
737
|
+
for (const [key, pointer] of Object.entries(task.activeRuns)) {
|
|
738
|
+
if (executionLaneActiveRunKeyParts(key) !== null && pointer.runId === rolePointer.runId) {
|
|
739
|
+
delete task.activeRuns[key];
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
getActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
|
|
745
|
+
const aggregate = this.#state().tasks[taskId];
|
|
746
|
+
const key = executionLaneActiveRunKey(executionGroupId, executionLaneId);
|
|
747
|
+
const pointer = aggregate?.activeRuns[key];
|
|
748
|
+
if (aggregate === undefined || pointer === undefined)
|
|
749
|
+
return null;
|
|
750
|
+
const run = aggregate.agentRuns[pointer.runId];
|
|
751
|
+
if (run === undefined) {
|
|
752
|
+
throw new StorageRecordError(`Active Agent run pointer is dangling: ${taskId}/${key}`);
|
|
753
|
+
}
|
|
754
|
+
if (run.executionGroupId !== executionGroupId
|
|
755
|
+
|| run.executionLaneId !== executionLaneId
|
|
756
|
+
|| run.status !== "active") {
|
|
757
|
+
throw new StorageRecordError(`Active Agent run pointer is invalid: ${taskId}/${key}`);
|
|
758
|
+
}
|
|
759
|
+
return clone(run);
|
|
760
|
+
}
|
|
761
|
+
saveActiveExecutionLaneRun(run) {
|
|
762
|
+
if (run.status !== "active") {
|
|
763
|
+
throw new StorageRecordError(`Active Agent run must have active status: ${run.id}`);
|
|
764
|
+
}
|
|
765
|
+
if (run.executionGroupId === undefined || run.executionLaneId === undefined) {
|
|
766
|
+
throw new StorageRecordError(`Lane active Agent run requires execution lineage: ${run.id}`);
|
|
767
|
+
}
|
|
768
|
+
this.transaction((store) => {
|
|
769
|
+
const key = executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId);
|
|
770
|
+
const current = store.getActiveExecutionLaneRun(run.taskId, run.executionGroupId, run.executionLaneId);
|
|
771
|
+
if (current !== null && current.id !== run.id) {
|
|
772
|
+
throw new StorageRecordError(`Execution Lane already has an active Agent run: ${run.taskId}/${key}`);
|
|
773
|
+
}
|
|
774
|
+
const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
|
|
775
|
+
if (sessions !== null && sessions.inFlight !== null && sessions.inFlight.runId !== run.id) {
|
|
776
|
+
throw new StorageRecordError(`Role still has an in-flight Turn: ${run.taskId}/${run.roleName}/${sessions.inFlight.runId}`);
|
|
777
|
+
}
|
|
778
|
+
store.saveAgentRun(run);
|
|
779
|
+
this.#mutate((state) => {
|
|
780
|
+
const task = state.tasks[run.taskId];
|
|
781
|
+
task.activeRuns[key] = {
|
|
782
|
+
schemaVersion: CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION,
|
|
783
|
+
runId: run.id
|
|
784
|
+
};
|
|
785
|
+
// Preserve the legacy role pointer for the single-lane delivery path.
|
|
786
|
+
// A second lane for the same Role keeps the first pointer unchanged;
|
|
787
|
+
// lane-aware readers use the exact key above.
|
|
788
|
+
if (task.activeRuns[run.roleName] === undefined) {
|
|
789
|
+
task.activeRuns[run.roleName] = {
|
|
790
|
+
schemaVersion: CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION,
|
|
791
|
+
runId: run.id
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
clearActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
|
|
798
|
+
this.#mutate((state) => {
|
|
799
|
+
const task = state.tasks[taskId];
|
|
800
|
+
if (task === undefined)
|
|
801
|
+
return;
|
|
802
|
+
const key = executionLaneActiveRunKey(executionGroupId, executionLaneId);
|
|
803
|
+
const pointer = task.activeRuns[key];
|
|
804
|
+
delete task.activeRuns[key];
|
|
805
|
+
const runId = pointer?.runId;
|
|
806
|
+
for (const [roleName, rolePointer] of Object.entries(task.activeRuns)) {
|
|
807
|
+
if (executionLaneActiveRunKeyParts(roleName) !== null)
|
|
808
|
+
continue;
|
|
809
|
+
const roleRun = task.agentRuns[rolePointer.runId];
|
|
810
|
+
const matches = runId !== undefined
|
|
811
|
+
? rolePointer.runId === runId
|
|
812
|
+
: roleRun?.executionGroupId === executionGroupId
|
|
813
|
+
&& roleRun.executionLaneId === executionLaneId;
|
|
814
|
+
if (matches) {
|
|
815
|
+
delete task.activeRuns[roleName];
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
nextMessageId(taskId) {
|
|
821
|
+
return this.#nextTaskRecordId(taskId, "message");
|
|
333
822
|
}
|
|
334
|
-
nextMessageId(_taskId) { return this.#nextGlobalId("message", (state) => allKeys(state, "messages")); }
|
|
335
823
|
saveMessage(taskId, message) {
|
|
336
824
|
validateTaskMessage(message);
|
|
825
|
+
if (message.taskId !== taskId) {
|
|
826
|
+
throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
|
|
827
|
+
}
|
|
337
828
|
this.#saveTaskRecord(taskId, "messages", message, "Message");
|
|
338
829
|
}
|
|
339
830
|
listMessages(taskId) { return values(this.#requireTask(taskId).messages, "id"); }
|
|
340
|
-
nextInputRequestId(
|
|
341
|
-
return this.#
|
|
831
|
+
nextInputRequestId(taskId) {
|
|
832
|
+
return this.#nextTaskRecordId(taskId, "inputRequest");
|
|
342
833
|
}
|
|
343
834
|
saveInputRequest(taskId, request) {
|
|
344
|
-
const stored = validateInputRequest(request);
|
|
835
|
+
const stored = validateInputRequest(versioned(request, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, "Input request"));
|
|
345
836
|
if (stored.taskId !== taskId) {
|
|
346
837
|
throw new StorageRecordError(`Input request belongs to another Task: ${stored.taskId}`);
|
|
347
838
|
}
|
|
348
839
|
this.#mutate((state) => {
|
|
349
840
|
const aggregate = requireTaskFromState(state, taskId);
|
|
350
|
-
const owner = taskRecordOwner(state, "inputRequests", stored.id);
|
|
351
|
-
if (owner !== null && owner !== taskId) {
|
|
352
|
-
throw new StorageRecordError(`Input request already exists in another Task: ${stored.id}`);
|
|
353
|
-
}
|
|
354
841
|
const existing = aggregate.inputRequests[stored.id];
|
|
355
842
|
if (existing === undefined && stored.status !== "open") {
|
|
356
843
|
throw new StorageRecordError(`Input request must start open: ${stored.id}`);
|
|
@@ -358,25 +845,25 @@ export class FileTaskStore {
|
|
|
358
845
|
if (existing !== undefined && !isValidInputRequestTransition(existing, stored)) {
|
|
359
846
|
throw new StorageRecordError(`Input request cannot be overwritten: ${stored.id}`);
|
|
360
847
|
}
|
|
848
|
+
observeTaskRecordId(aggregate, "inputRequest", stored.id);
|
|
361
849
|
aggregate.inputRequests[stored.id] = stored;
|
|
362
850
|
});
|
|
363
851
|
}
|
|
364
852
|
getInputRequest(taskId, requestId) {
|
|
365
853
|
return optional(this.#state().tasks[taskId]?.inputRequests[requestId]);
|
|
366
854
|
}
|
|
367
|
-
findInputRequest(requestId) {
|
|
368
|
-
return findUnique(this.#state(), "inputRequests", requestId, "Input request");
|
|
369
|
-
}
|
|
370
855
|
listInputRequests(taskId) {
|
|
371
856
|
return values(this.#requireTask(taskId).inputRequests, "id");
|
|
372
857
|
}
|
|
373
858
|
listAllInputRequests() {
|
|
374
859
|
return Object.values(this.#state().tasks)
|
|
375
860
|
.flatMap((aggregate) => Object.values(aggregate.inputRequests).map(clone))
|
|
376
|
-
.sort((left, right) =>
|
|
861
|
+
.sort((left, right) => (left.createdAt.localeCompare(right.createdAt)
|
|
862
|
+
|| left.taskId.localeCompare(right.taskId)
|
|
863
|
+
|| numericCompare(left.id, right.id)));
|
|
377
864
|
}
|
|
378
|
-
nextDecisionId(
|
|
379
|
-
return this.#
|
|
865
|
+
nextDecisionId(taskId) {
|
|
866
|
+
return this.#nextTaskRecordId(taskId, "decision");
|
|
380
867
|
}
|
|
381
868
|
saveDecision(taskId, decision) {
|
|
382
869
|
const stored = storedDecision(decision);
|
|
@@ -385,10 +872,6 @@ export class FileTaskStore {
|
|
|
385
872
|
}
|
|
386
873
|
this.#mutate((state) => {
|
|
387
874
|
const aggregate = requireTaskFromState(state, taskId);
|
|
388
|
-
const owner = taskRecordOwner(state, "decisions", stored.id);
|
|
389
|
-
if (owner !== null && owner !== taskId) {
|
|
390
|
-
throw new StorageRecordError(`Decision already exists in another Task: ${stored.id}`);
|
|
391
|
-
}
|
|
392
875
|
const existing = aggregate.decisions[stored.id];
|
|
393
876
|
if (existing === undefined && stored.status !== "active") {
|
|
394
877
|
throw new StorageRecordError(`Decision must start active: ${stored.id}`);
|
|
@@ -396,6 +879,7 @@ export class FileTaskStore {
|
|
|
396
879
|
if (existing !== undefined && !isValidDecisionSupersession(existing, stored)) {
|
|
397
880
|
throw new StorageRecordError(`Decision cannot be overwritten: ${stored.id}`);
|
|
398
881
|
}
|
|
882
|
+
observeTaskRecordId(aggregate, "decision", stored.id);
|
|
399
883
|
aggregate.decisions[stored.id] = stored;
|
|
400
884
|
});
|
|
401
885
|
}
|
|
@@ -405,8 +889,8 @@ export class FileTaskStore {
|
|
|
405
889
|
getDecision(taskId, decisionId) {
|
|
406
890
|
return this.#requireTask(taskId).decisions[decisionId] ?? null;
|
|
407
891
|
}
|
|
408
|
-
nextMilestoneId(
|
|
409
|
-
return this.#
|
|
892
|
+
nextMilestoneId(taskId) {
|
|
893
|
+
return this.#nextTaskRecordId(taskId, "milestone");
|
|
410
894
|
}
|
|
411
895
|
saveMilestone(taskId, milestone) {
|
|
412
896
|
const stored = storedMilestone(milestone);
|
|
@@ -415,9 +899,10 @@ export class FileTaskStore {
|
|
|
415
899
|
}
|
|
416
900
|
this.#mutate((state) => {
|
|
417
901
|
const aggregate = requireTaskFromState(state, taskId);
|
|
418
|
-
if (
|
|
419
|
-
throw new StorageRecordError(`Milestone already exists: ${stored.id}`);
|
|
902
|
+
if (aggregate.milestones[stored.id] !== undefined) {
|
|
903
|
+
throw new StorageRecordError(`Milestone already exists: ${taskId}/${stored.id}`);
|
|
420
904
|
}
|
|
905
|
+
observeTaskRecordId(aggregate, "milestone", stored.id);
|
|
421
906
|
aggregate.milestones[stored.id] = stored;
|
|
422
907
|
});
|
|
423
908
|
}
|
|
@@ -427,24 +912,91 @@ export class FileTaskStore {
|
|
|
427
912
|
getMilestone(taskId, milestoneId) {
|
|
428
913
|
return this.#requireTask(taskId).milestones[milestoneId] ?? null;
|
|
429
914
|
}
|
|
430
|
-
nextEventId(
|
|
915
|
+
nextEventId(taskId) {
|
|
916
|
+
return this.#nextTaskRecordId(taskId, "event");
|
|
917
|
+
}
|
|
431
918
|
saveEvent(taskId, event) {
|
|
432
919
|
const stored = storedTaskEvent(event);
|
|
920
|
+
if (stored.taskId !== taskId) {
|
|
921
|
+
throw new StorageRecordError(`Task event belongs to another Task: ${stored.taskId}`);
|
|
922
|
+
}
|
|
433
923
|
this.#mutate((state) => {
|
|
434
924
|
const aggregate = requireTaskFromState(state, taskId);
|
|
435
|
-
if (
|
|
436
|
-
throw new StorageRecordError(`Task event already exists: ${stored.id}`);
|
|
925
|
+
if (aggregate.events[stored.id] !== undefined) {
|
|
926
|
+
throw new StorageRecordError(`Task event already exists: ${taskId}/${stored.id}`);
|
|
437
927
|
}
|
|
928
|
+
observeTaskRecordId(aggregate, "event", stored.id);
|
|
438
929
|
aggregate.events[stored.id] = stored;
|
|
439
930
|
});
|
|
440
931
|
}
|
|
441
932
|
listEvents(taskId) { return values(this.#requireTask(taskId).events, "id"); }
|
|
442
|
-
|
|
933
|
+
getWorkMailbox(target) {
|
|
934
|
+
return optional(this.#state().mailboxes[mailboxTargetKey(target)]);
|
|
935
|
+
}
|
|
936
|
+
listWorkMailboxes() {
|
|
937
|
+
return Object.entries(this.#state().mailboxes)
|
|
938
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
939
|
+
.map(([, mailbox]) => clone(mailbox));
|
|
940
|
+
}
|
|
941
|
+
saveWorkMailbox(value) {
|
|
942
|
+
let mailbox;
|
|
943
|
+
try {
|
|
944
|
+
mailbox = validateWorkMailbox(versioned(value, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, "WorkMailbox"));
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
throw new StorageRecordError(error instanceof Error ? error.message : String(error));
|
|
948
|
+
}
|
|
949
|
+
this.#mutate((state) => {
|
|
950
|
+
validateMailboxReferences(state, mailbox);
|
|
951
|
+
state.mailboxes[mailboxTargetKey(mailbox.target)] = clone(mailbox);
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
removeWorkMailbox(target) {
|
|
955
|
+
return this.#remove((state) => state.mailboxes, mailboxTargetKey(target));
|
|
956
|
+
}
|
|
957
|
+
getPendingWakeup(taskId) {
|
|
958
|
+
return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));
|
|
959
|
+
}
|
|
443
960
|
listPendingWakeups() {
|
|
444
|
-
return
|
|
961
|
+
return this.listWorkMailboxes()
|
|
962
|
+
.flatMap((mailbox) => {
|
|
963
|
+
const wakeup = pendingWakeupProjection(mailbox);
|
|
964
|
+
return wakeup === null ? [] : [wakeup];
|
|
965
|
+
})
|
|
966
|
+
.sort((a, b) => numericCompare(a.taskId, b.taskId));
|
|
967
|
+
}
|
|
968
|
+
savePendingWakeup(value) {
|
|
969
|
+
const wakeup = identified(value, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, "taskId", value.taskId, "Pending wakeup");
|
|
970
|
+
const target = { kind: "role", taskId: wakeup.taskId, roleName: "leader" };
|
|
971
|
+
this.transaction(() => {
|
|
972
|
+
const existing = this.getWorkMailbox(target);
|
|
973
|
+
if (existing !== null && existing.pending !== null
|
|
974
|
+
&& wakeup.requestCount <= existing.pending.requestCount) {
|
|
975
|
+
throw new StorageRecordError(`Pending wakeup is stale: ${wakeup.taskId}`);
|
|
976
|
+
}
|
|
977
|
+
const fromSequence = existing?.pending?.fromSequence ?? existing?.nextSequence ?? 1;
|
|
978
|
+
const toSequence = fromSequence + wakeup.requestCount - 1;
|
|
979
|
+
this.saveWorkMailbox({
|
|
980
|
+
schemaVersion: CURRENT_WORK_MAILBOX_SCHEMA_VERSION,
|
|
981
|
+
target,
|
|
982
|
+
nextSequence: Math.max(existing?.nextSequence ?? 1, toSequence + 1),
|
|
983
|
+
processing: existing?.processing ?? null,
|
|
984
|
+
pending: {
|
|
985
|
+
...existing?.pending,
|
|
986
|
+
fromSequence,
|
|
987
|
+
toSequence,
|
|
988
|
+
reasons: [...wakeup.reasons],
|
|
989
|
+
refs: existing?.pending?.refs ?? [],
|
|
990
|
+
requestCount: wakeup.requestCount,
|
|
991
|
+
firstQueuedAt: wakeup.firstRequestedAt,
|
|
992
|
+
lastQueuedAt: wakeup.lastRequestedAt
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
clearPendingWakeup(taskId) {
|
|
998
|
+
this.removeWorkMailbox({ kind: "role", taskId, roleName: "leader" });
|
|
445
999
|
}
|
|
446
|
-
savePendingWakeup(value) { this.#saveSingleton(value.taskId, "pendingWakeup", value, "Pending wakeup"); }
|
|
447
|
-
clearPendingWakeup(taskId) { this.#clearSingleton(taskId, "pendingWakeup"); }
|
|
448
1000
|
getLeaderFailure(taskId) { return optional(this.#state().tasks[taskId]?.leaderFailure ?? undefined); }
|
|
449
1001
|
saveLeaderFailure(value) { this.#saveSingleton(value.taskId, "leaderFailure", value, "Leader failure"); }
|
|
450
1002
|
clearLeaderFailure(taskId) { this.#clearSingleton(taskId, "leaderFailure"); }
|
|
@@ -452,7 +1004,10 @@ export class FileTaskStore {
|
|
|
452
1004
|
saveOperatorNotification(value) { this.#saveSingleton(value.taskId, "operatorNotification", value, "Operator notification"); }
|
|
453
1005
|
clearOperatorNotification(taskId) { this.#clearSingleton(taskId, "operatorNotification"); }
|
|
454
1006
|
#saveSingleton(taskId, key, value, label) {
|
|
455
|
-
const
|
|
1007
|
+
const schemaVersion = key === "leaderFailure"
|
|
1008
|
+
? CURRENT_LEADER_FAILURE_SCHEMA_VERSION
|
|
1009
|
+
: CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION;
|
|
1010
|
+
const stored = identified(value, schemaVersion, "taskId", taskId, label);
|
|
456
1011
|
this.#requireTaskForWrite(taskId);
|
|
457
1012
|
this.#mutate((state) => { state.tasks[taskId][key] = stored; });
|
|
458
1013
|
}
|
|
@@ -461,12 +1016,13 @@ export class FileTaskStore {
|
|
|
461
1016
|
state.tasks[key][field] = null; });
|
|
462
1017
|
}
|
|
463
1018
|
#saveTaskRecord(taskId, key, value, label) {
|
|
464
|
-
const record = versioned(value,
|
|
1019
|
+
const record = versioned(value, CURRENT_MESSAGE_SCHEMA_VERSION, label);
|
|
465
1020
|
this.#requireTaskForWrite(taskId);
|
|
466
1021
|
this.#mutate((state) => {
|
|
467
|
-
if (
|
|
468
|
-
throw new StorageRecordError(`${label} already exists: ${record.id}`);
|
|
1022
|
+
if (state.tasks[taskId][key][record.id] !== undefined) {
|
|
1023
|
+
throw new StorageRecordError(`${label} already exists: ${taskId}/${record.id}`);
|
|
469
1024
|
}
|
|
1025
|
+
observeTaskRecordId(state.tasks[taskId], "message", record.id);
|
|
470
1026
|
state.tasks[taskId][key][record.id] = clone(value);
|
|
471
1027
|
});
|
|
472
1028
|
}
|
|
@@ -477,7 +1033,7 @@ export class FileTaskStore {
|
|
|
477
1033
|
return aggregate;
|
|
478
1034
|
}
|
|
479
1035
|
#requireTaskForWrite(taskId) { return this.#requireTask(taskId); }
|
|
480
|
-
#state() { return this.#transaction?.state ?? this.#
|
|
1036
|
+
#state() { return this.#transaction?.state ?? this.#readCachedState(); }
|
|
481
1037
|
#mutate(execute) { this.#mutateResult((state) => { execute(state); }); }
|
|
482
1038
|
#mutateResult(execute) {
|
|
483
1039
|
if (this.#transaction !== null) {
|
|
@@ -486,10 +1042,16 @@ export class FileTaskStore {
|
|
|
486
1042
|
return result;
|
|
487
1043
|
}
|
|
488
1044
|
return this.#withWriteLock(() => {
|
|
489
|
-
const state = this.#
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1045
|
+
const state = this.#readCachedState();
|
|
1046
|
+
try {
|
|
1047
|
+
const result = execute(state);
|
|
1048
|
+
this.#commit(state, state.revision);
|
|
1049
|
+
return result;
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
this.#readCache = null;
|
|
1053
|
+
throw error;
|
|
1054
|
+
}
|
|
493
1055
|
});
|
|
494
1056
|
}
|
|
495
1057
|
#remove(select, id) {
|
|
@@ -509,14 +1071,49 @@ export class FileTaskStore {
|
|
|
509
1071
|
}, 0);
|
|
510
1072
|
return `${prefix}-${maximum + 1}`;
|
|
511
1073
|
}
|
|
1074
|
+
#nextTaskRecordId(taskId, kind) {
|
|
1075
|
+
return this.#mutateResult((state) => {
|
|
1076
|
+
const aggregate = requireTaskFromState(state, taskId);
|
|
1077
|
+
const next = nextTaskRecordSequence(aggregate, taskId, kind);
|
|
1078
|
+
aggregate.idHighWaterMarks[kind] = next;
|
|
1079
|
+
return `${TASK_RECORD_ID_PREFIXES[kind]}-${next}`;
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
#peekTaskRecordId(taskId, kind) {
|
|
1083
|
+
const aggregate = this.#requireTask(taskId);
|
|
1084
|
+
return `${TASK_RECORD_ID_PREFIXES[kind]}-${nextTaskRecordSequence(aggregate, taskId, kind)}`;
|
|
1085
|
+
}
|
|
512
1086
|
#readState() {
|
|
513
|
-
|
|
1087
|
+
this.#requireReadableSchema();
|
|
514
1088
|
const path = join(this.rootDir, STORAGE_STATE_FILE);
|
|
515
1089
|
if (!existsSync(path))
|
|
516
1090
|
return emptyState();
|
|
517
|
-
return parseState(readFileSync(path, "utf8"));
|
|
1091
|
+
return this.#parseState(readFileSync(path, "utf8"));
|
|
1092
|
+
}
|
|
1093
|
+
#readCachedState() {
|
|
1094
|
+
this.#requireReadableSchema();
|
|
1095
|
+
const path = join(this.rootDir, STORAGE_STATE_FILE);
|
|
1096
|
+
if (!existsSync(path)) {
|
|
1097
|
+
if (this.#readCache?.fingerprint === "missing")
|
|
1098
|
+
return this.#readCache.state;
|
|
1099
|
+
const state = emptyState();
|
|
1100
|
+
this.#readCache = { fingerprint: "missing", state };
|
|
1101
|
+
return state;
|
|
1102
|
+
}
|
|
1103
|
+
const fingerprint = stateFileFingerprint(path);
|
|
1104
|
+
if (this.#readCache?.fingerprint === fingerprint)
|
|
1105
|
+
return this.#readCache.state;
|
|
1106
|
+
const state = this.#parseState(readFileSync(path, "utf8"));
|
|
1107
|
+
this.#readCache = { fingerprint, state };
|
|
1108
|
+
return state;
|
|
518
1109
|
}
|
|
519
1110
|
#commit(state, expectedRevision) {
|
|
1111
|
+
// The upgrade admission fence is honored at the single write moment, so both
|
|
1112
|
+
// baseline CLI writers and the Controller (which mutate through this same
|
|
1113
|
+
// store) refuse to persist while an upgrade owns the Home. Reads and
|
|
1114
|
+
// read-only transactions never reach here, and the fencing process itself is
|
|
1115
|
+
// exempt so it can re-pin the revision under the lock.
|
|
1116
|
+
assertHomeWritable(this.rootDir);
|
|
520
1117
|
const current = this.#readState();
|
|
521
1118
|
if (current.revision !== expectedRevision) {
|
|
522
1119
|
throw new StorageConflictError(`Storage changed concurrently (expected revision ${expectedRevision}, found ${current.revision}).`);
|
|
@@ -525,6 +1122,19 @@ export class FileTaskStore {
|
|
|
525
1122
|
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
526
1123
|
parseState(content);
|
|
527
1124
|
writeTextFileAtomically(join(this.rootDir, STORAGE_STATE_FILE), content);
|
|
1125
|
+
if (this.#normalizeState !== undefined) {
|
|
1126
|
+
// A compatible read never rewrites the Home. Its first actual mutation
|
|
1127
|
+
// emits current-only state, then advances the durable manifest to the
|
|
1128
|
+
// same current family versions under the existing storage write lock.
|
|
1129
|
+
// A crash between the two atomic files is detected as manifest/state
|
|
1130
|
+
// inconsistency on the next open; it is never silently accepted.
|
|
1131
|
+
writeCurrentStorageManifest(this.rootDir);
|
|
1132
|
+
this.#normalizeState = undefined;
|
|
1133
|
+
}
|
|
1134
|
+
this.#readCache = null;
|
|
1135
|
+
}
|
|
1136
|
+
#parseState(raw) {
|
|
1137
|
+
return parseState(this.#normalizeState?.(raw) ?? raw);
|
|
528
1138
|
}
|
|
529
1139
|
#withWriteLock(execute) {
|
|
530
1140
|
const release = acquireStorageLock(this.rootDir);
|
|
@@ -535,6 +1145,16 @@ export class FileTaskStore {
|
|
|
535
1145
|
release();
|
|
536
1146
|
}
|
|
537
1147
|
}
|
|
1148
|
+
#requireReadableSchema() {
|
|
1149
|
+
if (this.#normalizeState === undefined)
|
|
1150
|
+
requireStorageSchema(this.rootDir);
|
|
1151
|
+
else
|
|
1152
|
+
requireCompatibleStorageSchema(this.rootDir);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function stateFileFingerprint(path) {
|
|
1156
|
+
const stat = statSync(path, { bigint: true });
|
|
1157
|
+
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
538
1158
|
}
|
|
539
1159
|
export class StorageRecordError extends Error {
|
|
540
1160
|
constructor(message) { super(message); this.name = "StorageRecordError"; }
|
|
@@ -549,29 +1169,74 @@ export function resolveYuiHome(env) {
|
|
|
549
1169
|
}
|
|
550
1170
|
export function ensureYuiHome(rootDir) { mkdirSync(rootDir, { recursive: true, mode: 0o700 }); }
|
|
551
1171
|
function emptyState() {
|
|
552
|
-
return {
|
|
1172
|
+
return {
|
|
1173
|
+
schemaVersion: CURRENT_STORAGE_STATE_SCHEMA_VERSION,
|
|
1174
|
+
revision: 0,
|
|
1175
|
+
config: { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION },
|
|
1176
|
+
configuredAgents: {},
|
|
1177
|
+
projects: {},
|
|
1178
|
+
agentProfiles: {},
|
|
1179
|
+
globalRoles: {},
|
|
1180
|
+
globalRoleSessionSets: {},
|
|
1181
|
+
tasks: {},
|
|
1182
|
+
mailboxes: {}
|
|
1183
|
+
};
|
|
553
1184
|
}
|
|
554
1185
|
function emptyStoredTask(task) {
|
|
555
1186
|
return {
|
|
556
|
-
schemaVersion:
|
|
1187
|
+
schemaVersion: CURRENT_STORED_TASK_SCHEMA_VERSION,
|
|
557
1188
|
task,
|
|
1189
|
+
idHighWaterMarks: emptyTaskIdHighWaterMarks(),
|
|
558
1190
|
brief: null,
|
|
1191
|
+
changeSets: {},
|
|
1192
|
+
integrationAttempts: {},
|
|
559
1193
|
roles: {},
|
|
560
|
-
|
|
1194
|
+
managedWorkspaces: {},
|
|
561
1195
|
roleSessionSets: {},
|
|
562
1196
|
workItems: {},
|
|
563
1197
|
agentRuns: {},
|
|
1198
|
+
reviewRounds: {},
|
|
564
1199
|
activeRuns: {},
|
|
565
1200
|
messages: {},
|
|
566
1201
|
inputRequests: {},
|
|
567
1202
|
decisions: {},
|
|
568
1203
|
milestones: {},
|
|
569
1204
|
events: {},
|
|
570
|
-
pendingWakeup: null,
|
|
571
1205
|
leaderFailure: null,
|
|
572
1206
|
operatorNotification: null
|
|
573
1207
|
};
|
|
574
1208
|
}
|
|
1209
|
+
function emptyTaskIdHighWaterMarks() {
|
|
1210
|
+
return {
|
|
1211
|
+
workItem: 0,
|
|
1212
|
+
agentRun: 0,
|
|
1213
|
+
reviewRound: 0,
|
|
1214
|
+
changeSet: 0,
|
|
1215
|
+
integrationAttempt: 0,
|
|
1216
|
+
message: 0,
|
|
1217
|
+
inputRequest: 0,
|
|
1218
|
+
decision: 0,
|
|
1219
|
+
milestone: 0,
|
|
1220
|
+
event: 0
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function nextTaskRecordSequence(aggregate, taskId, kind) {
|
|
1224
|
+
const next = aggregate.idHighWaterMarks[kind] + 1;
|
|
1225
|
+
if (!Number.isSafeInteger(next)) {
|
|
1226
|
+
throw new StorageRecordError(`Task ${kind} id space is exhausted: ${taskId}.`);
|
|
1227
|
+
}
|
|
1228
|
+
return next;
|
|
1229
|
+
}
|
|
1230
|
+
function validateTaskIdHighWaterMarks(value, taskId) {
|
|
1231
|
+
const marks = object(value, `Task id high-water marks ${taskId}`);
|
|
1232
|
+
const kinds = Object.keys(TASK_RECORD_ID_PREFIXES);
|
|
1233
|
+
exact(marks, kinds, `Task id high-water marks ${taskId}`);
|
|
1234
|
+
for (const kind of kinds) {
|
|
1235
|
+
if (!Number.isSafeInteger(marks[kind]) || marks[kind] < 0) {
|
|
1236
|
+
throw new StorageRecordError(`Task id high-water mark is invalid: ${taskId}/${kind}.`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
575
1240
|
function parseState(raw) {
|
|
576
1241
|
let parsed;
|
|
577
1242
|
try {
|
|
@@ -581,30 +1246,65 @@ function parseState(raw) {
|
|
|
581
1246
|
throw new StorageRecordError(`Invalid ${STORAGE_STATE_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
582
1247
|
}
|
|
583
1248
|
const state = object(parsed, "Storage state");
|
|
584
|
-
exact(state, [
|
|
585
|
-
|
|
1249
|
+
exact(state, [
|
|
1250
|
+
"schemaVersion",
|
|
1251
|
+
"revision",
|
|
1252
|
+
"config",
|
|
1253
|
+
"configuredAgents",
|
|
1254
|
+
"projects",
|
|
1255
|
+
"agentProfiles",
|
|
1256
|
+
"globalRoles",
|
|
1257
|
+
"globalRoleSessionSets",
|
|
1258
|
+
"tasks",
|
|
1259
|
+
"mailboxes"
|
|
1260
|
+
], "Storage state");
|
|
1261
|
+
if (state.schemaVersion !== CURRENT_STORAGE_STATE_SCHEMA_VERSION || !Number.isInteger(state.revision) || state.revision < 0)
|
|
586
1262
|
throw new StorageRecordError("Storage state schemaVersion/revision is invalid.");
|
|
587
1263
|
const result = clone(state);
|
|
588
|
-
result.config = versioned(result.config,
|
|
1264
|
+
result.config = versioned(result.config, CURRENT_CONFIG_SCHEMA_VERSION, "Yui config");
|
|
589
1265
|
validateYuiConfig(result.config);
|
|
590
1266
|
parseMap(result.configuredAgents, (value, key) => {
|
|
591
|
-
const agent = identified(value,
|
|
1267
|
+
const agent = identified(value, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, "id", key, "Configured Agent");
|
|
592
1268
|
validateConfiguredAgent(agent);
|
|
593
1269
|
return agent;
|
|
594
1270
|
}, "configuredAgents");
|
|
595
|
-
parseMap(result.
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
return
|
|
599
|
-
}, "
|
|
1271
|
+
parseMap(result.projects, (value, key) => {
|
|
1272
|
+
const project = identified(value, CURRENT_PROJECT_SCHEMA_VERSION, "id", key, "Project");
|
|
1273
|
+
validateProject(project);
|
|
1274
|
+
return project;
|
|
1275
|
+
}, "projects");
|
|
1276
|
+
try {
|
|
1277
|
+
assertProjectCatalog(Object.values(result.projects));
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
throw new StorageRecordError(error instanceof Error ? error.message : String(error));
|
|
1281
|
+
}
|
|
1282
|
+
parseMap(result.agentProfiles, (value, key) => {
|
|
1283
|
+
const profile = identified(value, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, "id", key, "Agent Profile");
|
|
1284
|
+
validateAgentProfile(profile);
|
|
1285
|
+
return profile;
|
|
1286
|
+
}, "agentProfiles");
|
|
600
1287
|
parseMap(result.globalRoles, (value, key) => {
|
|
601
|
-
const role = identified(value,
|
|
1288
|
+
const role = identified(value, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, "name", key, "Global Role");
|
|
602
1289
|
validateGlobalRole(role);
|
|
603
1290
|
return role;
|
|
604
1291
|
}, "globalRoles");
|
|
605
1292
|
parseMap(result.globalRoleSessionSets, (value, key) => { const set = globalSessions(value); if (set.owner.roleName !== key)
|
|
606
1293
|
throw new StorageRecordError(`Global Role session set identity is inconsistent: ${key}`); return set; }, "globalRoleSessionSets");
|
|
607
1294
|
parseMap(result.tasks, (value, key) => parseStoredTask(value, key), "tasks");
|
|
1295
|
+
parseMap(result.mailboxes, (value, key) => {
|
|
1296
|
+
let mailbox;
|
|
1297
|
+
try {
|
|
1298
|
+
mailbox = validateWorkMailbox(versioned(value, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, "WorkMailbox"));
|
|
1299
|
+
}
|
|
1300
|
+
catch (error) {
|
|
1301
|
+
throw new StorageRecordError(error instanceof Error ? error.message : String(error));
|
|
1302
|
+
}
|
|
1303
|
+
if (mailboxTargetKey(mailbox.target) !== key) {
|
|
1304
|
+
throw new StorageRecordError(`WorkMailbox identity is inconsistent: ${key}`);
|
|
1305
|
+
}
|
|
1306
|
+
return mailbox;
|
|
1307
|
+
}, "mailboxes");
|
|
608
1308
|
for (const [name, role] of Object.entries(result.globalRoles)) {
|
|
609
1309
|
const sessions = result.globalRoleSessionSets[name];
|
|
610
1310
|
if (sessions !== undefined)
|
|
@@ -616,78 +1316,183 @@ function parseState(raw) {
|
|
|
616
1316
|
}
|
|
617
1317
|
}
|
|
618
1318
|
for (const aggregate of Object.values(result.tasks)) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1319
|
+
for (const binding of aggregate.task.projectBindings) {
|
|
1320
|
+
if (result.projects[binding.projectId] === undefined) {
|
|
1321
|
+
throw new StorageRecordError(`Task Project not found: ${aggregate.task.id}/${binding.projectId}`);
|
|
1322
|
+
}
|
|
622
1323
|
}
|
|
623
1324
|
for (const [name, role] of Object.entries(aggregate.roles)) {
|
|
624
1325
|
const sessions = aggregate.roleSessionSets[name];
|
|
625
1326
|
if (sessions !== undefined)
|
|
626
1327
|
assertSessionsMatchRole(sessions, role);
|
|
627
|
-
const workspace = aggregate.roleWorkspaces[name];
|
|
628
|
-
if (workspace !== undefined && workspace.path !== role.workspace) {
|
|
629
|
-
throw new StorageRecordError(`Task Role workspace path is inconsistent: ${aggregate.task.id}/${name}`);
|
|
630
|
-
}
|
|
631
1328
|
}
|
|
632
1329
|
for (const name of Object.keys(aggregate.roleSessionSets)) {
|
|
633
1330
|
if (aggregate.roles[name] === undefined) {
|
|
634
1331
|
throw new StorageRecordError(`Task Role session set has no Role: ${aggregate.task.id}/${name}`);
|
|
635
1332
|
}
|
|
636
1333
|
}
|
|
637
|
-
for (const [
|
|
638
|
-
if (
|
|
639
|
-
throw new StorageRecordError(`
|
|
1334
|
+
for (const [key, workspace] of Object.entries(aggregate.managedWorkspaces)) {
|
|
1335
|
+
if (workspace.owner.taskId !== aggregate.task.id) {
|
|
1336
|
+
throw new StorageRecordError(`Managed workspace belongs to another Task: ${key}`);
|
|
640
1337
|
}
|
|
641
|
-
|
|
642
|
-
|
|
1338
|
+
const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
|
|
1339
|
+
if (workspace.entries.some(({ projectId }) => !boundProjects.has(projectId))) {
|
|
1340
|
+
throw new StorageRecordError(`Managed workspace Project does not match Task: ${aggregate.task.id}/${key}`);
|
|
1341
|
+
}
|
|
1342
|
+
if (workspace.owner.type === "work-item"
|
|
1343
|
+
&& aggregate.workItems[workspace.owner.workItemId] === undefined) {
|
|
1344
|
+
throw new StorageRecordError(`Managed workspace WorkItem not found: ${aggregate.task.id}/${workspace.owner.workItemId}`);
|
|
1345
|
+
}
|
|
1346
|
+
if (workspace.owner.type === "review-round"
|
|
1347
|
+
&& aggregate.reviewRounds[workspace.owner.reviewRoundId] === undefined) {
|
|
1348
|
+
throw new StorageRecordError(`Managed workspace ReviewRound not found: ${aggregate.task.id}/${workspace.owner.reviewRoundId}`);
|
|
1349
|
+
}
|
|
1350
|
+
if (workspace.owner.type === "integration-attempt"
|
|
1351
|
+
&& aggregate.integrationAttempts[workspace.owner.integrationAttemptId] === undefined) {
|
|
1352
|
+
throw new StorageRecordError(`Managed workspace Integration Attempt not found: ${aggregate.task.id}/${workspace.owner.integrationAttemptId}`);
|
|
1353
|
+
}
|
|
1354
|
+
if (workspace.owner.type === "execution-lane") {
|
|
1355
|
+
const laneOwner = workspace.owner;
|
|
1356
|
+
const laneItem = laneOwner.purpose === "execution"
|
|
1357
|
+
? aggregate.workItems[laneOwner.workItemId ?? ""]
|
|
1358
|
+
: undefined;
|
|
1359
|
+
const group = laneOwner.purpose === "execution"
|
|
1360
|
+
? (laneItem === undefined
|
|
1361
|
+
? undefined
|
|
1362
|
+
: workItemExecutionGroupById(laneItem, laneOwner.executionGroupId))
|
|
1363
|
+
: aggregate.reviewRounds[laneOwner.reviewRoundId ?? ""]?.executionGroup;
|
|
1364
|
+
if (group?.id !== laneOwner.executionGroupId
|
|
1365
|
+
|| !group.lanes.some(({ id }) => id === laneOwner.executionLaneId)) {
|
|
1366
|
+
throw new StorageRecordError(`Managed workspace Execution Lane not found: ${aggregate.task.id}/${key}`);
|
|
1367
|
+
}
|
|
643
1368
|
}
|
|
644
1369
|
}
|
|
1370
|
+
validateCanonicalTaskReferences(result, aggregate);
|
|
645
1371
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
assertGloballyUniqueTaskRecordIds(result, "milestones", "Milestone");
|
|
649
|
-
assertGloballyUniqueTaskRecordIds(result, "events", "Task event");
|
|
650
|
-
assertGloballyUniqueTaskRecordIds(result, "messages", "Message");
|
|
1372
|
+
for (const mailbox of Object.values(result.mailboxes))
|
|
1373
|
+
validateMailboxReferences(result, mailbox);
|
|
651
1374
|
return result;
|
|
652
1375
|
}
|
|
1376
|
+
/** Strict current-model gate used by the compatible loader before any writer opens. */
|
|
1377
|
+
export function validateCurrentStorageStateSnapshot(value) {
|
|
1378
|
+
parseState(`${JSON.stringify(value)}\n`);
|
|
1379
|
+
}
|
|
653
1380
|
function parseStoredTask(value, taskId) {
|
|
654
1381
|
const aggregate = object(value, `Task aggregate ${taskId}`);
|
|
655
|
-
exact(aggregate, [
|
|
656
|
-
|
|
657
|
-
|
|
1382
|
+
exact(aggregate, [
|
|
1383
|
+
"schemaVersion",
|
|
1384
|
+
"task",
|
|
1385
|
+
"idHighWaterMarks",
|
|
1386
|
+
"brief",
|
|
1387
|
+
"changeSets",
|
|
1388
|
+
"integrationAttempts",
|
|
1389
|
+
"roles",
|
|
1390
|
+
"managedWorkspaces",
|
|
1391
|
+
"roleSessionSets",
|
|
1392
|
+
"workItems",
|
|
1393
|
+
"agentRuns",
|
|
1394
|
+
"reviewRounds",
|
|
1395
|
+
"activeRuns",
|
|
1396
|
+
"messages",
|
|
1397
|
+
"inputRequests",
|
|
1398
|
+
"decisions",
|
|
1399
|
+
"milestones",
|
|
1400
|
+
"events",
|
|
1401
|
+
"leaderFailure",
|
|
1402
|
+
"operatorNotification"
|
|
1403
|
+
], `Task aggregate ${taskId}`);
|
|
1404
|
+
parseMap(aggregate.changeSets, (record, key) => {
|
|
1405
|
+
const changeSet = identifiedChangeSet(record, key);
|
|
1406
|
+
if (changeSet.taskId !== taskId) {
|
|
1407
|
+
throw new StorageRecordError(`ChangeSet belongs to another Task: ${changeSet.taskId}.`);
|
|
1408
|
+
}
|
|
1409
|
+
validateChangeSet(changeSet);
|
|
1410
|
+
return changeSet;
|
|
1411
|
+
}, "changeSets");
|
|
1412
|
+
parseMap(aggregate.integrationAttempts, (record, key) => {
|
|
1413
|
+
const attempt = identified(record, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, "id", key, "Integration Attempt");
|
|
1414
|
+
if (attempt.taskId !== taskId) {
|
|
1415
|
+
throw new StorageRecordError(`Integration Attempt belongs to another Task: ${attempt.taskId}.`);
|
|
1416
|
+
}
|
|
1417
|
+
validateIntegrationAttempt(attempt);
|
|
1418
|
+
return attempt;
|
|
1419
|
+
}, "integrationAttempts");
|
|
1420
|
+
versioned(aggregate, CURRENT_STORED_TASK_SCHEMA_VERSION, `Task aggregate ${taskId}`);
|
|
1421
|
+
validateTaskIdHighWaterMarks(aggregate.idHighWaterMarks, taskId);
|
|
1422
|
+
validateTask(identified(aggregate.task, CURRENT_TASK_SCHEMA_VERSION, "id", taskId, "Task"));
|
|
658
1423
|
if (aggregate.brief !== null)
|
|
659
1424
|
storedTaskBrief(aggregate.brief);
|
|
660
|
-
parseMap(aggregate.roles, (record, key) => {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1425
|
+
parseMap(aggregate.roles, (record, key) => {
|
|
1426
|
+
const role = identified(record, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", key, "Task Role");
|
|
1427
|
+
if (role.taskId !== taskId)
|
|
1428
|
+
throw new StorageRecordError(`Task Role belongs to another Task: ${role.taskId}`);
|
|
1429
|
+
validateTaskRole(role);
|
|
1430
|
+
return role;
|
|
1431
|
+
}, "roles");
|
|
1432
|
+
parseMap(aggregate.managedWorkspaces, (record, key) => {
|
|
1433
|
+
const workspace = versioned(record, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, "Managed workspace");
|
|
1434
|
+
validateManagedWorkspace(workspace);
|
|
1435
|
+
if (workspace.owner.taskId !== taskId) {
|
|
1436
|
+
throw new StorageRecordError(`Managed workspace belongs to another Task: ${workspace.owner.taskId}`);
|
|
1437
|
+
}
|
|
1438
|
+
if (managedWorkspaceKey(workspace.owner) !== key) {
|
|
1439
|
+
throw new StorageRecordError(`Managed workspace identity is inconsistent: ${taskId}/${key}`);
|
|
1440
|
+
}
|
|
668
1441
|
return workspace;
|
|
669
|
-
}, "
|
|
1442
|
+
}, "managedWorkspaces");
|
|
670
1443
|
parseMap(aggregate.roleSessionSets, (record, key) => { const set = taskSessions(record); if (set.owner.taskId !== taskId || set.owner.roleName !== key)
|
|
671
1444
|
throw new StorageRecordError(`Task Role session set identity is inconsistent: ${taskId}/${key}`); return set; }, "roleSessionSets");
|
|
672
|
-
parseMap(aggregate.workItems, (record, key) => {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1445
|
+
parseMap(aggregate.workItems, (record, key) => {
|
|
1446
|
+
const item = identified(record, CURRENT_WORK_ITEM_SCHEMA_VERSION, "id", key, "Work item");
|
|
1447
|
+
if (item.taskId !== taskId) {
|
|
1448
|
+
throw new StorageRecordError(`Work item belongs to another Task: ${item.taskId}`);
|
|
1449
|
+
}
|
|
1450
|
+
validateWorkItem(item);
|
|
1451
|
+
return item;
|
|
1452
|
+
}, "workItems");
|
|
1453
|
+
parseMap(aggregate.agentRuns, (record, key) => {
|
|
1454
|
+
const run = identified(record, CURRENT_AGENT_RUN_SCHEMA_VERSION, "id", key, "Agent run");
|
|
1455
|
+
if (run.taskId !== taskId) {
|
|
1456
|
+
throw new StorageRecordError(`Agent run belongs to another Task: ${run.taskId}`);
|
|
1457
|
+
}
|
|
1458
|
+
validateAgentRun(run);
|
|
1459
|
+
return run;
|
|
1460
|
+
}, "agentRuns");
|
|
1461
|
+
parseMap(aggregate.reviewRounds, (record, key) => {
|
|
1462
|
+
const round = identified(record, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, "id", key, "ReviewRound");
|
|
1463
|
+
if (round.taskId !== taskId) {
|
|
1464
|
+
throw new StorageRecordError(`ReviewRound belongs to another Task: ${round.taskId}.`);
|
|
1465
|
+
}
|
|
1466
|
+
validateReviewRound(round);
|
|
1467
|
+
return round;
|
|
1468
|
+
}, "reviewRounds");
|
|
676
1469
|
parseMap(aggregate.activeRuns, (record, key) => {
|
|
677
|
-
const pointer = versioned(record,
|
|
1470
|
+
const pointer = versioned(record, CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION, `Active run ${key}`);
|
|
678
1471
|
const run = typeof pointer.runId === "string" ? aggregate.agentRuns[pointer.runId] : undefined;
|
|
679
|
-
|
|
1472
|
+
const laneMatch = executionLaneActiveRunKeyParts(key);
|
|
1473
|
+
const validLanePointer = laneMatch !== null
|
|
1474
|
+
&& run !== undefined
|
|
1475
|
+
&& run.executionGroupId === laneMatch.executionGroupId
|
|
1476
|
+
&& run.executionLaneId === laneMatch.executionLaneId;
|
|
1477
|
+
const validRolePointer = laneMatch === null
|
|
1478
|
+
&& run !== undefined
|
|
1479
|
+
&& run.roleName === key;
|
|
1480
|
+
if (run === undefined || run.status !== "active"
|
|
1481
|
+
|| (!validLanePointer && !validRolePointer)) {
|
|
680
1482
|
throw new StorageRecordError(`Active run pointer is invalid: ${taskId}/${key}`);
|
|
681
1483
|
}
|
|
682
1484
|
return pointer;
|
|
683
1485
|
}, "activeRuns");
|
|
684
1486
|
parseMap(aggregate.messages, (record, key) => {
|
|
685
|
-
const message = identified(record,
|
|
1487
|
+
const message = identified(record, CURRENT_MESSAGE_SCHEMA_VERSION, "id", key, "Message");
|
|
1488
|
+
if (message.taskId !== taskId) {
|
|
1489
|
+
throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
|
|
1490
|
+
}
|
|
686
1491
|
validateTaskMessage(message);
|
|
687
1492
|
return message;
|
|
688
1493
|
}, "messages");
|
|
689
1494
|
parseMap(aggregate.inputRequests, (record, key) => {
|
|
690
|
-
const request = validateInputRequest(record);
|
|
1495
|
+
const request = validateInputRequest(versioned(record, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, "Input request"));
|
|
691
1496
|
if (request.id !== key) {
|
|
692
1497
|
throw new StorageRecordError(`Input request identity is inconsistent: ${key}.`);
|
|
693
1498
|
}
|
|
@@ -718,25 +1523,71 @@ function parseStoredTask(value, taskId) {
|
|
|
718
1523
|
const event = storedTaskEvent(record);
|
|
719
1524
|
if (event.id !== key)
|
|
720
1525
|
throw new StorageRecordError(`Task event identity is inconsistent: ${key}.`);
|
|
1526
|
+
if (event.taskId !== taskId) {
|
|
1527
|
+
throw new StorageRecordError(`Task event belongs to another Task: ${event.taskId}`);
|
|
1528
|
+
}
|
|
721
1529
|
return event;
|
|
722
1530
|
}, "events");
|
|
723
|
-
for (const [key, label] of [["
|
|
1531
|
+
for (const [key, label] of [["leaderFailure", "Leader failure"], ["operatorNotification", "Operator notification"]]) {
|
|
724
1532
|
const record = aggregate[key];
|
|
725
|
-
if (record !== null)
|
|
726
|
-
|
|
1533
|
+
if (record !== null) {
|
|
1534
|
+
const schemaVersion = key === "leaderFailure"
|
|
1535
|
+
? CURRENT_LEADER_FAILURE_SCHEMA_VERSION
|
|
1536
|
+
: CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION;
|
|
1537
|
+
identified(record, schemaVersion, "taskId", taskId, label);
|
|
1538
|
+
}
|
|
727
1539
|
}
|
|
1540
|
+
validateTaskIdHighWaterCoverage(aggregate, taskId);
|
|
728
1541
|
return aggregate;
|
|
729
1542
|
}
|
|
1543
|
+
function validateTaskIdHighWaterCoverage(aggregate, taskId) {
|
|
1544
|
+
const records = {
|
|
1545
|
+
workItem: aggregate.workItems,
|
|
1546
|
+
agentRun: aggregate.agentRuns,
|
|
1547
|
+
reviewRound: aggregate.reviewRounds,
|
|
1548
|
+
changeSet: aggregate.changeSets,
|
|
1549
|
+
integrationAttempt: aggregate.integrationAttempts,
|
|
1550
|
+
message: aggregate.messages,
|
|
1551
|
+
inputRequest: aggregate.inputRequests,
|
|
1552
|
+
decision: aggregate.decisions,
|
|
1553
|
+
milestone: aggregate.milestones,
|
|
1554
|
+
event: aggregate.events
|
|
1555
|
+
};
|
|
1556
|
+
for (const kind of Object.keys(TASK_RECORD_ID_PREFIXES)) {
|
|
1557
|
+
const pattern = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`);
|
|
1558
|
+
for (const id of Object.keys(records[kind])) {
|
|
1559
|
+
const match = pattern.exec(id);
|
|
1560
|
+
if (match === null) {
|
|
1561
|
+
throw new StorageRecordError(`Task-local ${kind} id is invalid: ${taskId}/${id}.`);
|
|
1562
|
+
}
|
|
1563
|
+
const sequence = Number.parseInt(match[1], 10);
|
|
1564
|
+
if (sequence > aggregate.idHighWaterMarks[kind]) {
|
|
1565
|
+
throw new StorageRecordError(`Task id high-water mark is behind ${taskId}/${kind}: ${id}.`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
function observeTaskRecordId(aggregate, kind, id) {
|
|
1571
|
+
validateTaskRecordReference({ taskId: aggregate.task.id, localId: id }, kind);
|
|
1572
|
+
const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`).exec(id);
|
|
1573
|
+
if (match === null)
|
|
1574
|
+
throw new StorageRecordError(`Task-local ${kind} id is invalid: ${id}.`);
|
|
1575
|
+
const sequence = Number.parseInt(match[1], 10);
|
|
1576
|
+
aggregate.idHighWaterMarks[kind] = Math.max(aggregate.idHighWaterMarks[kind], sequence);
|
|
1577
|
+
}
|
|
730
1578
|
function validateYuiConfig(config) {
|
|
731
1579
|
try {
|
|
732
1580
|
reconciliationIntervalMilliseconds(config.reconciliationIntervalSeconds);
|
|
1581
|
+
resolveTimeZone(config.timeZone);
|
|
1582
|
+
if (config.review !== undefined)
|
|
1583
|
+
validateReviewConfig(config.review);
|
|
733
1584
|
}
|
|
734
1585
|
catch (error) {
|
|
735
1586
|
throw new StorageRecordError(error instanceof Error ? error.message : "Yui reconciliation interval is invalid.");
|
|
736
1587
|
}
|
|
737
1588
|
}
|
|
738
1589
|
function globalSessions(value) {
|
|
739
|
-
const set = versioned(value,
|
|
1590
|
+
const set = versioned(value, CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION, "Global Role session set");
|
|
740
1591
|
if (set.owner?.scope !== "global" || typeof set.owner.roleName !== "string")
|
|
741
1592
|
throw new StorageRecordError("Global Role session owner is invalid.");
|
|
742
1593
|
validateSessions(set.sessions);
|
|
@@ -744,7 +1595,7 @@ function globalSessions(value) {
|
|
|
744
1595
|
return set;
|
|
745
1596
|
}
|
|
746
1597
|
function taskSessions(value) {
|
|
747
|
-
const set = versioned(value,
|
|
1598
|
+
const set = versioned(value, CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION, "Task Role session set");
|
|
748
1599
|
if (set.owner?.scope !== "task" || typeof set.owner.taskId !== "string" || typeof set.owner.roleName !== "string")
|
|
749
1600
|
throw new StorageRecordError("Task Role session owner is invalid.");
|
|
750
1601
|
validateSessions(set.sessions);
|
|
@@ -752,11 +1603,11 @@ function taskSessions(value) {
|
|
|
752
1603
|
return set;
|
|
753
1604
|
}
|
|
754
1605
|
function validateSessions(sessions) {
|
|
755
|
-
parseMap(sessions, (record, key) => identified(record,
|
|
1606
|
+
parseMap(sessions, (record, key) => identified(record, CURRENT_ROLE_AGENT_SESSION_SCHEMA_VERSION, "agentId", key, "Role Agent session"), "sessions");
|
|
756
1607
|
}
|
|
757
1608
|
function assertSessionsMatchRole(sessions, role) {
|
|
758
1609
|
validateRoleSessionSet(sessions);
|
|
759
|
-
if (sessions.owner.roleName !== role.name
|
|
1610
|
+
if (sessions.owner.roleName !== role.name) {
|
|
760
1611
|
throw new StorageRecordError(`Role session set does not match Role: ${role.name}`);
|
|
761
1612
|
}
|
|
762
1613
|
if ("taskId" in role && (sessions.owner.scope !== "task" || sessions.owner.taskId !== role.taskId)) {
|
|
@@ -765,7 +1616,16 @@ function assertSessionsMatchRole(sessions, role) {
|
|
|
765
1616
|
if (!("taskId" in role) && sessions.owner.scope !== "global") {
|
|
766
1617
|
throw new StorageRecordError(`Global Role session owner is inconsistent: ${role.name}`);
|
|
767
1618
|
}
|
|
768
|
-
|
|
1619
|
+
const ownedSessions = [
|
|
1620
|
+
...Object.entries(sessions.sessions),
|
|
1621
|
+
...(sessions.owner.scope === "global"
|
|
1622
|
+
? Object.entries(sessions.history ?? {})
|
|
1623
|
+
: [
|
|
1624
|
+
...(sessions.history ?? []).map((session, index) => [`history-${index}`, session])
|
|
1625
|
+
])
|
|
1626
|
+
];
|
|
1627
|
+
for (const [, session] of ownedSessions) {
|
|
1628
|
+
const agentId = session.agentId;
|
|
769
1629
|
const binding = role.agentBindings[agentId];
|
|
770
1630
|
if (binding === undefined || binding.adapterId !== session.adapterId) {
|
|
771
1631
|
throw new StorageRecordError(`Role Agent session has no matching binding: ${role.name}/${agentId}`);
|
|
@@ -773,9 +1633,19 @@ function assertSessionsMatchRole(sessions, role) {
|
|
|
773
1633
|
}
|
|
774
1634
|
}
|
|
775
1635
|
function storedTaskBrief(value) {
|
|
776
|
-
const brief = versioned(value,
|
|
777
|
-
exact(brief, [
|
|
1636
|
+
const brief = versioned(value, CURRENT_TASK_BRIEF_SCHEMA_VERSION, "Task Brief");
|
|
1637
|
+
exact(brief, [
|
|
1638
|
+
"schemaVersion",
|
|
1639
|
+
"objective",
|
|
1640
|
+
"boundaries",
|
|
1641
|
+
"technicalApproach",
|
|
1642
|
+
"currentFocus",
|
|
1643
|
+
"leaderSummary",
|
|
1644
|
+
"updatedAt",
|
|
1645
|
+
"updatedBy"
|
|
1646
|
+
], "Task Brief");
|
|
778
1647
|
requireNormalizedText(brief.objective, "Task Brief objective");
|
|
1648
|
+
requireOptionalNormalizedText(brief.technicalApproach, "Task Brief technical approach");
|
|
779
1649
|
requireNormalizedText(brief.currentFocus, "Task Brief current focus");
|
|
780
1650
|
requireNormalizedText(brief.leaderSummary, "Task Brief leader summary");
|
|
781
1651
|
requireNormalizedText(brief.updatedBy, "Task Brief updatedBy");
|
|
@@ -790,7 +1660,7 @@ function storedTaskBrief(value) {
|
|
|
790
1660
|
return brief;
|
|
791
1661
|
}
|
|
792
1662
|
function storedDecision(value) {
|
|
793
|
-
const decision = versioned(value,
|
|
1663
|
+
const decision = versioned(value, CURRENT_DECISION_SCHEMA_VERSION, "Decision");
|
|
794
1664
|
const baseFields = [
|
|
795
1665
|
"schemaVersion", "id", "taskId", "title", "rationale", "status", "createdAt", "updatedAt"
|
|
796
1666
|
];
|
|
@@ -810,6 +1680,7 @@ function storedDecision(value) {
|
|
|
810
1680
|
}
|
|
811
1681
|
requireRecordIdentity(decision.id, "Decision id");
|
|
812
1682
|
requireRecordIdentity(decision.taskId, "Decision Task id");
|
|
1683
|
+
validateTaskRecordReference({ taskId: decision.taskId, localId: decision.id }, "decision");
|
|
813
1684
|
requireNormalizedText(decision.title, "Decision title");
|
|
814
1685
|
requireNormalizedText(decision.rationale, "Decision rationale");
|
|
815
1686
|
requireTimestamp(decision.createdAt, "Decision createdAt");
|
|
@@ -820,10 +1691,11 @@ function storedDecision(value) {
|
|
|
820
1691
|
return decision;
|
|
821
1692
|
}
|
|
822
1693
|
function storedMilestone(value) {
|
|
823
|
-
const milestone = versioned(value,
|
|
1694
|
+
const milestone = versioned(value, CURRENT_MILESTONE_SCHEMA_VERSION, "Milestone");
|
|
824
1695
|
exact(milestone, ["schemaVersion", "id", "taskId", "title", "summary", "createdBy", "createdAt"], "Milestone");
|
|
825
1696
|
requireRecordIdentity(milestone.id, "Milestone id");
|
|
826
1697
|
requireRecordIdentity(milestone.taskId, "Milestone Task id");
|
|
1698
|
+
validateTaskRecordReference({ taskId: milestone.taskId, localId: milestone.id }, "milestone");
|
|
827
1699
|
requireNormalizedText(milestone.title, "Milestone title");
|
|
828
1700
|
requireNormalizedText(milestone.summary, "Milestone summary");
|
|
829
1701
|
if (milestone.createdBy !== "leader") {
|
|
@@ -833,9 +1705,11 @@ function storedMilestone(value) {
|
|
|
833
1705
|
return milestone;
|
|
834
1706
|
}
|
|
835
1707
|
function storedTaskEvent(value) {
|
|
836
|
-
const event = versioned(value,
|
|
837
|
-
exact(event, ["schemaVersion", "id", "type", "payload", "createdAt"], "Task event");
|
|
1708
|
+
const event = versioned(value, CURRENT_EVENT_SCHEMA_VERSION, "Task event");
|
|
1709
|
+
exact(event, ["schemaVersion", "id", "taskId", "type", "payload", "createdAt"], "Task event");
|
|
838
1710
|
requireRecordIdentity(event.id, "Task event id");
|
|
1711
|
+
requireRecordIdentity(event.taskId, "Task event Task id");
|
|
1712
|
+
validateTaskRecordReference({ taskId: event.taskId, localId: event.id }, "event");
|
|
839
1713
|
requireNormalizedText(event.type, "Task event type");
|
|
840
1714
|
const payload = object(event.payload, "Task event payload");
|
|
841
1715
|
for (const [key, payloadValue] of Object.entries(payload)) {
|
|
@@ -876,25 +1750,6 @@ function requireTaskFromState(state, taskId) {
|
|
|
876
1750
|
throw new StorageRecordError(`Task not found: ${taskId}`);
|
|
877
1751
|
return aggregate;
|
|
878
1752
|
}
|
|
879
|
-
function taskRecordOwner(state, key, id) {
|
|
880
|
-
for (const [taskId, aggregate] of Object.entries(state.tasks)) {
|
|
881
|
-
if (aggregate[key][id] !== undefined)
|
|
882
|
-
return taskId;
|
|
883
|
-
}
|
|
884
|
-
return null;
|
|
885
|
-
}
|
|
886
|
-
function assertGloballyUniqueTaskRecordIds(state, key, label) {
|
|
887
|
-
const owners = new Map();
|
|
888
|
-
for (const [taskId, aggregate] of Object.entries(state.tasks)) {
|
|
889
|
-
for (const id of Object.keys(aggregate[key])) {
|
|
890
|
-
const owner = owners.get(id);
|
|
891
|
-
if (owner !== undefined) {
|
|
892
|
-
throw new StorageRecordError(`${label} id is duplicated across Tasks: ${id} (${owner}, ${taskId}).`);
|
|
893
|
-
}
|
|
894
|
-
owners.set(id, taskId);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
1753
|
function requireRecordIdentity(value, label) {
|
|
899
1754
|
const normalized = requireNormalizedText(value, label);
|
|
900
1755
|
if (["__proto__", "prototype", "constructor", ".", ".."].includes(normalized)
|
|
@@ -914,6 +1769,15 @@ function requireNormalizedText(value, label) {
|
|
|
914
1769
|
throw new StorageRecordError(`${label} must be normalized.`);
|
|
915
1770
|
return normalized;
|
|
916
1771
|
}
|
|
1772
|
+
function requireOptionalNormalizedText(value, label) {
|
|
1773
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
1774
|
+
throw new StorageRecordError(`${label} is invalid.`);
|
|
1775
|
+
}
|
|
1776
|
+
const normalized = value.trim();
|
|
1777
|
+
if (normalized !== value)
|
|
1778
|
+
throw new StorageRecordError(`${label} must be normalized.`);
|
|
1779
|
+
return normalized;
|
|
1780
|
+
}
|
|
917
1781
|
function requireTimestamp(value, label) {
|
|
918
1782
|
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
|
|
919
1783
|
throw new StorageRecordError(`${label} is invalid.`);
|
|
@@ -938,6 +1802,9 @@ function identified(value, schemaVersion, key, expected, label) {
|
|
|
938
1802
|
throw new StorageRecordError(`${label} identity is inconsistent: ${expected}.`);
|
|
939
1803
|
return record;
|
|
940
1804
|
}
|
|
1805
|
+
function identifiedChangeSet(value, expectedId) {
|
|
1806
|
+
return identified(value, CURRENT_CHANGE_SET_SCHEMA_VERSION, "id", expectedId, "ChangeSet");
|
|
1807
|
+
}
|
|
941
1808
|
function object(value, label) {
|
|
942
1809
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
943
1810
|
throw new StorageRecordError(`${label} must be an object.`);
|
|
@@ -979,12 +1846,726 @@ function values(records, identity) {
|
|
|
979
1846
|
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])));
|
|
980
1847
|
}
|
|
981
1848
|
function numericCompare(left, right) { return left.localeCompare(right, undefined, { numeric: true }); }
|
|
982
|
-
function
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
return
|
|
1849
|
+
function pendingWakeupProjection(mailbox) {
|
|
1850
|
+
if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
|
|
1851
|
+
|| mailbox.pending === null) {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
return {
|
|
1855
|
+
schemaVersion: CURRENT_PENDING_WAKEUP_SCHEMA_VERSION,
|
|
1856
|
+
taskId: mailbox.target.taskId,
|
|
1857
|
+
reasons: [...mailbox.pending.reasons],
|
|
1858
|
+
requestCount: mailbox.pending.requestCount,
|
|
1859
|
+
firstRequestedAt: mailbox.pending.firstQueuedAt,
|
|
1860
|
+
lastRequestedAt: mailbox.pending.lastQueuedAt
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
function validateMailboxReferences(state, mailbox) {
|
|
1864
|
+
if (mailbox.target.kind === "task"
|
|
1865
|
+
|| mailbox.target.kind === "role"
|
|
1866
|
+
|| mailbox.target.kind === "role-runtime") {
|
|
1867
|
+
const aggregate = state.tasks[mailbox.target.taskId];
|
|
1868
|
+
if (aggregate === undefined) {
|
|
1869
|
+
throw new StorageRecordError(`WorkMailbox target Task not found: ${mailbox.target.taskId}`);
|
|
1870
|
+
}
|
|
1871
|
+
if (mailbox.target.kind === "role" && aggregate.roles[mailbox.target.roleName] === undefined) {
|
|
1872
|
+
throw new StorageRecordError(`WorkMailbox target Role not found: ${mailbox.target.taskId}/${mailbox.target.roleName}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
const refs = [];
|
|
1876
|
+
if (mailbox.processing !== null) {
|
|
1877
|
+
refs.push(...mailbox.processing.batch.refs);
|
|
1878
|
+
if (mailbox.processing.executionRef !== undefined)
|
|
1879
|
+
refs.push(mailbox.processing.executionRef);
|
|
1880
|
+
}
|
|
1881
|
+
if (mailbox.pending !== null)
|
|
1882
|
+
refs.push(...mailbox.pending.refs);
|
|
1883
|
+
for (const ref of refs) {
|
|
1884
|
+
if (!mailboxReferenceExists(state, ref)) {
|
|
1885
|
+
const identity = "taskId" in ref ? `${ref.taskId}/${ref.id}` : ref.id;
|
|
1886
|
+
throw new StorageRecordError(`WorkMailbox reference does not exist: ${ref.type}/${identity}`);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
function validateCanonicalTaskReferences(state, aggregate) {
|
|
1891
|
+
const taskId = aggregate.task.id;
|
|
1892
|
+
if (aggregate.task.replacementTaskId !== undefined) {
|
|
1893
|
+
const replacement = state.tasks[aggregate.task.replacementTaskId];
|
|
1894
|
+
if (replacement === undefined || replacement.task.id === taskId) {
|
|
1895
|
+
throw new StorageRecordError(`Replacement Task reference is invalid: ${taskId}/${aggregate.task.replacementTaskId}.`);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
|
|
1899
|
+
assertAcyclicWorkItems(aggregate.workItems);
|
|
1900
|
+
for (const item of Object.values(aggregate.workItems)) {
|
|
1901
|
+
for (const dependencyId of item.dependsOn) {
|
|
1902
|
+
if (aggregate.workItems[dependencyId] === undefined) {
|
|
1903
|
+
throw new StorageRecordError(`Work Item dependency not found: ${taskId}/${dependencyId}.`);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
if (item.writeProjectIds.some((projectId) => !boundProjects.has(projectId))) {
|
|
1907
|
+
throw new StorageRecordError(`Work Item writable Project does not belong to Task: ${taskId}/${item.id}.`);
|
|
1908
|
+
}
|
|
1909
|
+
const writableProjects = new Set(item.writeProjectIds);
|
|
1910
|
+
if (item.baseRefs?.some(({ projectId }) => !boundProjects.has(projectId))) {
|
|
1911
|
+
throw new StorageRecordError(`Work Item base-ref Project does not belong to Task: ${taskId}/${item.id}.`);
|
|
1912
|
+
}
|
|
1913
|
+
if (item.baseRefs?.some(({ projectId }) => !writableProjects.has(projectId))) {
|
|
1914
|
+
throw new StorageRecordError(`Work Item base-ref Project must be writable: ${taskId}/${item.id}.`);
|
|
1915
|
+
}
|
|
1916
|
+
const replacementWorkItemId = item.disposition?.replacementWorkItemId;
|
|
1917
|
+
if (replacementWorkItemId !== undefined) {
|
|
1918
|
+
if (replacementWorkItemId === item.id) {
|
|
1919
|
+
throw new StorageRecordError(`Work Item cannot replace itself: ${taskId}/${item.id}.`);
|
|
1920
|
+
}
|
|
1921
|
+
const replacement = aggregate.workItems[replacementWorkItemId];
|
|
1922
|
+
if (replacement === undefined || replacement.taskId !== taskId) {
|
|
1923
|
+
throw new StorageRecordError(`Replacement Work Item must belong to the same Task: ${taskId}/${replacementWorkItemId}.`);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
for (const candidate of item.candidates) {
|
|
1927
|
+
assertWorkItemCandidateReferences(aggregate, item, candidate, `Work Item candidate ${item.id}`);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
for (const workspace of Object.values(aggregate.managedWorkspaces)) {
|
|
1931
|
+
if (workspace.owner.type === "work-item"
|
|
1932
|
+
&& aggregate.workItems[workspace.owner.workItemId] === undefined) {
|
|
1933
|
+
throw new StorageRecordError(`Managed workspace Work Item not found: ${taskId}/${workspace.owner.workItemId}.`);
|
|
1934
|
+
}
|
|
1935
|
+
if (workspace.owner.type === "review-round") {
|
|
1936
|
+
const round = aggregate.reviewRounds[workspace.owner.reviewRoundId];
|
|
1937
|
+
if (round === undefined) {
|
|
1938
|
+
throw new StorageRecordError(`Managed workspace ReviewRound is invalid: ${taskId}/${workspace.owner.reviewRoundId}.`);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
for (const [roleName, sessions] of Object.entries(aggregate.roleSessionSets)) {
|
|
1943
|
+
if (sessions.inFlight !== null) {
|
|
1944
|
+
const run = aggregate.agentRuns[sessions.inFlight.runId];
|
|
1945
|
+
if (run === undefined || run.roleName !== roleName
|
|
1946
|
+
|| sessions.inFlight.receiptId !== formatAgentRunReceiptId(taskId, run.id)) {
|
|
1947
|
+
throw new StorageRecordError(`Task Role in-flight Run is invalid: ${taskId}/${roleName}.`);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
if (sessions.pendingTurnCompletion !== null) {
|
|
1951
|
+
const completion = sessions.pendingTurnCompletion;
|
|
1952
|
+
const run = aggregate.agentRuns[completion.runId];
|
|
1953
|
+
if (completion.taskId !== taskId || run === undefined || run.roleName !== roleName) {
|
|
1954
|
+
throw new StorageRecordError(`Task Role pending completion Run is invalid: ${taskId}/${roleName}.`);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
for (const run of Object.values(aggregate.agentRuns)) {
|
|
1959
|
+
if (run.workItemId !== undefined && aggregate.workItems[run.workItemId] === undefined) {
|
|
1960
|
+
throw new StorageRecordError(`Agent Run Work Item not found: ${taskId}/${run.id}.`);
|
|
1961
|
+
}
|
|
1962
|
+
if (run.reviewRoundId !== undefined
|
|
1963
|
+
&& aggregate.reviewRounds[run.reviewRoundId] === undefined) {
|
|
1964
|
+
throw new StorageRecordError(`Agent Run ReviewRound not found: ${taskId}/${run.id}.`);
|
|
1965
|
+
}
|
|
1966
|
+
assertAgentRunExecutionReferences(aggregate, run);
|
|
1967
|
+
}
|
|
1968
|
+
for (const message of Object.values(aggregate.messages)) {
|
|
1969
|
+
if (message.runId !== undefined && aggregate.agentRuns[message.runId] === undefined) {
|
|
1970
|
+
throw new StorageRecordError(`Message Run not found: ${taskId}/${message.id}.`);
|
|
1971
|
+
}
|
|
1972
|
+
if (message.workItemId !== undefined
|
|
1973
|
+
&& aggregate.workItems[message.workItemId] === undefined) {
|
|
1974
|
+
throw new StorageRecordError(`Message Work Item not found: ${taskId}/${message.id}.`);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
for (const request of Object.values(aggregate.inputRequests)) {
|
|
1978
|
+
if (aggregate.agentRuns[request.requester.runId] === undefined) {
|
|
1979
|
+
throw new StorageRecordError(`Input requester Run not found: ${taskId}/${request.id}.`);
|
|
1980
|
+
}
|
|
1981
|
+
for (const reference of request.blockedRefs) {
|
|
1982
|
+
const found = reference.type === "run"
|
|
1983
|
+
? aggregate.agentRuns[reference.id]
|
|
1984
|
+
: aggregate.workItems[reference.id];
|
|
1985
|
+
if (found === undefined) {
|
|
1986
|
+
throw new StorageRecordError(`Input blocked ${reference.type} not found: ${taskId}/${request.id}/${reference.id}.`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
for (const round of Object.values(aggregate.reviewRounds)) {
|
|
1991
|
+
const item = aggregate.workItems[round.workItemId];
|
|
1992
|
+
if (item === undefined) {
|
|
1993
|
+
throw new StorageRecordError(`ReviewRound references are invalid: ${round.id}.`);
|
|
1994
|
+
}
|
|
1995
|
+
const candidate = item.candidates.find(({ id }) => id === round.candidateId);
|
|
1996
|
+
if (candidate === undefined) {
|
|
1997
|
+
throw new StorageRecordError(`ReviewRound Candidate not found: ${round.candidateId}.`);
|
|
1998
|
+
}
|
|
1999
|
+
assertWorkItemCandidateReferences(aggregate, item, candidate, `ReviewRound candidate ${round.id}`);
|
|
2000
|
+
if ((round.scope ?? "work-item") === "task") {
|
|
2001
|
+
const frozenProjects = round.taskCandidate?.projects ?? [];
|
|
2002
|
+
if (frozenProjects.length !== boundProjects.size
|
|
2003
|
+
|| frozenProjects.some(({ projectId }) => !boundProjects.has(projectId))) {
|
|
2004
|
+
throw new StorageRecordError(`Task ReviewRound Projects do not match Task scope: ${round.id}.`);
|
|
2005
|
+
}
|
|
2006
|
+
if (!sameTaskFinalReviewContract(round.taskFinalReviewContract, candidate.taskFinalReviewContract)) {
|
|
2007
|
+
throw new StorageRecordError(`Task ReviewRound contract does not match its Candidate: ${round.id}.`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (round.reviewerRunId !== undefined) {
|
|
2011
|
+
const reviewerRun = aggregate.agentRuns[round.reviewerRunId];
|
|
2012
|
+
if (reviewerRun === undefined
|
|
2013
|
+
|| reviewerRun.reviewRoundId !== round.id
|
|
2014
|
+
|| reviewerRun.purpose !== "review") {
|
|
2015
|
+
throw new StorageRecordError(`ReviewRound Reviewer Run is invalid: ${round.id}.`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
for (const changeSet of Object.values(aggregate.changeSets)) {
|
|
2020
|
+
if (aggregate.workItems[changeSet.workItemId] === undefined) {
|
|
2021
|
+
throw new StorageRecordError(`ChangeSet Work Item not found: ${changeSet.id}.`);
|
|
2022
|
+
}
|
|
2023
|
+
if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === changeSet.projectId)) {
|
|
2024
|
+
throw new StorageRecordError(`ChangeSet Project does not match Task: ${changeSet.id}.`);
|
|
2025
|
+
}
|
|
2026
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
|
|
2027
|
+
if (evidenceRound !== undefined) {
|
|
2028
|
+
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become a ChangeSet.`);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
for (const integration of Object.values(aggregate.integrationAttempts)) {
|
|
2032
|
+
if (!boundProjects.has(integration.projectId)) {
|
|
2033
|
+
throw new StorageRecordError(`Integration Project does not match Task: ${integration.id}.`);
|
|
2034
|
+
}
|
|
2035
|
+
for (const changeSetId of integration.changeSetIds) {
|
|
2036
|
+
const changeSet = aggregate.changeSets[changeSetId];
|
|
2037
|
+
if (changeSet === undefined) {
|
|
2038
|
+
throw new StorageRecordError(`Integration ChangeSet not found: ${integration.id}/${changeSetId}.`);
|
|
2039
|
+
}
|
|
2040
|
+
if (changeSet.projectId !== integration.projectId) {
|
|
2041
|
+
throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${integration.id}/${changeSetId}.`);
|
|
2042
|
+
}
|
|
2043
|
+
const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
|
|
2044
|
+
if (evidenceRound !== undefined) {
|
|
2045
|
+
throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
for (const workspace of Object.values(aggregate.managedWorkspaces)) {
|
|
2050
|
+
assertManagedWorkspaceReferences(aggregate, workspace, "Managed workspace");
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Enforce the owner-specific scope at the storage boundary. The map key and
|
|
2055
|
+
* foreign-record checks prevent dangling identities; these checks prevent a
|
|
2056
|
+
* valid owner from being paired with another lifecycle's Project scope.
|
|
2057
|
+
*/
|
|
2058
|
+
function assertManagedWorkspaceReferences(aggregate, workspace, label, workItemOverride) {
|
|
2059
|
+
const taskId = aggregate.task.id;
|
|
2060
|
+
const boundProjects = aggregate.task.projectBindings.map(({ projectId }) => projectId).sort();
|
|
2061
|
+
const actualProjects = workspace.entries.map(({ projectId }) => projectId).sort();
|
|
2062
|
+
const requireVisibleTaskScope = () => {
|
|
2063
|
+
// Project bindings are persisted before the physical workspace is
|
|
2064
|
+
// reconciled. During that bounded hand-off a workspace may be missing a
|
|
2065
|
+
// newly-bound Project, but it must never expose a Project outside the
|
|
2066
|
+
// current Task. Launch preparation closes the temporary subset before
|
|
2067
|
+
// the workspace can be used.
|
|
2068
|
+
if (actualProjects.some((projectId) => !boundProjects.includes(projectId))) {
|
|
2069
|
+
throw new StorageRecordError(`${label} Project scope does not match Task: ${taskId}.`);
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
switch (workspace.owner.type) {
|
|
2073
|
+
case "task":
|
|
2074
|
+
requireVisibleTaskScope();
|
|
2075
|
+
if (workspace.entries.some(({ access }) => access !== "write")) {
|
|
2076
|
+
throw new StorageRecordError(`${label} Task workspace must be writable: ${taskId}.`);
|
|
2077
|
+
}
|
|
2078
|
+
return;
|
|
2079
|
+
case "work-item": {
|
|
2080
|
+
const item = workItemOverride?.id === workspace.owner.workItemId
|
|
2081
|
+
? workItemOverride
|
|
2082
|
+
: aggregate.workItems[workspace.owner.workItemId];
|
|
2083
|
+
if (item === undefined) {
|
|
2084
|
+
throw new StorageRecordError(`${label} WorkItem not found: ${taskId}/${workspace.owner.workItemId}.`);
|
|
2085
|
+
}
|
|
2086
|
+
requireVisibleTaskScope();
|
|
2087
|
+
const writable = workspace.entries
|
|
2088
|
+
.filter(({ access }) => access === "write")
|
|
2089
|
+
.map(({ projectId }) => projectId)
|
|
2090
|
+
.sort();
|
|
2091
|
+
const expectedWritable = [...item.writeProjectIds].sort();
|
|
2092
|
+
// Write scope expansion is persisted before the physical WorkItem
|
|
2093
|
+
// workspace is reconciled. The stored workspace may therefore expose
|
|
2094
|
+
// a temporary subset, but it may never grant write access outside the
|
|
2095
|
+
// current WorkItem authorization.
|
|
2096
|
+
if (writable.some((projectId) => !expectedWritable.includes(projectId))) {
|
|
2097
|
+
throw new StorageRecordError(`${label} WorkItem write scope does not match: ${taskId}/${item.id}.`);
|
|
2098
|
+
}
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
case "review-round": {
|
|
2102
|
+
const round = aggregate.reviewRounds[workspace.owner.reviewRoundId];
|
|
2103
|
+
if (round === undefined) {
|
|
2104
|
+
throw new StorageRecordError(`${label} ReviewRound not found: ${taskId}/${workspace.owner.reviewRoundId}.`);
|
|
2105
|
+
}
|
|
2106
|
+
requireVisibleTaskScope();
|
|
2107
|
+
if (workspace.entries.some(({ access }) => access !== "write")) {
|
|
2108
|
+
throw new StorageRecordError(`${label} ReviewRound workspace must be writable: ${taskId}/${round.id}.`);
|
|
2109
|
+
}
|
|
2110
|
+
const item = aggregate.workItems[round.workItemId];
|
|
2111
|
+
const candidate = item?.candidates.find(({ id }) => id === round.candidateId);
|
|
2112
|
+
const frozenProjects = round.scope === "task"
|
|
2113
|
+
? round.taskCandidate?.projects
|
|
2114
|
+
: candidate?.gitSnapshot?.projects;
|
|
2115
|
+
if (frozenProjects !== undefined) {
|
|
2116
|
+
const expected = [...frozenProjects]
|
|
2117
|
+
.map(({ projectId, commit }) => ({ projectId, commit }))
|
|
2118
|
+
.sort((left, right) => left.projectId.localeCompare(right.projectId));
|
|
2119
|
+
const actual = workspace.entries
|
|
2120
|
+
.map(({ projectId, baseCommit }) => ({ projectId, commit: baseCommit }))
|
|
2121
|
+
.sort((left, right) => left.projectId.localeCompare(right.projectId));
|
|
2122
|
+
const matches = expected.length === actual.length
|
|
2123
|
+
&& expected.every((frozen, index) => {
|
|
2124
|
+
const reviewEntry = actual[index];
|
|
2125
|
+
return reviewEntry?.projectId === frozen.projectId
|
|
2126
|
+
&& reviewEntry.commit === frozen.commit;
|
|
2127
|
+
});
|
|
2128
|
+
if (!matches) {
|
|
2129
|
+
const provenance = round.scope === "task"
|
|
2130
|
+
? "Task frozen project set"
|
|
2131
|
+
: "Candidate frozen commit";
|
|
2132
|
+
throw new StorageRecordError(`${label} ReviewRound does not use the ${provenance}: ${taskId}/${round.id}.`);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
case "integration-attempt": {
|
|
2138
|
+
const attempt = aggregate.integrationAttempts[workspace.owner.integrationAttemptId];
|
|
2139
|
+
if (attempt === undefined) {
|
|
2140
|
+
throw new StorageRecordError(`${label} Integration Attempt not found: ${taskId}/${workspace.owner.integrationAttemptId}.`);
|
|
2141
|
+
}
|
|
2142
|
+
if (workspace.entries.length !== 1
|
|
2143
|
+
|| workspace.entries[0].projectId !== attempt.projectId
|
|
2144
|
+
|| workspace.entries[0].access !== "write") {
|
|
2145
|
+
throw new StorageRecordError(`${label} Integration Attempt scope is invalid: ${taskId}/${attempt.id}.`);
|
|
2146
|
+
}
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
case "execution-lane": {
|
|
2150
|
+
const owner = workspace.owner;
|
|
2151
|
+
const laneItem = owner.purpose === "execution"
|
|
2152
|
+
? aggregate.workItems[owner.workItemId ?? ""]
|
|
2153
|
+
: undefined;
|
|
2154
|
+
const group = owner.purpose === "execution"
|
|
2155
|
+
? (laneItem === undefined
|
|
2156
|
+
? undefined
|
|
2157
|
+
: workItemExecutionGroupById(laneItem, owner.executionGroupId))
|
|
2158
|
+
: aggregate.reviewRounds[owner.reviewRoundId ?? ""]?.executionGroup;
|
|
2159
|
+
if (group === undefined || group.id !== owner.executionGroupId
|
|
2160
|
+
|| !group.lanes.some(({ id }) => id === owner.executionLaneId)) {
|
|
2161
|
+
throw new StorageRecordError(`${label} Execution Lane lineage is invalid: ${taskId}/${owner.executionGroupId}/${owner.executionLaneId}.`);
|
|
2162
|
+
}
|
|
2163
|
+
requireVisibleTaskScope();
|
|
2164
|
+
const writable = workspace.entries
|
|
2165
|
+
.filter(({ access }) => access === "write")
|
|
2166
|
+
.map(({ projectId }) => projectId)
|
|
2167
|
+
.sort();
|
|
2168
|
+
const expectedWritable = owner.purpose === "execution"
|
|
2169
|
+
? [...(aggregate.workItems[owner.workItemId ?? ""]?.writeProjectIds ?? [])].sort()
|
|
2170
|
+
: boundProjects;
|
|
2171
|
+
if (!isDeepStrictEqual(writable, expectedWritable)) {
|
|
2172
|
+
throw new StorageRecordError(`${label} Execution Lane write scope does not match: ${taskId}/${owner.executionLaneId}.`);
|
|
2173
|
+
}
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
function validIntegrationTransition(before, after) {
|
|
2179
|
+
if (before.id !== after.id
|
|
2180
|
+
|| before.taskId !== after.taskId
|
|
2181
|
+
|| before.projectId !== after.projectId
|
|
2182
|
+
|| before.targetRef !== after.targetRef
|
|
2183
|
+
|| before.expectedHead !== after.expectedHead
|
|
2184
|
+
|| !isDeepStrictEqual(before.changeSetIds, after.changeSetIds)
|
|
2185
|
+
|| !isDeepStrictEqual(before.checkCommands, after.checkCommands)
|
|
2186
|
+
|| before.createdAt !== after.createdAt)
|
|
2187
|
+
return false;
|
|
2188
|
+
const allowed = {
|
|
2189
|
+
running: ["running", "blocked", "validating", "failed"],
|
|
2190
|
+
blocked: ["blocked", "validating", "failed"],
|
|
2191
|
+
validating: ["validating", "committed", "failed"],
|
|
2192
|
+
committed: ["committed"],
|
|
2193
|
+
failed: ["failed"]
|
|
2194
|
+
};
|
|
2195
|
+
return allowed[before.status].includes(after.status);
|
|
2196
|
+
}
|
|
2197
|
+
function assertAcyclicWorkItems(items) {
|
|
2198
|
+
const visiting = new Set();
|
|
2199
|
+
const visited = new Set();
|
|
2200
|
+
const visit = (id) => {
|
|
2201
|
+
if (visited.has(id))
|
|
2202
|
+
return;
|
|
2203
|
+
if (visiting.has(id))
|
|
2204
|
+
throw new StorageRecordError(`Work Item dependency cycle detected: ${id}.`);
|
|
2205
|
+
visiting.add(id);
|
|
2206
|
+
const item = items[id];
|
|
2207
|
+
if (item !== undefined) {
|
|
2208
|
+
for (const dependencyId of item.dependsOn)
|
|
2209
|
+
visit(dependencyId);
|
|
2210
|
+
}
|
|
2211
|
+
visiting.delete(id);
|
|
2212
|
+
visited.add(id);
|
|
2213
|
+
};
|
|
2214
|
+
for (const id of Object.keys(items))
|
|
2215
|
+
visit(id);
|
|
2216
|
+
}
|
|
2217
|
+
function mailboxReferenceExists(state, ref) {
|
|
2218
|
+
if ("taskId" in ref) {
|
|
2219
|
+
const aggregate = state.tasks[ref.taskId];
|
|
2220
|
+
if (aggregate === undefined)
|
|
2221
|
+
return false;
|
|
2222
|
+
switch (ref.type) {
|
|
2223
|
+
case "run": return aggregate.agentRuns[ref.id] !== undefined;
|
|
2224
|
+
case "work-item": return aggregate.workItems[ref.id] !== undefined;
|
|
2225
|
+
case "input": return aggregate.inputRequests[ref.id] !== undefined;
|
|
2226
|
+
case "message": return aggregate.messages[ref.id] !== undefined;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
switch (ref.type) {
|
|
2230
|
+
case "task": return state.tasks[ref.id] !== undefined;
|
|
2231
|
+
case "session":
|
|
2232
|
+
return [
|
|
2233
|
+
...Object.values(state.globalRoleSessionSets),
|
|
2234
|
+
...Object.values(state.tasks).flatMap((task) => Object.values(task.roleSessionSets))
|
|
2235
|
+
].some((set) => Object.values(set.sessions).some((session) => session.nativeSessionId === ref.id));
|
|
2236
|
+
default: return false;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function validWorkItemTransition(existing, candidate) {
|
|
2240
|
+
if (isDeepStrictEqual(existing, candidate))
|
|
2241
|
+
return true;
|
|
2242
|
+
if (existing.id !== candidate.id
|
|
2243
|
+
|| existing.taskId !== candidate.taskId
|
|
2244
|
+
|| existing.assignee !== candidate.assignee
|
|
2245
|
+
|| existing.createdAt !== candidate.createdAt
|
|
2246
|
+
|| !isDeepStrictEqual(existing.baseRefs, candidate.baseRefs)
|
|
2247
|
+
|| candidate.revision !== existing.revision + 1
|
|
2248
|
+
|| Date.parse(candidate.updatedAt) < Date.parse(existing.updatedAt))
|
|
2249
|
+
return false;
|
|
2250
|
+
if (!compatibleWorkItemExecutionGroups(existing, candidate))
|
|
2251
|
+
return false;
|
|
2252
|
+
if (existing.status !== candidate.status
|
|
2253
|
+
&& (existing.title !== candidate.title
|
|
2254
|
+
|| existing.objective !== candidate.objective
|
|
2255
|
+
|| !isDeepStrictEqual(existing.acceptance, candidate.acceptance)
|
|
2256
|
+
|| !isDeepStrictEqual(existing.dependsOn, candidate.dependsOn)
|
|
2257
|
+
|| !isDeepStrictEqual(existing.writeProjectIds, candidate.writeProjectIds)))
|
|
2258
|
+
return false;
|
|
2259
|
+
const candidateProjects = new Set(candidate.writeProjectIds);
|
|
2260
|
+
if (existing.writeProjectIds.some((projectId) => !candidateProjects.has(projectId))) {
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2263
|
+
const candidatesChanged = !isDeepStrictEqual(existing.candidates, candidate.candidates);
|
|
2264
|
+
const submittedCandidate = existing.status === "running"
|
|
2265
|
+
&& candidate.status === "awaiting_acceptance"
|
|
2266
|
+
&& candidate.candidates.length === existing.candidates.length + 1
|
|
2267
|
+
&& isDeepStrictEqual(candidate.candidates.slice(0, existing.candidates.length), existing.candidates);
|
|
2268
|
+
if (candidatesChanged && !submittedCandidate) {
|
|
2269
|
+
return false;
|
|
2270
|
+
}
|
|
2271
|
+
const allowed = {
|
|
2272
|
+
pending: ["pending", "running", "retired"],
|
|
2273
|
+
running: [
|
|
2274
|
+
"running",
|
|
2275
|
+
"awaiting_acceptance",
|
|
2276
|
+
"completed",
|
|
2277
|
+
"failed",
|
|
2278
|
+
"retired"
|
|
2279
|
+
],
|
|
2280
|
+
awaiting_acceptance: [
|
|
2281
|
+
"awaiting_acceptance",
|
|
2282
|
+
"completed",
|
|
2283
|
+
"failed",
|
|
2284
|
+
"retired"
|
|
2285
|
+
],
|
|
2286
|
+
completed: ["completed"],
|
|
2287
|
+
failed: ["failed", "running", "retired"],
|
|
2288
|
+
retired: ["retired"]
|
|
2289
|
+
};
|
|
2290
|
+
return allowed[existing.status].includes(candidate.status);
|
|
2291
|
+
}
|
|
2292
|
+
function compatibleWorkItemExecutionGroups(existing, candidate) {
|
|
2293
|
+
if (candidate.executionGroups.length < existing.executionGroups.length)
|
|
2294
|
+
return false;
|
|
2295
|
+
for (const [index, historical] of existing.executionGroups.entries()) {
|
|
2296
|
+
const next = candidate.executionGroups[index];
|
|
2297
|
+
if (next === undefined || next.id !== historical.id)
|
|
2298
|
+
return false;
|
|
2299
|
+
const isCurrent = existing.currentExecutionGroupId === historical.id;
|
|
2300
|
+
const mutableCurrent = isCurrent && historical.resolution === undefined;
|
|
2301
|
+
if (!mutableCurrent && !isDeepStrictEqual(historical, next))
|
|
2302
|
+
return false;
|
|
2303
|
+
if (mutableCurrent && !compatibleExecutionGroups(historical, next))
|
|
2304
|
+
return false;
|
|
2305
|
+
}
|
|
2306
|
+
const appended = candidate.executionGroups.slice(existing.executionGroups.length);
|
|
2307
|
+
if (appended.length > 1)
|
|
2308
|
+
return false;
|
|
2309
|
+
if (appended.length === 1) {
|
|
2310
|
+
const priorCurrent = existing.currentExecutionGroupId === undefined
|
|
2311
|
+
? undefined
|
|
2312
|
+
: workItemExecutionGroupById(existing, existing.currentExecutionGroupId);
|
|
2313
|
+
if (priorCurrent !== undefined && priorCurrent.resolution === undefined)
|
|
2314
|
+
return false;
|
|
2315
|
+
if (candidate.currentExecutionGroupId !== appended[0].id)
|
|
2316
|
+
return false;
|
|
2317
|
+
}
|
|
2318
|
+
else if (candidate.currentExecutionGroupId !== existing.currentExecutionGroupId) {
|
|
2319
|
+
const priorCurrent = existing.currentExecutionGroupId === undefined
|
|
2320
|
+
? undefined
|
|
2321
|
+
: workItemExecutionGroupById(existing, existing.currentExecutionGroupId);
|
|
2322
|
+
const clearingResolvedRetry = existing.status === "failed"
|
|
2323
|
+
&& candidate.status === "running"
|
|
2324
|
+
&& candidate.currentExecutionGroupId === undefined
|
|
2325
|
+
&& priorCurrent?.resolution !== undefined;
|
|
2326
|
+
if (!clearingResolvedRetry)
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
if (candidate.currentExecutionGroupId !== undefined
|
|
2330
|
+
&& workItemExecutionGroupById(candidate, candidate.currentExecutionGroupId) === undefined) {
|
|
2331
|
+
return false;
|
|
2332
|
+
}
|
|
2333
|
+
return true;
|
|
2334
|
+
}
|
|
2335
|
+
function assertWorkItemCandidateReferences(aggregate, item, candidate, label) {
|
|
2336
|
+
if (candidate.workItemRevision > item.revision) {
|
|
2337
|
+
throw new StorageRecordError(`${label} revision is invalid.`);
|
|
2338
|
+
}
|
|
2339
|
+
if ((candidate.executionGroupId === undefined) !== (candidate.executionLaneId === undefined)) {
|
|
2340
|
+
throw new StorageRecordError(`${label} execution lineage is incomplete.`);
|
|
2341
|
+
}
|
|
2342
|
+
if (candidate.executionGroupId !== undefined) {
|
|
2343
|
+
const group = workItemExecutionGroupById(item, candidate.executionGroupId);
|
|
2344
|
+
const lane = group?.lanes.find(({ id }) => id === candidate.executionLaneId);
|
|
2345
|
+
if (group === undefined
|
|
2346
|
+
|| group.id !== candidate.executionGroupId
|
|
2347
|
+
|| lane === undefined) {
|
|
2348
|
+
throw new StorageRecordError(`${label} execution lineage is invalid: candidate=${candidate.executionGroupId}/${candidate.executionLaneId}; `
|
|
2349
|
+
+ `item=${group?.id ?? "none"}/${lane?.id ?? "none"}.`);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
if (candidate.workspace !== undefined) {
|
|
2353
|
+
if (candidate.workspace.owner.taskId !== item.taskId) {
|
|
2354
|
+
throw new StorageRecordError(`${label} workspace belongs to another Task.`);
|
|
2355
|
+
}
|
|
2356
|
+
if (candidate.workspace.owner.type === "work-item"
|
|
2357
|
+
&& candidate.workspace.owner.workItemId !== item.id) {
|
|
2358
|
+
throw new StorageRecordError(`${label} workspace belongs to another Work Item.`);
|
|
2359
|
+
}
|
|
2360
|
+
if (candidate.workspace.owner.type !== "work-item") {
|
|
2361
|
+
throw new StorageRecordError(`${label} must use the WorkItem-owned Develop workspace.`);
|
|
2362
|
+
}
|
|
2363
|
+
assertManagedWorkspaceReferences(aggregate, candidate.workspace, label, item);
|
|
2364
|
+
}
|
|
2365
|
+
if (candidate.source.type === "direct") {
|
|
2366
|
+
if (item.assignee !== undefined) {
|
|
2367
|
+
throw new StorageRecordError(`${label} cannot be direct for an assigned Work Item.`);
|
|
2368
|
+
}
|
|
2369
|
+
if (candidate.workspace !== undefined
|
|
2370
|
+
&& (candidate.workspace.owner.type !== "work-item"
|
|
2371
|
+
|| candidate.workspace.owner.taskId !== item.taskId
|
|
2372
|
+
|| candidate.workspace.owner.workItemId !== item.id)) {
|
|
2373
|
+
throw new StorageRecordError(`${label} must use the WorkItem-owned Develop workspace.`);
|
|
2374
|
+
}
|
|
2375
|
+
if (candidate.taskMainSnapshot !== undefined) {
|
|
2376
|
+
const expectedProjects = [...item.writeProjectIds].sort();
|
|
2377
|
+
const actualProjects = candidate.taskMainSnapshot.projects
|
|
2378
|
+
.map(({ projectId }) => projectId)
|
|
2379
|
+
.sort();
|
|
2380
|
+
if (!isDeepStrictEqual(actualProjects, expectedProjects)) {
|
|
2381
|
+
throw new StorageRecordError(`${label} Task-main snapshot scope is stale.`);
|
|
2382
|
+
}
|
|
2383
|
+
for (const project of candidate.taskMainSnapshot.projects) {
|
|
2384
|
+
const binding = aggregate.task.projectBindings.find(({ projectId }) => projectId === project.projectId);
|
|
2385
|
+
if (binding === undefined || binding.directory !== project.directory) {
|
|
2386
|
+
throw new StorageRecordError(`${label} Task-main snapshot Project is not bound: ${project.projectId}.`);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const run = aggregate.agentRuns[candidate.source.runId];
|
|
2393
|
+
const resolvedGroupSummary = candidate.executionGroupId === undefined
|
|
2394
|
+
? undefined
|
|
2395
|
+
: workItemExecutionGroupById(item, candidate.executionGroupId)?.resolution?.summary;
|
|
2396
|
+
if (run === undefined
|
|
2397
|
+
|| run.workItemId !== item.id
|
|
2398
|
+
|| run.purpose !== "execution"
|
|
2399
|
+
|| run.status !== "yielded"
|
|
2400
|
+
|| (run.summary !== candidate.summary && resolvedGroupSummary !== candidate.summary)) {
|
|
2401
|
+
throw new StorageRecordError(`${label} Run is invalid: ${candidate.source.runId}.`);
|
|
2402
|
+
}
|
|
2403
|
+
if (candidate.executionGroupId !== run.executionGroupId
|
|
2404
|
+
|| candidate.executionLaneId !== run.executionLaneId) {
|
|
2405
|
+
throw new StorageRecordError(`${label} execution lineage does not match its source Run.`);
|
|
2406
|
+
}
|
|
2407
|
+
// A Gitless execution Run still carries its durable Task-owned empty view
|
|
2408
|
+
// for runtime fencing, while its Candidate intentionally has no Develop
|
|
2409
|
+
// workspace or Git snapshot. This is the only source/run workspace
|
|
2410
|
+
// mismatch permitted at the storage boundary.
|
|
2411
|
+
const gitlessRunWorkspace = run.workspace?.owner.type === "task"
|
|
2412
|
+
&& run.workspace.owner.taskId === item.taskId
|
|
2413
|
+
&& aggregate.task.projectBindings.length === 0
|
|
2414
|
+
&& run.workspace.entries.length === 0
|
|
2415
|
+
&& (() => {
|
|
2416
|
+
const durable = aggregate.managedWorkspaces[managedWorkspaceKey(run.workspace.owner)];
|
|
2417
|
+
return durable !== undefined && isDeepStrictEqual(durable, run.workspace);
|
|
2418
|
+
})();
|
|
2419
|
+
if (!gitlessRunWorkspace
|
|
2420
|
+
&& (candidate.workspace === undefined) !== (run.workspace === undefined)) {
|
|
2421
|
+
throw new StorageRecordError(`${label} workspace does not match its source Run.`);
|
|
2422
|
+
}
|
|
2423
|
+
if (!gitlessRunWorkspace && candidate.workspace !== undefined && run.workspace !== undefined) {
|
|
2424
|
+
assertCandidateWorkspaceMatchesRun(candidate.workspace, run.workspace, label);
|
|
2425
|
+
}
|
|
2426
|
+
if (candidate.workspace !== undefined && (candidate.workspace.owner.type !== "work-item"
|
|
2427
|
+
|| candidate.workspace.owner.taskId !== item.taskId
|
|
2428
|
+
|| candidate.workspace.owner.workItemId !== item.id)) {
|
|
2429
|
+
throw new StorageRecordError(`${label} must use the WorkItem-owned Develop workspace.`);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
function assertAgentRunExecutionReferences(aggregate, run) {
|
|
2433
|
+
if ((run.executionGroupId === undefined) !== (run.executionLaneId === undefined)) {
|
|
2434
|
+
throw new StorageRecordError(`Agent Run execution lineage is incomplete: ${run.id}.`);
|
|
2435
|
+
}
|
|
2436
|
+
if (run.executionGroupId === undefined)
|
|
2437
|
+
return;
|
|
2438
|
+
const ownerGroup = run.purpose === "review"
|
|
2439
|
+
? (run.reviewRoundId === undefined
|
|
2440
|
+
? undefined
|
|
2441
|
+
: aggregate.reviewRounds[run.reviewRoundId]?.executionGroup)
|
|
2442
|
+
: (run.workItemId === undefined || run.executionGroupId === undefined
|
|
2443
|
+
? undefined
|
|
2444
|
+
: (() => {
|
|
2445
|
+
const item = aggregate.workItems[run.workItemId];
|
|
2446
|
+
return item === undefined
|
|
2447
|
+
? undefined
|
|
2448
|
+
: workItemExecutionGroupById(item, run.executionGroupId);
|
|
2449
|
+
})());
|
|
2450
|
+
if (ownerGroup === undefined) {
|
|
2451
|
+
throw new StorageRecordError(`Agent Run ExecutionGroup not found: ${run.id}.`);
|
|
2452
|
+
}
|
|
2453
|
+
validateExecutionGroup(ownerGroup);
|
|
2454
|
+
const lane = ownerGroup.lanes.find(({ id }) => id === run.executionLaneId);
|
|
2455
|
+
if (ownerGroup.id !== run.executionGroupId || lane === undefined) {
|
|
2456
|
+
throw new StorageRecordError(`Agent Run ExecutionLane does not match its owner: ${run.id}.`);
|
|
2457
|
+
}
|
|
2458
|
+
if (lane.roleName !== run.roleName) {
|
|
2459
|
+
throw new StorageRecordError(`Agent Run Role does not match its ExecutionLane: ${run.id}.`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
function compatibleExecutionGroups(existing, candidate) {
|
|
2463
|
+
if (existing === undefined)
|
|
2464
|
+
return true;
|
|
2465
|
+
if (candidate === undefined)
|
|
2466
|
+
return false;
|
|
2467
|
+
return isExecutionGroupTransition(existing, candidate);
|
|
2468
|
+
}
|
|
2469
|
+
/** Candidate freezes Git commits at yield time, so timestamp/baseCommit fields
|
|
2470
|
+
* may differ from the Run's dispatch snapshot while workspace identity and
|
|
2471
|
+
* execution scope must remain exact. */
|
|
2472
|
+
function assertCandidateWorkspaceMatchesRun(candidate, run, label) {
|
|
2473
|
+
if (candidate.owner.type === "work-item" && run.owner.type === "execution-lane") {
|
|
2474
|
+
if (candidate.owner.taskId !== run.owner.taskId
|
|
2475
|
+
|| run.owner.purpose !== "execution"
|
|
2476
|
+
|| candidate.owner.workItemId !== run.owner.workItemId
|
|
2477
|
+
|| candidate.entries.length !== run.entries.length) {
|
|
2478
|
+
throw new StorageRecordError(`${label} workspace lineage does not match its source Lane.`);
|
|
2479
|
+
}
|
|
2480
|
+
for (const source of run.entries) {
|
|
2481
|
+
const target = candidate.entries.find(({ projectId }) => projectId === source.projectId);
|
|
2482
|
+
if (target === undefined || target.directory !== source.directory || target.access !== source.access) {
|
|
2483
|
+
throw new StorageRecordError(`${label} workspace Project scope does not match its source Lane.`);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
if (candidate.owner.type !== run.owner.type
|
|
2489
|
+
|| candidate.owner.taskId !== run.owner.taskId
|
|
2490
|
+
|| candidate.root !== run.root
|
|
2491
|
+
|| candidate.entries.length !== run.entries.length) {
|
|
2492
|
+
throw new StorageRecordError(`${label} workspace does not match its source Run.`);
|
|
2493
|
+
}
|
|
2494
|
+
for (const source of run.entries) {
|
|
2495
|
+
const frozen = candidate.entries.find(({ projectId }) => projectId === source.projectId);
|
|
2496
|
+
if (frozen === undefined
|
|
2497
|
+
|| frozen.directory !== source.directory
|
|
2498
|
+
|| frozen.access !== source.access
|
|
2499
|
+
|| frozen.path !== source.path
|
|
2500
|
+
|| frozen.branch !== source.branch
|
|
2501
|
+
|| frozen.baseRef !== source.baseRef) {
|
|
2502
|
+
throw new StorageRecordError(`${label} workspace scope does not match its source Run.`);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
function validReviewRoundTransition(existing, candidate) {
|
|
2507
|
+
if (isDeepStrictEqual(existing, candidate))
|
|
2508
|
+
return true;
|
|
2509
|
+
if (existing.id !== candidate.id
|
|
2510
|
+
|| existing.taskId !== candidate.taskId
|
|
2511
|
+
|| existing.workItemId !== candidate.workItemId
|
|
2512
|
+
|| existing.candidateId !== candidate.candidateId
|
|
2513
|
+
|| existing.reviewerRoleName !== candidate.reviewerRoleName
|
|
2514
|
+
|| existing.reviewBaseCommit !== candidate.reviewBaseCommit
|
|
2515
|
+
|| (existing.scope ?? "work-item") !== (candidate.scope ?? "work-item")
|
|
2516
|
+
|| !isDeepStrictEqual(existing.taskCandidate, candidate.taskCandidate)
|
|
2517
|
+
|| !sameTaskFinalReviewContract(existing.taskFinalReviewContract, candidate.taskFinalReviewContract)
|
|
2518
|
+
|| !compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup)
|
|
2519
|
+
|| existing.requestedBy !== candidate.requestedBy
|
|
2520
|
+
|| existing.createdAt !== candidate.createdAt)
|
|
2521
|
+
return false;
|
|
2522
|
+
if (existing.status === "pending") {
|
|
2523
|
+
if (candidate.status === "pending") {
|
|
2524
|
+
return (existing.workspace === undefined
|
|
2525
|
+
&& candidate.workspace !== undefined
|
|
2526
|
+
|| existing.executionGroup === undefined
|
|
2527
|
+
&& candidate.executionGroup !== undefined
|
|
2528
|
+
|| existing.executionGroup !== undefined
|
|
2529
|
+
&& candidate.executionGroup !== undefined
|
|
2530
|
+
&& !isDeepStrictEqual(existing.executionGroup, candidate.executionGroup))
|
|
2531
|
+
&& candidate.reviewerRunId === undefined
|
|
2532
|
+
&& candidate.summary === undefined
|
|
2533
|
+
&& candidate.checks === undefined
|
|
2534
|
+
&& candidate.evidenceCommit === undefined
|
|
2535
|
+
&& candidate.endedAt === undefined
|
|
2536
|
+
&& candidate.workspaceDisposition === undefined;
|
|
2537
|
+
}
|
|
2538
|
+
return ["running", "failed"].includes(candidate.status)
|
|
2539
|
+
&& (existing.workspace === undefined
|
|
2540
|
+
|| isDeepStrictEqual(existing.workspace, candidate.workspace));
|
|
2541
|
+
}
|
|
2542
|
+
if (existing.status === "running") {
|
|
2543
|
+
if (candidate.status === "running") {
|
|
2544
|
+
return existing.reviewerRunId === candidate.reviewerRunId
|
|
2545
|
+
&& isDeepStrictEqual(existing.workspace, candidate.workspace)
|
|
2546
|
+
&& existing.executionGroup !== undefined
|
|
2547
|
+
&& candidate.executionGroup !== undefined
|
|
2548
|
+
&& !isDeepStrictEqual(existing.executionGroup, candidate.executionGroup)
|
|
2549
|
+
&& candidate.summary === undefined
|
|
2550
|
+
&& candidate.report === undefined
|
|
2551
|
+
&& candidate.checks === undefined
|
|
2552
|
+
&& candidate.evidenceCommit === undefined
|
|
2553
|
+
&& candidate.endedAt === undefined
|
|
2554
|
+
&& candidate.workspaceDisposition === undefined;
|
|
2555
|
+
}
|
|
2556
|
+
return ["completed", "failed"].includes(candidate.status)
|
|
2557
|
+
&& existing.reviewerRunId === candidate.reviewerRunId
|
|
2558
|
+
&& isDeepStrictEqual(existing.workspace, candidate.workspace);
|
|
2559
|
+
}
|
|
2560
|
+
if (existing.status === candidate.status
|
|
2561
|
+
&& (existing.status === "completed" || existing.status === "failed")) {
|
|
2562
|
+
const { workspaceDisposition: _existingDisposition, ...existingResult } = existing;
|
|
2563
|
+
const { workspaceDisposition: _candidateDisposition, ...candidateResult } = candidate;
|
|
2564
|
+
return isDeepStrictEqual(existingResult, candidateResult)
|
|
2565
|
+
&& existing.workspaceDisposition?.kind !== "removed"
|
|
2566
|
+
&& candidate.workspaceDisposition !== undefined;
|
|
2567
|
+
}
|
|
2568
|
+
return false;
|
|
988
2569
|
}
|
|
989
2570
|
function synchronousResult(value) { if (typeof value === "object" && value !== null && "then" in value)
|
|
990
2571
|
throw new StorageRecordError("FileTaskStore transactions must be synchronous."); return value; }
|
|
@@ -1008,6 +2589,21 @@ function acquireStorageLock(rootDir) {
|
|
|
1008
2589
|
}
|
|
1009
2590
|
}
|
|
1010
2591
|
}
|
|
2592
|
+
/**
|
|
2593
|
+
* Run `execute` while holding the same storage write lock the store uses, without
|
|
2594
|
+
* the version-gated {@link FileTaskStore} constructor. The upgrade orchestrator
|
|
2595
|
+
* uses this to re-pin the committed revision under the lock against a source Home
|
|
2596
|
+
* whose schema is not the current version (so a store cannot be constructed yet).
|
|
2597
|
+
*/
|
|
2598
|
+
export function withStorageWriteLock(rootDir, execute) {
|
|
2599
|
+
const release = acquireStorageLock(rootDir);
|
|
2600
|
+
try {
|
|
2601
|
+
return execute();
|
|
2602
|
+
}
|
|
2603
|
+
finally {
|
|
2604
|
+
release();
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
1011
2607
|
function reclaimDeadLock(lock) {
|
|
1012
2608
|
try {
|
|
1013
2609
|
const age = Date.now() - statSync(lock).mtimeMs;
|