@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
|
@@ -1,256 +1,1753 @@
|
|
|
1
|
-
import { rmdir } from "node:fs/promises";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { lstat, mkdir, readlink, readdir, rmdir, symlink, unlink } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
|
|
3
5
|
import { updateRole } from "../role/role.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
+
import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
|
|
7
|
+
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
|
|
8
|
+
import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
|
|
9
|
+
import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
|
|
10
|
+
const MAIN_WORKTREE = "main";
|
|
11
|
+
const LEADER_ROLE = "leader";
|
|
12
|
+
export class WorkspaceCleanupBlockedError extends Error {
|
|
13
|
+
reason;
|
|
14
|
+
resource;
|
|
15
|
+
retryable;
|
|
16
|
+
constructor(reason, resource, retryable, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.reason = reason;
|
|
19
|
+
this.resource = resource;
|
|
20
|
+
this.retryable = retryable;
|
|
21
|
+
this.name = "WorkspaceCleanupBlockedError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Existing ReviewRound workspace evidence must never be replaced during recovery. */
|
|
25
|
+
export class ReviewRoundWorkspaceEvidenceError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ReviewRoundWorkspaceEvidenceError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
6
31
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
32
|
+
* A Task owns one workspace root containing every bound Project. A WorkItem
|
|
33
|
+
* owns another root: writable Projects point at isolated worktrees while the
|
|
34
|
+
* remaining entries point at the Task main worktrees as read-only context.
|
|
10
35
|
*/
|
|
11
36
|
export class FileTaskWorkspacePreparer {
|
|
37
|
+
home;
|
|
12
38
|
store;
|
|
13
39
|
git;
|
|
14
40
|
now;
|
|
15
|
-
worktreeRoot;
|
|
16
41
|
constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date()) {
|
|
42
|
+
this.home = home;
|
|
17
43
|
this.store = store;
|
|
18
44
|
this.git = git;
|
|
19
45
|
this.now = now;
|
|
20
|
-
this.worktreeRoot = join(resolve(home), "worktrees");
|
|
21
46
|
}
|
|
22
47
|
async prepareTaskWorkspace(taskId) {
|
|
23
48
|
const task = requireTask(this.store, taskId);
|
|
24
|
-
if (task.
|
|
25
|
-
|
|
26
|
-
taskId,
|
|
27
|
-
status: task.status === "draft" ? "draft" : "ready",
|
|
28
|
-
...(task.cwd === undefined ? {} : { path: task.cwd })
|
|
29
|
-
};
|
|
49
|
+
if (!["draft", "active"].includes(task.status)) {
|
|
50
|
+
throw new Error(`Task is not open for workspace preparation: ${task.id}.`);
|
|
30
51
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
if (task.projectBindings.length === 0) {
|
|
53
|
+
const existing = this.store.getTaskWorkspace(task.id);
|
|
54
|
+
const root = this.#taskWorkspaceRoot(task.id);
|
|
55
|
+
if (existing !== null && (existing.owner.type !== "task"
|
|
56
|
+
|| existing.owner.taskId !== task.id
|
|
57
|
+
|| existing.root !== root
|
|
58
|
+
|| existing.entries.length !== 0)) {
|
|
59
|
+
throw new Error(`Gitless Task workspace ownership is invalid: ${task.id}.`);
|
|
60
|
+
}
|
|
61
|
+
// Gitless Tasks still need a durable runtime owner. The empty view is
|
|
62
|
+
// Task-specific, so launches cannot fall back to a global workspace or
|
|
63
|
+
// repository root while no Project is bound.
|
|
64
|
+
await ensureWorkspaceView(root, []);
|
|
65
|
+
const workspace = createManagedWorkspace({
|
|
66
|
+
owner: { type: "task", taskId: task.id },
|
|
67
|
+
root,
|
|
68
|
+
entries: []
|
|
69
|
+
}, this.now());
|
|
70
|
+
this.store.transaction((tx) => {
|
|
71
|
+
const latest = requireTask(tx, task.id);
|
|
72
|
+
if (!['draft', 'active'].includes(latest.status)
|
|
73
|
+
|| latest.projectBindings.length !== 0) {
|
|
74
|
+
throw new Error(`Task changed while preparing its Gitless workspace: ${task.id}.`);
|
|
75
|
+
}
|
|
76
|
+
const current = tx.getTaskWorkspace(task.id);
|
|
77
|
+
// `createManagedWorkspace` stamps a fresh updatedAt on every call.
|
|
78
|
+
// Gitless preparation is idempotent, so compare only stable identity
|
|
79
|
+
// and retain the existing durable record verbatim.
|
|
80
|
+
if (current !== null && !sameManagedWorkspaceIdentity(current, workspace)) {
|
|
81
|
+
throw new Error(`Gitless Task workspace changed during preparation: ${task.id}.`);
|
|
82
|
+
}
|
|
83
|
+
const timestamp = this.now();
|
|
84
|
+
if (latest.cwd !== root) {
|
|
85
|
+
tx.saveTask({ ...latest, cwd: root, updatedAt: timestamp.toISOString() });
|
|
86
|
+
}
|
|
87
|
+
if (current === null)
|
|
88
|
+
tx.saveManagedWorkspace(workspace);
|
|
89
|
+
for (const role of tx.listRoles(task.id)) {
|
|
90
|
+
if (role.workspace !== root) {
|
|
91
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
92
|
+
tx.saveRole(task.id, updateRole(role, { workspace: root }, timestamp));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
55
95
|
});
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
96
|
+
return { taskId, status: "ready", path: root };
|
|
97
|
+
}
|
|
98
|
+
const existing = this.store.getTaskWorkspace(task.id);
|
|
99
|
+
if (existing !== null && existing.owner.type !== "task") {
|
|
100
|
+
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
101
|
+
}
|
|
102
|
+
const root = this.#taskWorkspaceRoot(task.id);
|
|
103
|
+
const prepared = [];
|
|
104
|
+
const defaultProjects = remoteDefaultProjects(this.store, task.id);
|
|
105
|
+
const baselines = new Map();
|
|
106
|
+
try {
|
|
107
|
+
// Resolve every first-use baseline before creating any managed worktree.
|
|
108
|
+
// A later Project failure therefore cannot leave an earlier Project's
|
|
109
|
+
// workspace behind, and the Task can only be pinned after all evidence
|
|
110
|
+
// has been gathered successfully.
|
|
111
|
+
for (const binding of task.projectBindings) {
|
|
112
|
+
const project = requireProject(this.store, binding.projectId);
|
|
113
|
+
const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
|
|
114
|
+
if (previous !== undefined) {
|
|
115
|
+
baselines.set(project.id, {
|
|
116
|
+
baseRef: previous.baseCommit,
|
|
117
|
+
recordedBaseRef: previous.baseRef
|
|
118
|
+
});
|
|
69
119
|
continue;
|
|
70
|
-
|
|
71
|
-
const
|
|
120
|
+
}
|
|
121
|
+
const useRemoteDefault = defaultProjects.has(project.id)
|
|
122
|
+
&& project.remoteUrl !== undefined
|
|
123
|
+
&& !looksLikeCommit(binding.baseRef);
|
|
124
|
+
if (useRemoteDefault) {
|
|
125
|
+
const resolver = this.git.resolveRemoteBaseline;
|
|
126
|
+
if (typeof resolver !== "function") {
|
|
127
|
+
throw new Error(`Git workspace cannot resolve the remote baseline for Project: ${project.id}.`);
|
|
128
|
+
}
|
|
129
|
+
const remote = await resolver.call(this.git, {
|
|
130
|
+
repositoryPath: project.path,
|
|
131
|
+
remoteUrl: project.remoteUrl,
|
|
132
|
+
// The binding captured the configured development ref at Task
|
|
133
|
+
// creation; use that snapshot even if the Project catalog was
|
|
134
|
+
// edited before this first workspace preparation.
|
|
135
|
+
developmentRef: binding.baseRef
|
|
136
|
+
});
|
|
137
|
+
baselines.set(project.id, {
|
|
138
|
+
baseRef: remote.commit,
|
|
139
|
+
recordedBaseRef: remote.commit,
|
|
140
|
+
expectedCommit: remote.commit,
|
|
141
|
+
pinTask: true
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// Validate local and explicit refs in the same preflight phase. This
|
|
146
|
+
// preserves the fail-closed boundary before any worktree is created.
|
|
147
|
+
await this.git.inspect(project.path, binding.baseRef);
|
|
148
|
+
baselines.set(project.id, {
|
|
149
|
+
baseRef: binding.baseRef,
|
|
150
|
+
recordedBaseRef: binding.baseRef
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
for (const binding of task.projectBindings) {
|
|
154
|
+
const project = requireProject(this.store, binding.projectId);
|
|
155
|
+
const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
|
|
156
|
+
const baseline = baselines.get(project.id);
|
|
157
|
+
if (baseline === undefined) {
|
|
158
|
+
throw new Error(`Task Project baseline was not resolved: ${project.id}.`);
|
|
159
|
+
}
|
|
160
|
+
const physical = await this.git.ensureWorktree({
|
|
161
|
+
repositoryPath: project.path,
|
|
162
|
+
container: this.#projectContainer(project.name),
|
|
72
163
|
taskId: task.id,
|
|
73
|
-
roleName:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
164
|
+
roleName: MAIN_WORKTREE,
|
|
165
|
+
baseRef: previous?.baseCommit ?? baseline.baseRef
|
|
166
|
+
});
|
|
167
|
+
if (baseline.expectedCommit !== undefined
|
|
168
|
+
&& physical.baseCommit !== baseline.expectedCommit) {
|
|
169
|
+
throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
|
|
170
|
+
}
|
|
171
|
+
prepared.push({
|
|
172
|
+
project,
|
|
173
|
+
entry: {
|
|
174
|
+
projectId: project.id,
|
|
175
|
+
directory: binding.directory,
|
|
176
|
+
access: "write",
|
|
177
|
+
path: physical.path,
|
|
178
|
+
branch: physical.branch,
|
|
179
|
+
baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
|
|
180
|
+
baseCommit: physical.baseCommit
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
185
|
+
const workspace = createManagedWorkspace({
|
|
186
|
+
owner: { type: "task", taskId: task.id },
|
|
187
|
+
root,
|
|
188
|
+
entries: prepared.map(({ entry }) => entry)
|
|
189
|
+
}, this.now());
|
|
190
|
+
this.store.transaction((tx) => {
|
|
191
|
+
const latest = requireTask(tx, task.id);
|
|
192
|
+
if (!["draft", "active"].includes(latest.status)) {
|
|
193
|
+
throw new Error(`Task changed while preparing its workspace: ${task.id}.`);
|
|
194
|
+
}
|
|
195
|
+
if (!isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
|
|
196
|
+
throw new Error(`Task Projects changed while preparing its workspace: ${task.id}.`);
|
|
197
|
+
}
|
|
198
|
+
const current = tx.getTaskWorkspace(task.id);
|
|
199
|
+
if (current !== null && current.owner.type !== "task") {
|
|
200
|
+
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
201
|
+
}
|
|
202
|
+
const pinnedBindings = latest.projectBindings.map((binding) => {
|
|
203
|
+
const baseline = baselines.get(binding.projectId);
|
|
204
|
+
return baseline?.pinTask === true
|
|
205
|
+
? { ...binding, baseRef: baseline.baseRef }
|
|
206
|
+
: binding;
|
|
207
|
+
});
|
|
208
|
+
const timestamp = this.now();
|
|
209
|
+
let persistedTask = isDeepStrictEqual(pinnedBindings, latest.projectBindings)
|
|
210
|
+
? latest
|
|
211
|
+
: { ...latest, projectBindings: pinnedBindings, updatedAt: timestamp.toISOString() };
|
|
212
|
+
if (persistedTask.cwd !== root) {
|
|
213
|
+
persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
|
|
214
|
+
}
|
|
215
|
+
if (!isDeepStrictEqual(persistedTask, latest)) {
|
|
216
|
+
tx.saveTask(persistedTask);
|
|
217
|
+
}
|
|
218
|
+
tx.saveManagedWorkspace(preserveWorkspaceCreatedAt(workspace, current));
|
|
219
|
+
for (const role of tx.listRoles(task.id)) {
|
|
220
|
+
// The Role field is only a cwd/snapshot hint. Preserve the hint for
|
|
221
|
+
// an active WorkItem assignment; the durable owner is the WorkItem,
|
|
222
|
+
// not this Role record. Other Roles use Task main.
|
|
223
|
+
const assignedItem = tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
|
|
224
|
+
&& !["completed", "failed", "retired"]
|
|
225
|
+
.includes(candidate.status)));
|
|
226
|
+
const assignedWorkspace = assignedItem === undefined
|
|
227
|
+
? null
|
|
228
|
+
: tx.getWorkItemWorkspace(task.id, assignedItem.id);
|
|
229
|
+
const target = assignedWorkspace?.root ?? root;
|
|
230
|
+
if (role.workspace !== target) {
|
|
231
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
232
|
+
tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return { taskId, status: "ready", path: root };
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
await this.#discardUnadoptedEntries(task, prepared, MAIN_WORKTREE);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async snapshotCandidateWorkspace(workspace) {
|
|
244
|
+
if (workspace.owner.type === "review-round") {
|
|
245
|
+
throw new Error("ReviewRound workspace cannot become a WorkItem Candidate source.");
|
|
246
|
+
}
|
|
247
|
+
if (workspace.owner.type !== "work-item") {
|
|
248
|
+
throw new Error("Only a WorkItem workspace can become a Candidate source.");
|
|
249
|
+
}
|
|
250
|
+
const projects = [];
|
|
251
|
+
for (const entry of workspace.entries) {
|
|
252
|
+
if (!await this.git.isClean(entry.path)) {
|
|
253
|
+
throw new Error(`Candidate Project workspace must be clean and committed before review: ${entry.projectId}.`);
|
|
254
|
+
}
|
|
255
|
+
const branch = await this.git.headRef(entry.path);
|
|
256
|
+
if (branch !== entry.branch) {
|
|
257
|
+
throw new Error(`Candidate Project workspace left its managed branch: ${entry.projectId}/${branch}.`);
|
|
258
|
+
}
|
|
259
|
+
projects.push({
|
|
260
|
+
projectId: entry.projectId,
|
|
261
|
+
commit: (await this.git.inspect(entry.path, "HEAD")).baseCommit
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return createCandidateGitSnapshot(workspace, projects);
|
|
265
|
+
}
|
|
266
|
+
/** Freeze every writable Project head from the exact managed Lane workspace. */
|
|
267
|
+
async snapshotExecutionLaneWorkspace(workspace) {
|
|
268
|
+
if (workspace.owner.type !== "execution-lane"
|
|
269
|
+
&& workspace.owner.type !== "work-item"
|
|
270
|
+
&& workspace.owner.type !== "review-round") {
|
|
271
|
+
throw new Error("Only a managed execution workspace can freeze Lane output.");
|
|
272
|
+
}
|
|
273
|
+
const stored = this.store.getManagedWorkspace(workspace.owner);
|
|
274
|
+
if (stored === null || !isDeepStrictEqual(stored, workspace)) {
|
|
275
|
+
throw new Error("Execution Lane managed workspace is not the durable owner.");
|
|
276
|
+
}
|
|
277
|
+
const writable = workspace.entries.filter(({ access }) => access === "write");
|
|
278
|
+
// Read-only and Gitless Lanes still have a normal terminal lifecycle, but
|
|
279
|
+
// they have no Git output boundary to freeze. Keep the result absent
|
|
280
|
+
// instead of inventing a snapshot from context-only worktrees.
|
|
281
|
+
if (writable.length === 0)
|
|
282
|
+
return undefined;
|
|
283
|
+
const projects = [];
|
|
284
|
+
for (const entry of writable) {
|
|
285
|
+
if (!await this.git.isClean(entry.path)) {
|
|
286
|
+
throw new Error(`Execution Lane Project workspace is dirty: ${entry.projectId}.`);
|
|
287
|
+
}
|
|
288
|
+
const branch = await this.git.headRef(entry.path);
|
|
289
|
+
if (branch !== entry.branch) {
|
|
290
|
+
throw new Error(`Execution Lane Project workspace left its managed branch: ${entry.projectId}/${branch}.`);
|
|
291
|
+
}
|
|
292
|
+
projects.push({
|
|
293
|
+
projectId: entry.projectId,
|
|
294
|
+
headCommit: (await this.git.inspect(entry.path, "HEAD")).baseCommit,
|
|
295
|
+
branch
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return { schemaVersion: 1, projects };
|
|
299
|
+
}
|
|
300
|
+
async snapshotDirectTaskMain(workspace, projectIds) {
|
|
301
|
+
if (workspace.owner.type !== "task") {
|
|
302
|
+
throw new Error("Only Task main can become a direct Candidate source.");
|
|
303
|
+
}
|
|
304
|
+
const selected = new Set(projectIds);
|
|
305
|
+
const entries = workspace.entries.filter(({ projectId }) => selected.has(projectId));
|
|
306
|
+
if (entries.length !== selected.size) {
|
|
307
|
+
throw new Error("Direct Candidate Project scope does not match Task main.");
|
|
308
|
+
}
|
|
309
|
+
const projects = [];
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
if (entry.access !== "write") {
|
|
312
|
+
throw new Error(`Direct Candidate Project is not writable: ${entry.projectId}.`);
|
|
313
|
+
}
|
|
314
|
+
if (!await this.git.isClean(entry.path)) {
|
|
315
|
+
throw new Error(`Direct Candidate Task main must be clean and committed: ${entry.projectId}.`);
|
|
316
|
+
}
|
|
317
|
+
const branch = await this.git.headRef(entry.path);
|
|
318
|
+
if (branch !== entry.branch) {
|
|
319
|
+
throw new Error(`Direct Candidate Task main left its managed branch: ${entry.projectId}/${branch}.`);
|
|
320
|
+
}
|
|
321
|
+
const headCommit = (await this.git.inspect(entry.path, "HEAD")).baseCommit;
|
|
322
|
+
if (!await this.git.isAncestor(entry.path, entry.baseCommit, headCommit)) {
|
|
323
|
+
throw new Error(`Direct Candidate Task main does not descend from its recorded base: ${entry.projectId}.`);
|
|
324
|
+
}
|
|
325
|
+
projects.push({ projectId: entry.projectId, headCommit });
|
|
326
|
+
}
|
|
327
|
+
return createDirectTaskMainSnapshot(workspace, projectIds, projects);
|
|
328
|
+
}
|
|
329
|
+
async prepareWorkItemWorkspace(taskId, workItemId) {
|
|
330
|
+
const item = requireWorkItem(this.store, taskId, workItemId);
|
|
331
|
+
const task = requireTask(this.store, item.taskId);
|
|
332
|
+
assertWorkItemWorkspaceEligible(this.store, task, item);
|
|
333
|
+
await this.prepareTaskWorkspace(task.id);
|
|
334
|
+
const main = this.store.getTaskWorkspace(task.id);
|
|
335
|
+
if (main === null || main.owner.type !== "task") {
|
|
336
|
+
throw new Error(`Task main workspace is not ready: ${task.id}.`);
|
|
337
|
+
}
|
|
338
|
+
const existing = this.store.getWorkItemWorkspace(task.id, item.id);
|
|
339
|
+
if (existing !== null && (existing.owner.type !== "work-item"
|
|
340
|
+
|| existing.owner.workItemId !== item.id)) {
|
|
341
|
+
throw new Error(`WorkItem workspace owner is invalid: ${task.id}/${item.id}.`);
|
|
342
|
+
}
|
|
343
|
+
if (item.assignee !== undefined) {
|
|
344
|
+
assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
|
|
345
|
+
}
|
|
346
|
+
const writeProjects = new Set(item.writeProjectIds);
|
|
347
|
+
const boundProjects = new Set(task.projectBindings.map(({ projectId }) => projectId));
|
|
348
|
+
for (const baseRef of item.baseRefs ?? []) {
|
|
349
|
+
if (!boundProjects.has(baseRef.projectId)) {
|
|
350
|
+
throw new Error(`WorkItem base-ref Project is not bound to its Task: ${item.id}/${baseRef.projectId}.`);
|
|
351
|
+
}
|
|
352
|
+
if (!writeProjects.has(baseRef.projectId)) {
|
|
353
|
+
throw new Error(`WorkItem base-ref Project is not writable: ${item.id}/${baseRef.projectId}.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const root = this.#workItemWorkspaceRoot(task.id, item.id);
|
|
357
|
+
const prepared = [];
|
|
358
|
+
try {
|
|
359
|
+
for (const binding of task.projectBindings) {
|
|
360
|
+
const project = requireProject(this.store, binding.projectId);
|
|
361
|
+
const mainEntry = requireWorkspaceEntry(main, project.id);
|
|
362
|
+
if (!writeProjects.has(project.id)) {
|
|
363
|
+
prepared.push({
|
|
364
|
+
project,
|
|
365
|
+
entry: { ...mainEntry, access: "read" }
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
|
|
370
|
+
const head = await this.git.inspect(mainEntry.path, "HEAD");
|
|
371
|
+
const requestedBaseRef = item.baseRefs?.find(({ projectId }) => (projectId === project.id))?.baseRef;
|
|
372
|
+
const baseRef = previous?.access === "write"
|
|
373
|
+
? previous.baseCommit
|
|
374
|
+
: requestedBaseRef ?? head.baseCommit;
|
|
375
|
+
const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
|
|
376
|
+
? null
|
|
377
|
+
: await this.git.inspect(project.path, requestedBaseRef);
|
|
378
|
+
const physical = await this.git.ensureWorktree({
|
|
379
|
+
repositoryPath: project.path,
|
|
380
|
+
container: this.#projectContainer(project.name),
|
|
81
381
|
taskId: task.id,
|
|
82
|
-
roleName:
|
|
83
|
-
repositoryId: repository.id,
|
|
84
|
-
path: physical.path,
|
|
85
|
-
branch: physical.branch,
|
|
382
|
+
roleName: item.id,
|
|
86
383
|
baseRef
|
|
87
384
|
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
385
|
+
const entry = {
|
|
386
|
+
projectId: project.id,
|
|
387
|
+
directory: binding.directory,
|
|
388
|
+
access: "write",
|
|
389
|
+
path: physical.path,
|
|
390
|
+
branch: physical.branch,
|
|
391
|
+
baseRef: previous?.access === "write" ? previous.baseRef : baseRef,
|
|
392
|
+
// The recorded base is the immutable capture boundary. An existing
|
|
393
|
+
// worktree reports its current HEAD from ensureWorktree(), which
|
|
394
|
+
// may already contain committed Worker changes and must never
|
|
395
|
+
// replace that boundary during scope expansion or reconciliation.
|
|
396
|
+
baseCommit: previous?.access === "write"
|
|
397
|
+
? previous.baseCommit
|
|
398
|
+
: physical.baseCommit
|
|
399
|
+
};
|
|
400
|
+
// Track the physical entry before any postcondition check. If a
|
|
401
|
+
// freshly attached deterministic branch is rejected below, the
|
|
402
|
+
// catch-path must remove its unadopted worktree even though no
|
|
403
|
+
// durable ManagedWorkspace has been written yet.
|
|
404
|
+
prepared.push({ project, entry });
|
|
405
|
+
if (previous?.access === "write" && (physical.path !== previous.path
|
|
406
|
+
|| physical.branch !== previous.branch)) {
|
|
407
|
+
throw new Error(`Existing WorkItem Project workspace identity changed: ${item.id}/${project.id}.`);
|
|
408
|
+
}
|
|
409
|
+
if (previous?.access === "write") {
|
|
410
|
+
if (requestedBaseRef === undefined && previous.baseRef !== previous.baseCommit) {
|
|
411
|
+
throw new Error(`Existing WorkItem Project base ref record changed: ${item.id}/${project.id}.`);
|
|
412
|
+
}
|
|
413
|
+
if (requestedBaseRef !== undefined && requestedBaseRef !== previous.baseRef) {
|
|
414
|
+
throw new Error(`Existing WorkItem Project base ref changed: ${item.id}/${project.id}.`);
|
|
415
|
+
}
|
|
416
|
+
if (!await this.git.isAncestor(project.path, previous.baseCommit, physical.baseCommit)) {
|
|
417
|
+
throw new Error(`Existing WorkItem Project HEAD does not descend from its frozen base: `
|
|
418
|
+
+ `${item.id}/${project.id}.`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else if (requestedBase !== null && physical.baseCommit !== requestedBase.baseCommit) {
|
|
422
|
+
throw new Error(`WorkItem Project workspace did not start at its requested base ref: `
|
|
423
|
+
+ `${item.id}/${project.id}.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
427
|
+
const workspace = createManagedWorkspace({
|
|
428
|
+
owner: { type: "work-item", taskId: task.id, workItemId: item.id },
|
|
429
|
+
root,
|
|
430
|
+
entries: prepared.map(({ entry }) => entry)
|
|
431
|
+
}, this.now());
|
|
432
|
+
return this.store.transaction((tx) => {
|
|
433
|
+
const latestTask = requireTask(tx, task.id);
|
|
434
|
+
const latestItem = tx.getWorkItem(task.id, item.id);
|
|
435
|
+
if (latestTask.status !== "active" || latestItem === null) {
|
|
436
|
+
throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
|
|
437
|
+
}
|
|
438
|
+
if (latestItem.revision !== item.revision
|
|
439
|
+
|| !isDeepStrictEqual(latestItem.writeProjectIds, item.writeProjectIds)) {
|
|
440
|
+
throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
|
|
441
|
+
}
|
|
442
|
+
const activeDevelopRun = tx.listAgentRuns(task.id)
|
|
443
|
+
.find((run) => run.status === "active" && run.workItemId === item.id);
|
|
444
|
+
if (activeDevelopRun !== undefined) {
|
|
445
|
+
throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
|
|
446
|
+
}
|
|
447
|
+
if (latestItem.assignee !== undefined
|
|
448
|
+
&& tx.getActiveAgentRun(task.id, latestItem.assignee) !== null) {
|
|
449
|
+
throw new Error(`Role has an active Run: ${task.id}/${latestItem.assignee}.`);
|
|
450
|
+
}
|
|
451
|
+
const current = tx.getWorkItemWorkspace(task.id, item.id);
|
|
452
|
+
if (current !== null && (current.owner.type !== "work-item"
|
|
453
|
+
|| current.owner.workItemId !== item.id)) {
|
|
454
|
+
throw new Error(`WorkItem workspace changed: ${task.id}/${item.id}.`);
|
|
455
|
+
}
|
|
456
|
+
const timestamp = this.now();
|
|
457
|
+
const stored = preserveWorkspaceCreatedAt(workspace, current);
|
|
458
|
+
tx.saveManagedWorkspace(stored);
|
|
459
|
+
if (latestItem.assignee !== undefined) {
|
|
460
|
+
const latestRole = tx.getRole(task.id, latestItem.assignee);
|
|
461
|
+
if (latestRole !== null && latestRole.workspace !== root) {
|
|
462
|
+
retireWorkspaceBoundSession(tx, task.id, latestItem.assignee, timestamp);
|
|
463
|
+
tx.saveRole(task.id, updateRole(latestRole, { workspace: root }, timestamp));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return stored;
|
|
100
467
|
});
|
|
101
|
-
|
|
102
|
-
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
await this.#discardUnadoptedEntries(task, prepared, item.id);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Prepare/reuse a durable physical workspace for an ExecutionGroup Lane.
|
|
476
|
+
* Git identity, ownership, and cleanup are delegated to this preparer; the
|
|
477
|
+
* command layer only consumes the resulting managed record.
|
|
478
|
+
*/
|
|
479
|
+
async prepareExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId, hint) {
|
|
480
|
+
const task = requireTask(this.store, taskId);
|
|
481
|
+
const lineage = executionLaneLineage(this.store, task, executionGroupId, executionLaneId, hint);
|
|
482
|
+
const source = lineage.purpose === "execution"
|
|
483
|
+
? this.store.getWorkItemWorkspace(taskId, lineage.workItemId)
|
|
484
|
+
: this.store.getReviewRoundWorkspace(taskId, lineage.reviewRoundId);
|
|
485
|
+
if (source === null) {
|
|
486
|
+
throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
|
|
487
|
+
}
|
|
488
|
+
const owner = lineage.purpose === "execution"
|
|
489
|
+
? {
|
|
490
|
+
type: "execution-lane",
|
|
491
|
+
taskId,
|
|
492
|
+
executionGroupId,
|
|
493
|
+
executionLaneId,
|
|
494
|
+
purpose: "execution",
|
|
495
|
+
workItemId: lineage.workItemId
|
|
103
496
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
497
|
+
: {
|
|
498
|
+
type: "execution-lane",
|
|
499
|
+
taskId,
|
|
500
|
+
executionGroupId,
|
|
501
|
+
executionLaneId,
|
|
502
|
+
purpose: "review",
|
|
503
|
+
reviewRoundId: lineage.reviewRoundId
|
|
504
|
+
};
|
|
505
|
+
const existing = this.store.getManagedWorkspace(owner);
|
|
506
|
+
if (existing !== null) {
|
|
507
|
+
if (existing.owner.type !== "execution-lane" || existing.root !== this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId)) {
|
|
508
|
+
throw new Error(`Execution Lane managed workspace identity changed: ${taskId}/${executionLaneId}.`);
|
|
107
509
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
510
|
+
await ensureWorkspaceView(existing.root, existing.entries);
|
|
511
|
+
for (const entry of existing.entries.filter(({ access }) => access === "write")) {
|
|
512
|
+
const project = requireProject(this.store, entry.projectId);
|
|
513
|
+
const physical = await this.git.ensureWorktree({
|
|
514
|
+
repositoryPath: project.path,
|
|
515
|
+
container: this.#projectContainer(project.name),
|
|
516
|
+
taskId,
|
|
517
|
+
roleName: managedWorktreeName(owner),
|
|
518
|
+
baseRef: entry.baseCommit
|
|
519
|
+
});
|
|
520
|
+
if (physical.path !== entry.path || physical.branch !== entry.branch) {
|
|
521
|
+
throw new Error(`Execution Lane physical identity changed: ${taskId}/${executionLaneId}/${entry.projectId}.`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return existing;
|
|
525
|
+
}
|
|
526
|
+
const item = lineage.purpose === "execution"
|
|
527
|
+
? this.store.getWorkItem(taskId, lineage.workItemId)
|
|
528
|
+
: null;
|
|
529
|
+
const writable = new Set(lineage.purpose === "execution"
|
|
530
|
+
? item?.writeProjectIds ?? []
|
|
531
|
+
: task.projectBindings.map(({ projectId }) => projectId));
|
|
532
|
+
const prepared = [];
|
|
533
|
+
try {
|
|
534
|
+
for (const binding of task.projectBindings) {
|
|
535
|
+
const project = requireProject(this.store, binding.projectId);
|
|
536
|
+
const sourceEntry = requireWorkspaceEntry(source, project.id);
|
|
537
|
+
if (!writable.has(project.id)) {
|
|
538
|
+
prepared.push({ project, entry: { ...sourceEntry, access: "read" } });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const physical = await this.git.ensureWorktree({
|
|
542
|
+
repositoryPath: project.path,
|
|
543
|
+
container: this.#projectContainer(project.name),
|
|
544
|
+
taskId,
|
|
545
|
+
roleName: managedWorktreeName(owner),
|
|
546
|
+
baseRef: sourceEntry.baseCommit
|
|
547
|
+
});
|
|
548
|
+
prepared.push({
|
|
549
|
+
project,
|
|
550
|
+
entry: {
|
|
551
|
+
...sourceEntry,
|
|
552
|
+
access: "write",
|
|
553
|
+
path: physical.path,
|
|
554
|
+
branch: physical.branch,
|
|
555
|
+
baseRef: sourceEntry.baseCommit,
|
|
556
|
+
baseCommit: sourceEntry.baseCommit
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
|
|
561
|
+
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
562
|
+
const workspace = createManagedWorkspace({
|
|
563
|
+
owner,
|
|
564
|
+
root,
|
|
565
|
+
entries: prepared.map(({ entry }) => entry)
|
|
566
|
+
}, this.now());
|
|
567
|
+
const durableLane = this.store.listWorkItems(taskId).some((item) => (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId))) || this.store.listReviewRounds(taskId).some((round) => (round.executionGroup?.id === executionGroupId
|
|
568
|
+
&& round.executionGroup.lanes.some(({ id }) => id === executionLaneId)));
|
|
569
|
+
if (durableLane) {
|
|
570
|
+
return this.store.transaction((tx) => {
|
|
571
|
+
const current = tx.getManagedWorkspace(owner);
|
|
572
|
+
if (current !== null && !isDeepStrictEqual(current, workspace)) {
|
|
573
|
+
throw new Error(`Execution Lane workspace changed before adoption: ${executionLaneId}.`);
|
|
574
|
+
}
|
|
575
|
+
tx.saveManagedWorkspace(current ?? workspace);
|
|
576
|
+
return current ?? workspace;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return workspace;
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
await this.#discardUnadoptedEntries(task, prepared, managedWorktreeName(owner), true);
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Compensate a command preflight when its later aggregate transaction does
|
|
588
|
+
* not adopt the prepared Lane. Existing durable owners are never removed.
|
|
589
|
+
*/
|
|
590
|
+
async discardUnadoptedExecutionLaneWorkspaces(workspaces) {
|
|
591
|
+
if (workspaces === undefined)
|
|
592
|
+
return;
|
|
593
|
+
for (const workspace of workspaces.values()) {
|
|
594
|
+
if (this.store.getManagedWorkspace(workspace.owner) !== null)
|
|
129
595
|
continue;
|
|
130
|
-
|
|
131
|
-
|
|
596
|
+
const result = await this.discardUnadoptedExecutionLaneWorkspace(workspace);
|
|
597
|
+
if (result === "dirty") {
|
|
598
|
+
throw new Error(`Unadopted Execution Lane workspace is dirty and was retained: ${workspace.root}.`);
|
|
132
599
|
}
|
|
133
|
-
|
|
134
|
-
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** Merge every selected lane's committed output into the WorkItem owner. */
|
|
603
|
+
async materializeExecutionGroupCandidate(taskId, workItemId, executionGroupId, selectedLaneIds) {
|
|
604
|
+
const item = requireWorkItem(this.store, taskId, workItemId);
|
|
605
|
+
const task = requireTask(this.store, taskId);
|
|
606
|
+
const group = workItemExecutionGroupById(item, executionGroupId);
|
|
607
|
+
if (group === undefined || group.id !== executionGroupId) {
|
|
608
|
+
throw new Error(`ExecutionGroup is not attached to Work Item: ${executionGroupId}.`);
|
|
609
|
+
}
|
|
610
|
+
const workspace = this.store.getWorkItemWorkspace(taskId, workItemId);
|
|
611
|
+
const selected = (selectedLaneIds.length === 0
|
|
612
|
+
? group.lanes.filter((lane) => lane.status === "yielded" || lane.status === "completed")
|
|
613
|
+
: group.lanes.filter((lane) => selectedLaneIds.includes(lane.id)));
|
|
614
|
+
if (selected.some((lane) => !["yielded", "completed"].includes(lane.status))) {
|
|
615
|
+
throw new Error(`ExecutionGroup has a selected Lane without terminal output: ${executionGroupId}.`);
|
|
616
|
+
}
|
|
617
|
+
if (selected.length === 0)
|
|
618
|
+
throw new Error(`ExecutionGroup has no selected Lane outputs: ${executionGroupId}.`);
|
|
619
|
+
// A Gitless Task has no WorkItem workspace to materialize. Its selected
|
|
620
|
+
// Lane reports still form a valid non-Git Candidate; no fake workspace or
|
|
621
|
+
// commit snapshot is introduced.
|
|
622
|
+
if (workspace === null) {
|
|
623
|
+
if (task.projectBindings.length === 0
|
|
624
|
+
&& selected.every((lane) => lane.result?.gitSnapshot === undefined)) {
|
|
625
|
+
return { restoreTargets: [] };
|
|
626
|
+
}
|
|
627
|
+
throw new Error(`Work Item workspace is not ready for Candidate materialization: ${workItemId}.`);
|
|
628
|
+
}
|
|
629
|
+
if (workspace.owner.type !== "work-item") {
|
|
630
|
+
throw new Error(`Work Item workspace owner is invalid for Candidate materialization: ${workItemId}.`);
|
|
631
|
+
}
|
|
632
|
+
const pendingMerges = new Map();
|
|
633
|
+
const restoreTargets = [];
|
|
634
|
+
const writableProjectIds = workspace.entries
|
|
635
|
+
.filter(({ access }) => access === "write")
|
|
636
|
+
.map(({ projectId }) => projectId);
|
|
637
|
+
if (writableProjectIds.length === 0) {
|
|
638
|
+
for (const lane of selected) {
|
|
639
|
+
if (lane.result?.gitSnapshot !== undefined) {
|
|
640
|
+
throw new Error(`Read-only Lane must not carry a Git snapshot: ${lane.id}.`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
workspace,
|
|
645
|
+
// A read-only WorkItem has no Lane Git output, but its frozen
|
|
646
|
+
// WorkItem workspace is still the legitimate Candidate review
|
|
647
|
+
// context. Keep that Candidate snapshot separate from Lane output.
|
|
648
|
+
snapshot: await this.snapshotCandidateWorkspace(workspace),
|
|
649
|
+
restoreTargets: []
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
for (const lane of selected) {
|
|
653
|
+
const laneSnapshot = lane.result?.gitSnapshot;
|
|
654
|
+
if (laneSnapshot === undefined) {
|
|
655
|
+
throw new Error(`Selected Lane has no frozen Git snapshot: ${lane.id}.`);
|
|
656
|
+
}
|
|
657
|
+
const snapshotProjectIds = laneSnapshot.projects.map(({ projectId }) => projectId).sort();
|
|
658
|
+
if (!isDeepStrictEqual(snapshotProjectIds, [...writableProjectIds].sort())) {
|
|
659
|
+
throw new Error(`Selected Lane Git snapshot Project scope changed: ${lane.id}.`);
|
|
660
|
+
}
|
|
661
|
+
const laneWorkspace = this.store.getManagedWorkspace({
|
|
662
|
+
type: "execution-lane",
|
|
663
|
+
taskId,
|
|
664
|
+
executionGroupId,
|
|
665
|
+
executionLaneId: lane.id,
|
|
666
|
+
purpose: "execution",
|
|
667
|
+
workItemId
|
|
668
|
+
});
|
|
669
|
+
if (laneWorkspace === null) {
|
|
670
|
+
// fixed(1) retains the WorkItem owner as its single normal-path workspace.
|
|
671
|
+
if (group.lanes.length === 1) {
|
|
672
|
+
for (const target of workspace.entries.filter(({ access }) => access === "write")) {
|
|
673
|
+
const frozen = laneSnapshot.projects.find(({ projectId }) => projectId === target.projectId);
|
|
674
|
+
if (frozen === undefined)
|
|
675
|
+
throw new Error(`Selected Lane snapshot omits Project: ${lane.id}/${target.projectId}.`);
|
|
676
|
+
if (await this.git.headRef(target.path) !== frozen.branch || !await this.git.isClean(target.path)) {
|
|
677
|
+
throw new Error(`Single-Lane workspace is not clean on its managed branch: ${lane.id}/${target.projectId}.`);
|
|
678
|
+
}
|
|
679
|
+
const targetHead = (await this.git.inspect(target.path, "HEAD")).baseCommit;
|
|
680
|
+
if (targetHead !== frozen.headCommit) {
|
|
681
|
+
throw new Error(`Single-Lane workspace changed after yield: ${lane.id}/${target.projectId}.`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
throw new Error(`Selected Execution Lane workspace is not adopted: ${lane.id}.`);
|
|
687
|
+
}
|
|
688
|
+
for (const target of workspace.entries.filter(({ access }) => access === "write")) {
|
|
689
|
+
const source = laneWorkspace.entries.find(({ projectId }) => projectId === target.projectId);
|
|
690
|
+
if (source === undefined)
|
|
691
|
+
throw new Error(`Selected Lane omits Project: ${lane.id}/${target.projectId}.`);
|
|
692
|
+
const frozen = laneSnapshot.projects.find(({ projectId }) => projectId === target.projectId);
|
|
693
|
+
if (frozen === undefined)
|
|
694
|
+
throw new Error(`Selected Lane snapshot omits Project: ${lane.id}/${target.projectId}.`);
|
|
695
|
+
if (await this.git.headRef(source.path) !== frozen.branch || frozen.branch !== source.branch) {
|
|
696
|
+
throw new Error(`Selected Lane left its managed branch: ${lane.id}/${target.projectId}.`);
|
|
697
|
+
}
|
|
698
|
+
if (!await this.git.isClean(source.path)) {
|
|
699
|
+
throw new Error(`Selected Lane has uncommitted output: ${lane.id}/${target.projectId}.`);
|
|
700
|
+
}
|
|
701
|
+
const sourceHead = (await this.git.inspect(source.path, "HEAD")).baseCommit;
|
|
702
|
+
if (sourceHead !== frozen.headCommit) {
|
|
703
|
+
throw new Error(`Selected Lane advanced after yield: ${lane.id}/${target.projectId}.`);
|
|
704
|
+
}
|
|
705
|
+
const targetHead = (await this.git.inspect(target.path, "HEAD")).baseCommit;
|
|
706
|
+
if (sourceHead !== targetHead && !await this.git.isAncestor(target.path, sourceHead, targetHead)) {
|
|
707
|
+
const pending = pendingMerges.get(target.path) ?? [];
|
|
708
|
+
pending.push(frozen.headCommit);
|
|
709
|
+
pendingMerges.set(target.path, pending);
|
|
710
|
+
if (!restoreTargets.some(({ path }) => path === target.path)) {
|
|
711
|
+
restoreTargets.push({ path: target.path, head: targetHead });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
for (const [targetPath, sourceRefs] of pendingMerges) {
|
|
718
|
+
await this.git.mergeWorktree({ targetPath, sourceRefs });
|
|
719
|
+
const target = restoreTargets.find(({ path }) => path === targetPath);
|
|
720
|
+
if (target !== undefined) {
|
|
721
|
+
target.mergedHead = (await this.git.inspect(targetPath, "HEAD")).baseCommit;
|
|
722
|
+
}
|
|
135
723
|
}
|
|
724
|
+
return {
|
|
725
|
+
workspace,
|
|
726
|
+
snapshot: await this.snapshotCandidateWorkspace(workspace),
|
|
727
|
+
restoreTargets: restoreTargets
|
|
728
|
+
.filter((target) => (target.mergedHead !== undefined && target.mergedHead !== target.head))
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
for (const target of [...restoreTargets].reverse()) {
|
|
733
|
+
if (target.mergedHead === undefined)
|
|
734
|
+
continue;
|
|
735
|
+
try {
|
|
736
|
+
await this.git.resetWorktree({
|
|
737
|
+
targetPath: target.path,
|
|
738
|
+
expectedHead: target.mergedHead,
|
|
739
|
+
restoreHead: target.head
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
catch (restoreError) {
|
|
743
|
+
throw new Error(`Candidate materialization failed and Git compensation could not restore ${target.path}.`, { cause: restoreError });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
throw error;
|
|
136
747
|
}
|
|
137
|
-
return results;
|
|
138
748
|
}
|
|
139
|
-
async
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
if (
|
|
749
|
+
async restoreExecutionGroupCandidateMaterialization(materialization) {
|
|
750
|
+
for (const target of [...materialization.restoreTargets].reverse()) {
|
|
751
|
+
const current = (await this.git.inspect(target.path, "HEAD")).baseCommit;
|
|
752
|
+
if (current === target.head)
|
|
143
753
|
continue;
|
|
144
|
-
|
|
145
|
-
|
|
754
|
+
if (current !== target.mergedHead) {
|
|
755
|
+
throw new Error(`Candidate materialization compensation found unexpected Git head: ${target.path}.`);
|
|
146
756
|
}
|
|
147
|
-
|
|
148
|
-
|
|
757
|
+
await this.git.resetWorktree({
|
|
758
|
+
targetPath: target.path,
|
|
759
|
+
expectedHead: target.mergedHead,
|
|
760
|
+
restoreHead: target.head
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async cleanupExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
|
|
765
|
+
const task = requireTask(this.store, taskId);
|
|
766
|
+
const lineage = executionLaneLineage(this.store, task, executionGroupId, executionLaneId);
|
|
767
|
+
const item = lineage.purpose === "execution"
|
|
768
|
+
? this.store.getWorkItem(taskId, lineage.workItemId)
|
|
769
|
+
: null;
|
|
770
|
+
const group = lineage.purpose === "execution"
|
|
771
|
+
? (item === null ? undefined : workItemExecutionGroupById(item, executionGroupId))
|
|
772
|
+
: this.store.getReviewRound(taskId, lineage.reviewRoundId)?.executionGroup;
|
|
773
|
+
const lane = group?.lanes.find(({ id }) => id === executionLaneId);
|
|
774
|
+
if (group === undefined
|
|
775
|
+
|| lane === undefined || !["completed", "failed", "yielded"].includes(lane.status)) {
|
|
776
|
+
throw new Error(`Execution Lane is not terminally resolved: ${taskId}/${executionLaneId}.`);
|
|
777
|
+
}
|
|
778
|
+
const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
|
|
779
|
+
&& owner.executionGroupId === executionGroupId
|
|
780
|
+
&& owner.executionLaneId === executionLaneId));
|
|
781
|
+
if (workspace === undefined)
|
|
782
|
+
return "missing";
|
|
783
|
+
const state = await this.#inspectEntries(taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
784
|
+
if (state === "dirty")
|
|
785
|
+
return "dirty";
|
|
786
|
+
let removed = false;
|
|
787
|
+
for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
|
|
788
|
+
const project = requireProject(this.store, entry.projectId);
|
|
789
|
+
const result = await this.git.removeWorktree({
|
|
790
|
+
repositoryPath: project.path,
|
|
791
|
+
container: this.#projectContainer(project.name),
|
|
792
|
+
taskId,
|
|
793
|
+
roleName: managedWorktreeName(workspace.owner),
|
|
794
|
+
deleteBranch: true
|
|
795
|
+
});
|
|
796
|
+
if (result === "dirty")
|
|
797
|
+
return "dirty";
|
|
798
|
+
removed ||= result === "removed";
|
|
799
|
+
}
|
|
800
|
+
await removeWorkspaceView(workspace.root);
|
|
801
|
+
this.store.removeManagedWorkspace(workspace.owner);
|
|
802
|
+
return removed ? "removed" : "missing";
|
|
803
|
+
}
|
|
804
|
+
/** Remove a physical lane that was prepared but never adopted into a Group. */
|
|
805
|
+
async discardUnadoptedExecutionLaneWorkspace(workspace) {
|
|
806
|
+
if (workspace.owner.type !== "execution-lane") {
|
|
807
|
+
throw new Error("Only an execution-lane workspace can be discarded here.");
|
|
808
|
+
}
|
|
809
|
+
if (this.store.getManagedWorkspace(workspace.owner) !== null)
|
|
810
|
+
return "missing";
|
|
811
|
+
const state = await this.#inspectEntries(workspace.owner.taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
812
|
+
if (state === "dirty")
|
|
813
|
+
return "dirty";
|
|
814
|
+
let removed = false;
|
|
815
|
+
for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
|
|
816
|
+
const project = requireProject(this.store, entry.projectId);
|
|
817
|
+
const result = await this.git.removeWorktree({
|
|
818
|
+
repositoryPath: project.path,
|
|
819
|
+
container: this.#projectContainer(project.name),
|
|
820
|
+
taskId: workspace.owner.taskId,
|
|
821
|
+
roleName: managedWorktreeName(workspace.owner),
|
|
822
|
+
deleteBranch: true
|
|
823
|
+
});
|
|
824
|
+
if (result === "dirty")
|
|
825
|
+
return "dirty";
|
|
826
|
+
removed ||= result === "removed";
|
|
827
|
+
}
|
|
828
|
+
await removeWorkspaceView(workspace.root);
|
|
829
|
+
return removed ? "removed" : "missing";
|
|
830
|
+
}
|
|
831
|
+
async cleanupExecutionLaneWorkspacesForWorkItem(taskId, workItemId) {
|
|
832
|
+
let result = "missing";
|
|
833
|
+
const item = this.store.getWorkItem(taskId, workItemId);
|
|
834
|
+
for (const group of item?.executionGroups ?? [])
|
|
835
|
+
for (const lane of group.lanes) {
|
|
836
|
+
const owner = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane" && owner.purpose === "execution"
|
|
837
|
+
&& owner.workItemId === workItemId && owner.executionLaneId === lane.id));
|
|
838
|
+
if (owner !== undefined) {
|
|
839
|
+
if (owner.owner.type !== "execution-lane")
|
|
840
|
+
continue;
|
|
841
|
+
const cleaned = await this.cleanupExecutionLaneWorkspace(taskId, owner.owner.executionGroupId, lane.id);
|
|
842
|
+
if (cleaned === "dirty")
|
|
843
|
+
return cleaned;
|
|
844
|
+
if (cleaned === "removed")
|
|
845
|
+
result = cleaned;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
async cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId) {
|
|
851
|
+
let result = "missing";
|
|
852
|
+
const round = this.store.getReviewRound(taskId, reviewRoundId);
|
|
853
|
+
for (const lane of round?.executionGroup?.lanes ?? []) {
|
|
854
|
+
const owner = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane" && owner.purpose === "review"
|
|
855
|
+
&& owner.reviewRoundId === reviewRoundId && owner.executionLaneId === lane.id));
|
|
856
|
+
if (owner !== undefined) {
|
|
857
|
+
if (owner.owner.type !== "execution-lane")
|
|
858
|
+
continue;
|
|
859
|
+
const cleaned = await this.cleanupExecutionLaneWorkspace(taskId, owner.owner.executionGroupId, lane.id);
|
|
860
|
+
if (cleaned === "dirty")
|
|
861
|
+
return cleaned;
|
|
862
|
+
if (cleaned === "removed")
|
|
863
|
+
result = cleaned;
|
|
149
864
|
}
|
|
150
865
|
}
|
|
151
|
-
return
|
|
866
|
+
return result;
|
|
152
867
|
}
|
|
153
|
-
async
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
868
|
+
async inspectWorkItemWorkspace(taskId, workItemId) {
|
|
869
|
+
const item = requireWorkItem(this.store, taskId, workItemId);
|
|
870
|
+
const workspace = this.store.getWorkItemWorkspace(item.taskId, item.id);
|
|
871
|
+
if (workspace === null)
|
|
872
|
+
return "missing";
|
|
873
|
+
assertWorkItemOwnsWorkspace(item, workspace);
|
|
874
|
+
return this.#inspectEntries(item.taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
875
|
+
}
|
|
876
|
+
/** Prepare a fresh, reviewer-writable workspace from the candidate's
|
|
877
|
+
* frozen Develop snapshot. This record is owned by the ReviewRound; it is
|
|
878
|
+
* never used as a ChangeSet capture source. */
|
|
879
|
+
async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
880
|
+
const task = requireTask(this.store, taskId);
|
|
881
|
+
const round = this.store.getReviewRound(task.id, reviewRoundId);
|
|
882
|
+
if (round === null)
|
|
883
|
+
throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
|
|
884
|
+
if (round.status !== "pending") {
|
|
885
|
+
throw new Error(`ReviewRound workspace can only prepare while pending: ${round.id}.`);
|
|
886
|
+
}
|
|
887
|
+
const item = requireWorkItem(this.store, task.id, round.workItemId);
|
|
888
|
+
const candidate = item.candidates.find(({ id }) => id === round.candidateId);
|
|
889
|
+
if (candidate === undefined) {
|
|
890
|
+
throw new Error(`ReviewRound Candidate not found: ${round.candidateId}.`);
|
|
891
|
+
}
|
|
892
|
+
const taskScope = (round.scope ?? "work-item") === "task";
|
|
893
|
+
// A WorkItem ReviewRound is an immutable snapshot of Develop. A Task
|
|
894
|
+
// ReviewRound intentionally uses the latest committed Integration heads
|
|
895
|
+
// instead, while retaining the WorkItem/Candidate anchor for storage and
|
|
896
|
+
// lifecycle compatibility.
|
|
897
|
+
const develop = candidate.workspace;
|
|
898
|
+
if (!taskScope && (develop === undefined || candidate.gitSnapshot === undefined)) {
|
|
899
|
+
throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate.id}.`);
|
|
900
|
+
}
|
|
901
|
+
if (!taskScope && candidate.gitSnapshot.reviewBaseCommit !== round.reviewBaseCommit) {
|
|
902
|
+
throw new Error(`ReviewRound base no longer matches its Candidate: ${round.id}.`);
|
|
903
|
+
}
|
|
904
|
+
const reviewer = this.store.getRole(task.id, round.reviewerRoleName);
|
|
905
|
+
if (reviewer === null) {
|
|
906
|
+
throw new Error(`Reviewer Role not found: ${task.id}/${round.reviewerRoleName}.`);
|
|
907
|
+
}
|
|
908
|
+
const snapshotCommits = new Map((taskScope
|
|
909
|
+
? round.taskCandidate?.projects ?? []
|
|
910
|
+
: candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
|
|
911
|
+
const frozenEntries = taskScope
|
|
912
|
+
? task.projectBindings.map((binding) => {
|
|
913
|
+
const commit = snapshotCommits.get(binding.projectId);
|
|
914
|
+
if (commit === undefined) {
|
|
915
|
+
throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
|
|
916
|
+
}
|
|
917
|
+
const project = requireProject(this.store, binding.projectId);
|
|
918
|
+
const identity = worktreeIdentity(task.id, round.id);
|
|
919
|
+
return {
|
|
920
|
+
projectId: binding.projectId,
|
|
921
|
+
directory: binding.directory,
|
|
922
|
+
access: "write",
|
|
923
|
+
path: join(this.#projectContainer(project.name), identity.directory),
|
|
924
|
+
branch: identity.branch,
|
|
925
|
+
baseRef: commit,
|
|
926
|
+
baseCommit: commit
|
|
927
|
+
};
|
|
928
|
+
})
|
|
929
|
+
: develop.entries.map((entry) => {
|
|
930
|
+
const commit = snapshotCommits.get(entry.projectId);
|
|
931
|
+
if (commit === undefined) {
|
|
932
|
+
throw new Error(`Candidate snapshot Project is missing: ${entry.projectId}.`);
|
|
933
|
+
}
|
|
934
|
+
return { ...entry, baseRef: commit, baseCommit: commit };
|
|
935
|
+
});
|
|
936
|
+
if (taskScope && snapshotCommits.size !== task.projectBindings.length) {
|
|
937
|
+
throw new Error(`Task Review candidate Project scope changed: ${round.id}.`);
|
|
938
|
+
}
|
|
939
|
+
const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
|
|
940
|
+
const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
|
|
941
|
+
const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round.id);
|
|
942
|
+
const retained = new Map();
|
|
943
|
+
const missing = new Set();
|
|
944
|
+
const adopted = existing?.root === reviewRoot;
|
|
945
|
+
if (existing !== null) {
|
|
946
|
+
if (!adopted) {
|
|
947
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace root changed: ${round.id}.`);
|
|
948
|
+
}
|
|
949
|
+
if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, existing)) {
|
|
950
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
951
|
+
}
|
|
952
|
+
if (existing.entries.length !== expectedEntries.size) {
|
|
953
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}.`);
|
|
954
|
+
}
|
|
955
|
+
for (const entry of existing.entries) {
|
|
956
|
+
const source = expectedEntries.get(entry.projectId);
|
|
957
|
+
if (source === undefined) {
|
|
958
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}/${entry.projectId}.`);
|
|
959
|
+
}
|
|
960
|
+
if (!sameCommit(entry.baseCommit, source.baseCommit)) {
|
|
961
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace baseCommit record mismatch for ${round.id}/${entry.projectId}: `
|
|
962
|
+
+ `expected ${source.baseCommit}, recorded ${entry.baseCommit}.`);
|
|
963
|
+
}
|
|
964
|
+
if (entry.directory !== source.directory
|
|
965
|
+
|| entry.access !== "write"
|
|
966
|
+
|| !sameCommit(entry.baseRef, source.baseCommit)) {
|
|
967
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
|
|
968
|
+
}
|
|
969
|
+
const project = requireProject(this.store, entry.projectId);
|
|
970
|
+
const identity = worktreeIdentity(task.id, round.id);
|
|
971
|
+
const expectedPath = join(this.#projectContainer(project.name), identity.directory);
|
|
972
|
+
if (entry.path !== expectedPath || entry.branch !== identity.branch) {
|
|
973
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
|
|
974
|
+
}
|
|
975
|
+
let physical;
|
|
976
|
+
try {
|
|
977
|
+
physical = await this.git.inspect(entry.path, "HEAD");
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
try {
|
|
981
|
+
await lstat(entry.path);
|
|
982
|
+
}
|
|
983
|
+
catch (probeError) {
|
|
984
|
+
if (isMissingPath(probeError)) {
|
|
985
|
+
missing.add(entry.projectId);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace cannot be inspected for ${round.id}/${entry.projectId}: `
|
|
990
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
991
|
+
}
|
|
992
|
+
if (physical.root !== entry.path) {
|
|
993
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed path mismatch for ${round.id}/${entry.projectId}.`);
|
|
994
|
+
}
|
|
995
|
+
const projectRoot = await this.git.inspect(project.path, "HEAD");
|
|
996
|
+
if (physical.gitDirectory !== projectRoot.gitDirectory) {
|
|
997
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed Project mismatch for ${round.id}/${entry.projectId}.`);
|
|
998
|
+
}
|
|
999
|
+
let branch;
|
|
1000
|
+
try {
|
|
1001
|
+
branch = await this.git.headRef(entry.path);
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace branch cannot be verified for `
|
|
1005
|
+
+ `${round.id}/${entry.projectId}: `
|
|
1006
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1007
|
+
}
|
|
1008
|
+
if (branch !== identity.branch) {
|
|
1009
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed branch mismatch for ${round.id}/${entry.projectId}: `
|
|
1010
|
+
+ `expected ${identity.branch}, physical ${branch}.`);
|
|
1011
|
+
}
|
|
1012
|
+
let descendsFromBase;
|
|
1013
|
+
try {
|
|
1014
|
+
descendsFromBase = await this.git.isAncestor(project.path, entry.baseCommit, physical.baseCommit);
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
|
|
1018
|
+
+ `${round.id}/${entry.projectId}: `
|
|
1019
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1020
|
+
}
|
|
1021
|
+
if (!descendsFromBase) {
|
|
1022
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
|
|
1023
|
+
+ `${round.id}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
|
|
1024
|
+
+ `physical HEAD ${physical.baseCommit}.`);
|
|
1025
|
+
}
|
|
1026
|
+
retained.set(entry.projectId, { project, entry });
|
|
1027
|
+
}
|
|
1028
|
+
if (adopted && missing.size === 0) {
|
|
1029
|
+
try {
|
|
1030
|
+
await ensureWorkspaceView(reviewRoot, existing.entries);
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
|
|
1034
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1035
|
+
}
|
|
1036
|
+
if (round.workspace === undefined) {
|
|
1037
|
+
this.store.saveReviewRound(task.id, attachReviewRoundWorkspace(round, existing));
|
|
1038
|
+
}
|
|
1039
|
+
return existing;
|
|
1040
|
+
}
|
|
1041
|
+
// The durable owner record can outlive a controller crash before the
|
|
1042
|
+
// physical review worktrees were adopted. Recreate only missing
|
|
1043
|
+
// physical entries; existing reviewer diagnostics remain attached to
|
|
1044
|
+
// their frozen Candidate provenance.
|
|
1045
|
+
}
|
|
1046
|
+
const prepared = [];
|
|
1047
|
+
try {
|
|
1048
|
+
const sources = adopted
|
|
1049
|
+
? frozenEntries.filter((source) => missing.has(source.projectId))
|
|
1050
|
+
: frozenEntries;
|
|
1051
|
+
for (const source of sources) {
|
|
1052
|
+
const project = requireProject(this.store, source.projectId);
|
|
1053
|
+
const physical = await this.git.ensureWorktree({
|
|
1054
|
+
repositoryPath: project.path,
|
|
1055
|
+
container: this.#projectContainer(project.name),
|
|
161
1056
|
taskId: task.id,
|
|
162
|
-
roleName:
|
|
1057
|
+
roleName: round.id,
|
|
1058
|
+
baseRef: source.baseCommit
|
|
163
1059
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1060
|
+
const entry = {
|
|
1061
|
+
...source,
|
|
1062
|
+
access: "write",
|
|
1063
|
+
path: physical.path,
|
|
1064
|
+
branch: physical.branch,
|
|
1065
|
+
baseRef: source.baseCommit,
|
|
1066
|
+
baseCommit: source.baseCommit
|
|
1067
|
+
};
|
|
1068
|
+
prepared.push({ project, entry });
|
|
1069
|
+
if (!sameCommit(physical.baseCommit, source.baseCommit)) {
|
|
1070
|
+
if (existing === null) {
|
|
1071
|
+
throw new Error(`ReviewRound workspace baseCommit mismatch for ${round.id}/${source.projectId}: `
|
|
1072
|
+
+ `expected ${source.baseCommit}, physical HEAD ${physical.baseCommit}.`);
|
|
1073
|
+
}
|
|
1074
|
+
let descendsFromBase;
|
|
1075
|
+
try {
|
|
1076
|
+
descendsFromBase = await this.git.isAncestor(project.path, source.baseCommit, physical.baseCommit);
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
|
|
1080
|
+
+ `${round.id}/${source.projectId}: `
|
|
1081
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1082
|
+
}
|
|
1083
|
+
if (!descendsFromBase) {
|
|
1084
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
|
|
1085
|
+
+ `${round.id}/${source.projectId}: expected ancestor ${source.baseCommit}, `
|
|
1086
|
+
+ `physical HEAD ${physical.baseCommit}.`);
|
|
1087
|
+
}
|
|
167
1088
|
}
|
|
168
1089
|
}
|
|
1090
|
+
const preparedByProject = new Map(prepared.map(({ project, entry }) => [project.id, entry]));
|
|
1091
|
+
const entries = adopted
|
|
1092
|
+
? frozenEntries.map((source) => {
|
|
1093
|
+
const retainedEntry = retained.get(source.projectId)?.entry;
|
|
1094
|
+
if (retainedEntry !== undefined)
|
|
1095
|
+
return retainedEntry;
|
|
1096
|
+
const preparedEntry = preparedByProject.get(source.projectId);
|
|
1097
|
+
if (preparedEntry === undefined) {
|
|
1098
|
+
throw new Error(`ReviewRound workspace Project could not be reconstructed: `
|
|
1099
|
+
+ `${round.id}/${source.projectId}.`);
|
|
1100
|
+
}
|
|
1101
|
+
return preparedEntry;
|
|
1102
|
+
})
|
|
1103
|
+
: prepared.map(({ entry }) => entry);
|
|
1104
|
+
try {
|
|
1105
|
+
await ensureWorkspaceView(reviewRoot, entries);
|
|
1106
|
+
}
|
|
169
1107
|
catch (error) {
|
|
170
|
-
|
|
1108
|
+
if (existing !== null) {
|
|
1109
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
|
|
1110
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
1111
|
+
}
|
|
1112
|
+
throw error;
|
|
171
1113
|
}
|
|
1114
|
+
const stored = existing ?? createManagedWorkspace({
|
|
1115
|
+
owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
|
|
1116
|
+
root: reviewRoot,
|
|
1117
|
+
entries
|
|
1118
|
+
}, this.now());
|
|
1119
|
+
return this.store.transaction((tx) => {
|
|
1120
|
+
const currentRound = tx.getReviewRound(task.id, round.id);
|
|
1121
|
+
const currentItem = tx.getWorkItem(task.id, item.id);
|
|
1122
|
+
if (currentRound === null || currentRound.status !== "pending"
|
|
1123
|
+
|| currentItem === null
|
|
1124
|
+
|| !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
|
|
1125
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
|
|
1126
|
+
}
|
|
1127
|
+
if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
|
|
1128
|
+
throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
|
|
1129
|
+
}
|
|
1130
|
+
const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
|
|
1131
|
+
if (existing !== null
|
|
1132
|
+
? currentWorkspace === null || !sameManagedWorkspace(currentWorkspace, existing)
|
|
1133
|
+
: currentWorkspace !== null && !sameManagedWorkspace(currentWorkspace, stored)) {
|
|
1134
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace changed: ${task.id}/${round.id}.`);
|
|
1135
|
+
}
|
|
1136
|
+
const latestReviewer = tx.getRole(task.id, reviewer.name);
|
|
1137
|
+
if (latestReviewer === null) {
|
|
1138
|
+
throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role not found: ${task.id}/${reviewer.name}.`);
|
|
1139
|
+
}
|
|
1140
|
+
if (currentRound.workspace !== undefined
|
|
1141
|
+
&& !sameManagedWorkspace(currentRound.workspace, stored)) {
|
|
1142
|
+
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1143
|
+
}
|
|
1144
|
+
const timestamp = this.now();
|
|
1145
|
+
if (currentWorkspace === null)
|
|
1146
|
+
tx.saveManagedWorkspace(stored);
|
|
1147
|
+
if (currentRound.workspace === undefined) {
|
|
1148
|
+
tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
|
|
1149
|
+
}
|
|
1150
|
+
if (latestReviewer.workspace !== stored.root) {
|
|
1151
|
+
retireWorkspaceBoundSession(tx, task.id, latestReviewer.name, timestamp);
|
|
1152
|
+
tx.saveRole(task.id, updateRole(latestReviewer, { workspace: stored.root }, timestamp));
|
|
1153
|
+
}
|
|
1154
|
+
return stored;
|
|
1155
|
+
});
|
|
172
1156
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
1157
|
+
catch (error) {
|
|
1158
|
+
await this.#discardUnadoptedEntries(task, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
|
|
1159
|
+
throw error;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
1163
|
+
const round = this.store.getReviewRound(taskId, reviewRoundId);
|
|
1164
|
+
if (round === null)
|
|
1165
|
+
throw new Error(`ReviewRound not found: ${taskId}/${reviewRoundId}.`);
|
|
1166
|
+
const workspace = this.store.getReviewRoundWorkspace(taskId, reviewRoundId);
|
|
1167
|
+
if (workspace === null)
|
|
1168
|
+
return "missing";
|
|
1169
|
+
assertReviewRoundOwnsWorkspace(round.id, workspace);
|
|
1170
|
+
if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, workspace)) {
|
|
1171
|
+
throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1172
|
+
}
|
|
1173
|
+
return this.#inspectEntries(taskId, round.id, workspace.entries);
|
|
1174
|
+
}
|
|
1175
|
+
async snapshotReviewRoundResult(taskId, reviewRoundId) {
|
|
1176
|
+
const round = this.store.getReviewRound(taskId, reviewRoundId);
|
|
1177
|
+
if (round === null)
|
|
1178
|
+
throw new Error(`ReviewRound not found: ${taskId}/${reviewRoundId}.`);
|
|
1179
|
+
const workspace = this.store.getReviewRoundWorkspace(taskId, reviewRoundId);
|
|
1180
|
+
if (round.status !== "running" || workspace === null) {
|
|
1181
|
+
throw new Error(`ReviewRound is not running in a managed workspace: ${reviewRoundId}.`);
|
|
1182
|
+
}
|
|
1183
|
+
assertReviewRoundOwnsWorkspace(round.id, workspace);
|
|
1184
|
+
if (round.workspace === undefined || !isDeepStrictEqual(round.workspace, workspace)) {
|
|
1185
|
+
throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1186
|
+
}
|
|
1187
|
+
return this.#snapshotReviewWorkspaceEntries(round.id, workspace);
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Snapshot the exact workspace recorded on one Reviewer Run. Panel Lanes
|
|
1191
|
+
* own separate durable workspaces; the ReviewRound's shared workspace is
|
|
1192
|
+
* not a valid substitute for a Lane's branch/head evidence.
|
|
1193
|
+
*/
|
|
1194
|
+
async snapshotReviewRunResult(taskId, run) {
|
|
1195
|
+
if (run.taskId !== taskId || run.purpose !== "review" || run.reviewRoundId === undefined) {
|
|
1196
|
+
throw new Error(`Run is not an exact ReviewRound Run: ${run.id}.`);
|
|
1197
|
+
}
|
|
1198
|
+
if (run.workspace === undefined) {
|
|
1199
|
+
throw new Error(`Review Run has no managed workspace: ${run.id}.`);
|
|
1200
|
+
}
|
|
1201
|
+
const round = this.store.getReviewRound(taskId, run.reviewRoundId);
|
|
1202
|
+
if (round === null || round.status !== "running") {
|
|
1203
|
+
throw new Error(`ReviewRound is not running for Review Run: ${run.id}.`);
|
|
1204
|
+
}
|
|
1205
|
+
const stored = this.store.getManagedWorkspace(run.workspace.owner);
|
|
1206
|
+
if (stored === null || !isDeepStrictEqual(stored, run.workspace)) {
|
|
1207
|
+
throw new Error(`Review Run workspace is not the durable owner: ${run.id}.`);
|
|
1208
|
+
}
|
|
1209
|
+
if (run.workspace.owner.type === "review-round") {
|
|
1210
|
+
if (run.workspace.owner.taskId !== taskId
|
|
1211
|
+
|| run.workspace.owner.reviewRoundId !== round.id
|
|
1212
|
+
|| round.workspace === undefined
|
|
1213
|
+
|| !isDeepStrictEqual(round.workspace, run.workspace)) {
|
|
1214
|
+
throw new Error(`Review Run workspace owner does not match its ReviewRound: ${run.id}.`);
|
|
1215
|
+
}
|
|
1216
|
+
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
|
|
1217
|
+
}
|
|
1218
|
+
if (run.workspace.owner.type !== "execution-lane"
|
|
1219
|
+
|| run.workspace.owner.taskId !== taskId
|
|
1220
|
+
|| run.workspace.owner.purpose !== "review"
|
|
1221
|
+
|| run.workspace.owner.reviewRoundId !== round.id
|
|
1222
|
+
|| run.workspace.owner.executionGroupId !== run.executionGroupId
|
|
1223
|
+
|| run.workspace.owner.executionLaneId !== run.executionLaneId) {
|
|
1224
|
+
throw new Error(`Review Run workspace owner does not match its Lane lineage: ${run.id}.`);
|
|
1225
|
+
}
|
|
1226
|
+
const lane = round.executionGroup?.lanes.find(({ id }) => id === run.executionLaneId);
|
|
1227
|
+
const writableProjectIds = run.workspace.entries
|
|
1228
|
+
.filter(({ access }) => access === "write")
|
|
1229
|
+
.map(({ projectId }) => projectId)
|
|
1230
|
+
.sort();
|
|
1231
|
+
if (lane === undefined
|
|
1232
|
+
|| lane.runId !== run.id
|
|
1233
|
+
|| lane.roleName !== run.roleName
|
|
1234
|
+
|| lane.reviewRoundId !== round.id
|
|
1235
|
+
|| lane.workspace?.root !== run.workspace.root
|
|
1236
|
+
|| !isDeepStrictEqual([...lane.workspace.writableProjectIds].sort(), writableProjectIds)
|
|
1237
|
+
|| run.workspace.entries.length === 0
|
|
1238
|
+
|| run.workspace.entries.some(({ access }) => access !== "write")) {
|
|
1239
|
+
throw new Error(`Review Run Lane workspace lineage is not exact: ${run.id}.`);
|
|
1240
|
+
}
|
|
1241
|
+
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
|
|
1242
|
+
}
|
|
1243
|
+
async inspectExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
|
|
1244
|
+
requireTask(this.store, taskId);
|
|
1245
|
+
const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
|
|
1246
|
+
&& owner.executionGroupId === executionGroupId
|
|
1247
|
+
&& owner.executionLaneId === executionLaneId));
|
|
1248
|
+
if (workspace === undefined)
|
|
1249
|
+
return "missing";
|
|
1250
|
+
return this.#inspectEntries(taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
1251
|
+
}
|
|
1252
|
+
async cleanupReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
1253
|
+
const task = requireTask(this.store, taskId);
|
|
1254
|
+
const round = this.store.getReviewRound(task.id, reviewRoundId);
|
|
1255
|
+
if (round === null)
|
|
1256
|
+
throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
|
|
1257
|
+
if (round.status !== "completed" && round.status !== "failed") {
|
|
1258
|
+
throw new Error(`ReviewRound must be terminal before cleanup: ${round.id}.`);
|
|
1259
|
+
}
|
|
1260
|
+
if (round.workspaceDisposition?.kind === "removed")
|
|
1261
|
+
return "missing";
|
|
1262
|
+
const workspace = this.store.getReviewRoundWorkspace(task.id, round.id);
|
|
1263
|
+
if (workspace === null || round.workspace === undefined) {
|
|
1264
|
+
throw new Error(`ReviewRound has no managed workspace: ${round.id}.`);
|
|
1265
|
+
}
|
|
1266
|
+
assertReviewRoundOwnsWorkspace(round.id, workspace);
|
|
1267
|
+
if (!isDeepStrictEqual(round.workspace, workspace)) {
|
|
1268
|
+
throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1269
|
+
}
|
|
1270
|
+
assertWorkspaceSessionsRetirable(this.store, task.id, round.reviewerRoleName, this.now());
|
|
1271
|
+
for (const lane of round.executionGroup?.lanes ?? []) {
|
|
1272
|
+
assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
|
|
1273
|
+
}
|
|
1274
|
+
if (await this.#inspectEntries(task.id, managedWorktreeName(workspace.owner), workspace.entries) === "dirty")
|
|
1275
|
+
return "dirty";
|
|
1276
|
+
let removed = false;
|
|
1277
|
+
for (const entry of workspace.entries) {
|
|
1278
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1279
|
+
const result = await this.git.removeWorktree({
|
|
1280
|
+
repositoryPath: project.path,
|
|
1281
|
+
container: this.#projectContainer(project.name),
|
|
176
1282
|
taskId: task.id,
|
|
1283
|
+
roleName: managedWorktreeName(workspace.owner),
|
|
1284
|
+
deleteBranch: true
|
|
1285
|
+
});
|
|
1286
|
+
if (result === "dirty") {
|
|
1287
|
+
throw new Error(`Review workspace changed after cleanup preflight: ${round.id}.`);
|
|
1288
|
+
}
|
|
1289
|
+
removed ||= result === "removed";
|
|
1290
|
+
}
|
|
1291
|
+
await removeWorkspaceView(workspace.root);
|
|
1292
|
+
this.store.transaction((tx) => {
|
|
1293
|
+
const currentRound = tx.getReviewRound(task.id, round.id);
|
|
1294
|
+
const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
|
|
1295
|
+
if (currentRound === null || currentRound.status !== round.status
|
|
1296
|
+
|| currentRound.workspace === undefined
|
|
1297
|
+
|| !isDeepStrictEqual(currentRound.workspace, workspace)
|
|
1298
|
+
|| currentWorkspace === null
|
|
1299
|
+
|| !sameManagedWorkspace(currentWorkspace, workspace)) {
|
|
1300
|
+
throw new Error(`ReviewRound changed before cleanup was recorded: ${round.id}.`);
|
|
1301
|
+
}
|
|
1302
|
+
tx.removeManagedWorkspace(workspace.owner);
|
|
1303
|
+
tx.saveReviewRound(task.id, recordReviewWorkspaceDisposition(currentRound, "removed", this.now()));
|
|
1304
|
+
const reviewer = tx.getRole(task.id, round.reviewerRoleName);
|
|
1305
|
+
if (reviewer !== null && reviewer.workspace === workspace.root) {
|
|
1306
|
+
const main = tx.getTaskWorkspace(task.id);
|
|
1307
|
+
const target = main?.root ?? this.#fallbackWorkspace();
|
|
1308
|
+
const timestamp = this.now();
|
|
1309
|
+
retireWorkspaceBoundSession(tx, task.id, reviewer.name, timestamp);
|
|
1310
|
+
tx.saveRole(task.id, updateRole(reviewer, { workspace: target }, timestamp));
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
return removed ? "removed" : "missing";
|
|
1314
|
+
}
|
|
1315
|
+
async cleanupWorkItemWorkspace(taskId, workItemId, disposition) {
|
|
1316
|
+
const item = requireWorkItem(this.store, taskId, workItemId);
|
|
1317
|
+
const task = requireTask(this.store, item.taskId);
|
|
1318
|
+
if (!["completed", "failed", "retired"].includes(item.status)) {
|
|
1319
|
+
throw new Error(`Work item must be terminal before cleanup: ${item.id}.`);
|
|
1320
|
+
}
|
|
1321
|
+
if (item.workspaceDisposition !== undefined && item.workspaceDisposition !== disposition) {
|
|
1322
|
+
throw new Error(`Work item workspace is already recorded as ${item.workspaceDisposition}.`);
|
|
1323
|
+
}
|
|
1324
|
+
const workspace = this.store.getWorkItemWorkspace(task.id, item.id);
|
|
1325
|
+
if (workspace === null) {
|
|
1326
|
+
if (item.workspaceDisposition === disposition)
|
|
1327
|
+
return "missing";
|
|
1328
|
+
throw new Error(`WorkItem has no managed isolated worktree workspace: ${item.id}.`);
|
|
1329
|
+
}
|
|
1330
|
+
assertWorkItemOwnsWorkspace(item, workspace);
|
|
1331
|
+
if (item.assignee !== undefined) {
|
|
1332
|
+
assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
|
|
1333
|
+
}
|
|
1334
|
+
for (const group of item.executionGroups)
|
|
1335
|
+
for (const lane of group.lanes) {
|
|
1336
|
+
assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
|
|
1337
|
+
}
|
|
1338
|
+
const writable = workspace.entries.filter(({ access }) => access === "write");
|
|
1339
|
+
if (await this.#inspectEntries(task.id, managedWorktreeName(workspace.owner), writable) === "dirty")
|
|
1340
|
+
return "dirty";
|
|
1341
|
+
let removed = false;
|
|
1342
|
+
for (const entry of writable) {
|
|
1343
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1344
|
+
const result = await this.git.removeWorktree({
|
|
1345
|
+
repositoryPath: project.path,
|
|
1346
|
+
container: this.#projectContainer(project.name),
|
|
1347
|
+
taskId: task.id,
|
|
1348
|
+
roleName: managedWorktreeName(workspace.owner),
|
|
1349
|
+
deleteBranch: true
|
|
1350
|
+
});
|
|
1351
|
+
if (result === "dirty") {
|
|
1352
|
+
throw new Error(`WorkItem workspace changed after cleanup preflight: ${item.id}.`);
|
|
1353
|
+
}
|
|
1354
|
+
removed ||= result === "removed";
|
|
1355
|
+
}
|
|
1356
|
+
await removeWorkspaceView(workspace.root);
|
|
1357
|
+
try {
|
|
1358
|
+
this.#recordWorkspaceRemoval(task, workspace, this.#fallbackWorkspace(), {
|
|
1359
|
+
workItemId: item.id,
|
|
1360
|
+
disposition
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
if (!removed)
|
|
1365
|
+
throw error;
|
|
1366
|
+
throw new Error(`WorkItem worktree was removed but durable cleanup was not recorded; retry cleanup: ${error instanceof Error ? error.message : String(error)}`);
|
|
1367
|
+
}
|
|
1368
|
+
return removed ? "removed" : "missing";
|
|
1369
|
+
}
|
|
1370
|
+
async inspectTaskMainWorkspace(taskId) {
|
|
1371
|
+
const task = requireTask(this.store, taskId);
|
|
1372
|
+
const main = this.store.getTaskWorkspace(task.id);
|
|
1373
|
+
if (main === null)
|
|
1374
|
+
return "missing";
|
|
1375
|
+
if (main.owner.type !== "task") {
|
|
1376
|
+
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
1377
|
+
}
|
|
1378
|
+
return this.#inspectEntries(task.id, MAIN_WORKTREE, main.entries);
|
|
1379
|
+
}
|
|
1380
|
+
async cleanupTaskForArchive(taskId) {
|
|
1381
|
+
const task = requireTask(this.store, taskId);
|
|
1382
|
+
assertTaskArchiveState(task, task);
|
|
1383
|
+
const isolated = this.store.listManagedWorkspaces(task.id)
|
|
1384
|
+
.filter(({ owner }) => owner.type !== "task");
|
|
1385
|
+
if (isolated.length > 0) {
|
|
1386
|
+
return {
|
|
1387
|
+
taskId,
|
|
177
1388
|
status: "failed",
|
|
178
|
-
path:
|
|
179
|
-
error: `
|
|
1389
|
+
...(task.cwd === undefined ? {} : { path: task.cwd }),
|
|
1390
|
+
error: `Managed workspaces require explicit cleanup: ${isolated.map(({ owner }) => managedWorkspaceKey(owner)).join(", ")}.`
|
|
180
1391
|
};
|
|
181
1392
|
}
|
|
182
|
-
this
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
1393
|
+
const main = this.store.getTaskWorkspace(task.id);
|
|
1394
|
+
if (main !== null) {
|
|
1395
|
+
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1396
|
+
if (await this.#inspectEntries(task.id, MAIN_WORKTREE, main.entries) === "dirty") {
|
|
1397
|
+
return {
|
|
1398
|
+
taskId,
|
|
1399
|
+
status: "retained-dirty",
|
|
1400
|
+
...(task.cwd === undefined ? {} : { path: task.cwd })
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
for (const entry of main.entries) {
|
|
1404
|
+
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1405
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1406
|
+
const result = await this.git.removeWorktree({
|
|
1407
|
+
repositoryPath: project.path,
|
|
1408
|
+
container: this.#projectContainer(project.name),
|
|
1409
|
+
taskId: task.id,
|
|
1410
|
+
roleName: MAIN_WORKTREE
|
|
1411
|
+
});
|
|
1412
|
+
if (result === "dirty") {
|
|
1413
|
+
throw new Error(`Task workspace changed after cleanup preflight: ${task.id}.`);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1417
|
+
await removeWorkspaceView(main.root);
|
|
1418
|
+
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1419
|
+
this.#recordWorkspaceRemoval(task, main, this.#fallbackWorkspace());
|
|
1420
|
+
}
|
|
1421
|
+
this.#clearTaskWorkspace(task, this.#fallbackWorkspace());
|
|
1422
|
+
return { taskId, status: "removed" };
|
|
1423
|
+
}
|
|
1424
|
+
async #inspectEntries(taskId, roleName, entries) {
|
|
1425
|
+
if (entries.length === 0)
|
|
1426
|
+
return "missing";
|
|
1427
|
+
let found = false;
|
|
1428
|
+
for (const entry of entries) {
|
|
1429
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1430
|
+
const state = await this.git.inspectWorktree({
|
|
1431
|
+
repositoryPath: project.path,
|
|
1432
|
+
container: this.#projectContainer(project.name),
|
|
1433
|
+
taskId,
|
|
1434
|
+
roleName
|
|
1435
|
+
});
|
|
1436
|
+
if (state === "dirty")
|
|
1437
|
+
return "dirty";
|
|
1438
|
+
found ||= state === "clean";
|
|
1439
|
+
}
|
|
1440
|
+
return found ? "clean" : "missing";
|
|
1441
|
+
}
|
|
1442
|
+
async #snapshotReviewWorkspaceEntries(reviewRoundId, workspace) {
|
|
1443
|
+
const changed = [];
|
|
1444
|
+
for (const entry of workspace.entries) {
|
|
1445
|
+
if (await this.git.headRef(entry.path) !== entry.branch) {
|
|
1446
|
+
throw new Error(`Review Project workspace left its managed branch: ${reviewRoundId}/${entry.projectId}.`);
|
|
1447
|
+
}
|
|
1448
|
+
const head = (await this.git.inspect(entry.path, "HEAD")).baseCommit;
|
|
1449
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1450
|
+
if (!await this.git.isAncestor(project.path, entry.baseCommit, head)) {
|
|
1451
|
+
throw new Error(`Review workspace HEAD does not descend from its frozen base for `
|
|
1452
|
+
+ `${reviewRoundId}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
|
|
1453
|
+
+ `physical HEAD ${head}.`);
|
|
1454
|
+
}
|
|
1455
|
+
if (head !== entry.baseCommit)
|
|
1456
|
+
changed.push(head);
|
|
1457
|
+
}
|
|
1458
|
+
if (changed.length > 1) {
|
|
1459
|
+
throw new Error(`Review workspace has diagnostic commits in multiple Projects; preserve it for Leader routing: ${reviewRoundId}.`);
|
|
1460
|
+
}
|
|
1461
|
+
return changed.length === 0 ? {} : { evidenceCommit: changed[0] };
|
|
1462
|
+
}
|
|
1463
|
+
#projectContainer(projectName) {
|
|
1464
|
+
return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
|
|
1465
|
+
}
|
|
1466
|
+
#taskWorkspaceRoot(taskId) {
|
|
1467
|
+
return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "main");
|
|
1468
|
+
}
|
|
1469
|
+
#workItemWorkspaceRoot(taskId, workItemId) {
|
|
1470
|
+
return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "work-items", safePathSegment(workItemId));
|
|
1471
|
+
}
|
|
1472
|
+
#reviewRoundWorkspaceRoot(taskId, reviewRoundId) {
|
|
1473
|
+
return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "reviews", safePathSegment(reviewRoundId));
|
|
1474
|
+
}
|
|
1475
|
+
#executionLaneWorkspaceRoot(taskId, groupId, laneId) {
|
|
1476
|
+
return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "execution-lanes", safePathSegment(groupId), safePathSegment(laneId));
|
|
1477
|
+
}
|
|
1478
|
+
#fallbackWorkspace() {
|
|
1479
|
+
return this.store.getConfig().defaultWorkspace ?? process.cwd();
|
|
1480
|
+
}
|
|
1481
|
+
async #discardUnadoptedEntries(task, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
|
|
1482
|
+
.flatMap((workspace) => workspace.entries.map(({ path }) => path)))) {
|
|
1483
|
+
for (const { project, entry } of prepared.filter(({ entry }) => entry.access === "write" && !adoptedPaths.has(entry.path))) {
|
|
1484
|
+
const removal = await this.git.removeWorktree({
|
|
1485
|
+
repositoryPath: project.path,
|
|
1486
|
+
container: this.#projectContainer(project.name),
|
|
1487
|
+
taskId: task.id,
|
|
1488
|
+
roleName,
|
|
1489
|
+
...(deleteBranch ? { deleteBranch: true } : {})
|
|
1490
|
+
});
|
|
1491
|
+
if (removal === "dirty") {
|
|
1492
|
+
throw new Error(`Unadopted managed worktree is dirty and was retained at ${entry.path}; inspect it and retry.`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
#recordWorkspaceRemoval(task, workspace, fallback, workItem) {
|
|
1497
|
+
this.store.transaction((tx) => {
|
|
1498
|
+
if (workspace.owner.type === "task") {
|
|
1499
|
+
assertTaskArchiveState(requireTask(tx, task.id), task);
|
|
1500
|
+
}
|
|
1501
|
+
const current = tx.listManagedWorkspaces(task.id)
|
|
1502
|
+
.find((entry) => managedWorkspaceKey(entry.owner) === managedWorkspaceKey(workspace.owner))
|
|
1503
|
+
?? null;
|
|
1504
|
+
if (current === null || !sameManagedWorkspace(current, workspace)) {
|
|
1505
|
+
if (workItem === undefined) {
|
|
1506
|
+
throw new Error(`Managed workspace changed before cleanup was recorded: ${task.id}/${managedWorkspaceKey(workspace.owner)}.`);
|
|
1507
|
+
}
|
|
1508
|
+
recordWorkspaceDisposition(tx, task.id, workItem, this.now());
|
|
200
1509
|
return;
|
|
201
|
-
if (role.workspace !== repositoryPath) {
|
|
202
|
-
store.saveRole(taskId, updateRole(role, { workspace: repositoryPath }, now));
|
|
203
1510
|
}
|
|
204
|
-
|
|
1511
|
+
const roleName = workspace.owner.type === "work-item"
|
|
1512
|
+
? tx.getWorkItem(task.id, workspace.owner.workItemId)?.assignee
|
|
1513
|
+
: workspace.owner.type === "task" ? LEADER_ROLE : undefined;
|
|
1514
|
+
const role = roleName === undefined ? null : tx.getRole(task.id, roleName);
|
|
1515
|
+
tx.removeManagedWorkspace(workspace.owner);
|
|
1516
|
+
const main = tx.getTaskWorkspace(task.id);
|
|
1517
|
+
const target = workspace.owner.type === "task" ? fallback : main?.root ?? fallback;
|
|
1518
|
+
if (role !== null && role.workspace !== target) {
|
|
1519
|
+
const timestamp = this.now();
|
|
1520
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
1521
|
+
tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
|
|
1522
|
+
}
|
|
1523
|
+
if (workItem !== undefined) {
|
|
1524
|
+
recordWorkspaceDisposition(tx, task.id, workItem, this.now());
|
|
1525
|
+
}
|
|
205
1526
|
});
|
|
206
1527
|
}
|
|
207
|
-
#
|
|
208
|
-
this.store.transaction((
|
|
209
|
-
const latest = requireTask(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
1528
|
+
#clearTaskWorkspace(task, fallback) {
|
|
1529
|
+
this.store.transaction((tx) => {
|
|
1530
|
+
const latest = requireTask(tx, task.id);
|
|
1531
|
+
assertTaskArchiveState(latest, task);
|
|
1532
|
+
for (const role of tx.listRoles(task.id)) {
|
|
1533
|
+
if (role.workspace !== fallback) {
|
|
1534
|
+
const timestamp = this.now();
|
|
1535
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
1536
|
+
tx.saveRole(task.id, updateRole(role, { workspace: fallback }, timestamp));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
if (latest.cwd !== undefined) {
|
|
1540
|
+
const { cwd: _cwd, ...withoutCwd } = latest;
|
|
1541
|
+
tx.saveTask({ ...withoutCwd, updatedAt: this.now().toISOString() });
|
|
215
1542
|
}
|
|
216
1543
|
});
|
|
217
1544
|
}
|
|
218
|
-
|
|
219
|
-
|
|
1545
|
+
}
|
|
1546
|
+
function requireTask(store, taskId) {
|
|
1547
|
+
const task = store.getTask(taskId);
|
|
1548
|
+
if (task === null)
|
|
1549
|
+
throw new Error(`Task not found: ${taskId}.`);
|
|
1550
|
+
return task;
|
|
1551
|
+
}
|
|
1552
|
+
function assertTaskArchiveState(current, expected) {
|
|
1553
|
+
if ((current.status !== "completed" && current.status !== "retired")
|
|
1554
|
+
|| !isDeepStrictEqual(current, expected)) {
|
|
1555
|
+
throw new WorkspaceCleanupBlockedError("task-changed", `task:${expected.id}`, true, `Task changed during archive cleanup: ${expected.id}.`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
function requireProject(store, projectId) {
|
|
1559
|
+
const project = store.getProject(projectId);
|
|
1560
|
+
if (project === null)
|
|
1561
|
+
throw new Error(`Project not found: ${projectId}.`);
|
|
1562
|
+
return project;
|
|
1563
|
+
}
|
|
1564
|
+
function requireWorkItem(store, taskId, workItemId) {
|
|
1565
|
+
const item = store.getWorkItem(taskId, workItemId);
|
|
1566
|
+
if (item === null)
|
|
1567
|
+
throw new Error(`Work item not found: ${taskId}/${workItemId}.`);
|
|
1568
|
+
return item;
|
|
1569
|
+
}
|
|
1570
|
+
function requireWorkspaceEntry(workspace, projectId) {
|
|
1571
|
+
const entry = workspace.entries.find((candidate) => candidate.projectId === projectId);
|
|
1572
|
+
if (entry === undefined)
|
|
1573
|
+
throw new Error(`Workspace Project not found: ${projectId}.`);
|
|
1574
|
+
return entry;
|
|
1575
|
+
}
|
|
1576
|
+
function executionLaneLineage(store, task, executionGroupId, executionLaneId, hint) {
|
|
1577
|
+
if (hint?.purpose === "execution" && hint.workItemId !== undefined) {
|
|
1578
|
+
return { purpose: "execution", workItemId: hint.workItemId, reviewRoundId: "" };
|
|
1579
|
+
}
|
|
1580
|
+
if (hint?.purpose === "review" && hint.reviewRoundId !== undefined) {
|
|
1581
|
+
const round = store.getReviewRound(task.id, hint.reviewRoundId);
|
|
1582
|
+
if (round === null)
|
|
1583
|
+
throw new Error(`ReviewRound not found: ${hint.reviewRoundId}.`);
|
|
1584
|
+
return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
|
|
1585
|
+
}
|
|
1586
|
+
for (const item of store.listWorkItems(task.id)) {
|
|
1587
|
+
if (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId)) {
|
|
1588
|
+
return { purpose: "execution", workItemId: item.id, reviewRoundId: "" };
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
for (const round of store.listReviewRounds(task.id)) {
|
|
1592
|
+
if (round.executionGroup?.id === executionGroupId
|
|
1593
|
+
&& round.executionGroup.lanes.some(({ id }) => id === executionLaneId)) {
|
|
1594
|
+
return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
throw new Error(`Execution Lane lineage not found: ${task.id}/${executionGroupId}/${executionLaneId}.`);
|
|
1598
|
+
}
|
|
1599
|
+
function assertWorkItemWorkspaceEligible(store, task, item) {
|
|
1600
|
+
if (task.status !== "active")
|
|
1601
|
+
throw new Error(`Task is not active: ${task.id}.`);
|
|
1602
|
+
if (["completed", "failed", "retired"].includes(item.status)) {
|
|
1603
|
+
throw new Error(`Work item is already terminal: ${item.id}.`);
|
|
1604
|
+
}
|
|
1605
|
+
const activeDevelopRun = store.listAgentRuns(task.id)
|
|
1606
|
+
.find((run) => run.status === "active" && run.workItemId === item.id);
|
|
1607
|
+
if (activeDevelopRun !== undefined) {
|
|
1608
|
+
throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
|
|
1609
|
+
}
|
|
1610
|
+
if (item.assignee !== undefined && store.getActiveAgentRun(task.id, item.assignee) !== null) {
|
|
1611
|
+
throw new Error(`Role has an active Run: ${task.id}/${item.assignee}.`);
|
|
220
1612
|
}
|
|
221
1613
|
}
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1614
|
+
function assertWorkItemOwnsWorkspace(item, workspace) {
|
|
1615
|
+
if (workspace.owner.type !== "work-item" || workspace.owner.workItemId !== item.id) {
|
|
1616
|
+
throw new Error(`WorkItem does not own the managed workspace: ${item.id}.`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
function assertReviewRoundOwnsWorkspace(reviewRoundId, workspace) {
|
|
1620
|
+
if (workspace.owner.type !== "review-round"
|
|
1621
|
+
|| workspace.owner.reviewRoundId !== reviewRoundId) {
|
|
1622
|
+
throw new Error(`ReviewRound does not own the Role workspace: ${reviewRoundId}.`);
|
|
1623
|
+
}
|
|
228
1624
|
}
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
1625
|
+
function preserveWorkspaceCreatedAt(workspace, previous) {
|
|
1626
|
+
return previous === null
|
|
1627
|
+
? workspace
|
|
1628
|
+
: { ...workspace, createdAt: previous.createdAt };
|
|
1629
|
+
}
|
|
1630
|
+
function assertWorkspaceSessionsRetirable(store, taskId, roleName, now) {
|
|
1631
|
+
if (store.getActiveAgentRun(taskId, roleName) !== null) {
|
|
1632
|
+
throw new Error(`Role has an active Run: ${taskId}/${roleName}.`);
|
|
1633
|
+
}
|
|
1634
|
+
const sessions = store.getTaskRoleSessionSet(taskId, roleName);
|
|
1635
|
+
if (sessions !== null)
|
|
1636
|
+
retireTaskRoleSessionsForWorkspace(sessions, now);
|
|
1637
|
+
}
|
|
1638
|
+
function retireWorkspaceBoundSession(store, taskId, roleName, now) {
|
|
1639
|
+
if (store.getActiveAgentRun(taskId, roleName) !== null) {
|
|
1640
|
+
throw new Error(`Role has an active Run: ${taskId}/${roleName}.`);
|
|
1641
|
+
}
|
|
1642
|
+
const sessions = store.getTaskRoleSessionSet(taskId, roleName);
|
|
1643
|
+
if (sessions !== null) {
|
|
1644
|
+
store.saveTaskRoleSessionSet(retireTaskRoleSessionsForWorkspace(sessions, now));
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
function sameManagedWorkspace(left, right) {
|
|
1648
|
+
return isDeepStrictEqual(left, right);
|
|
1649
|
+
}
|
|
1650
|
+
function sameManagedWorkspaceIdentity(left, right) {
|
|
1651
|
+
return isDeepStrictEqual({ owner: left.owner, root: left.root, entries: left.entries }, { owner: right.owner, root: right.root, entries: right.entries });
|
|
1652
|
+
}
|
|
1653
|
+
function isMissingPath(error) {
|
|
1654
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
1655
|
+
&& error.code === "ENOENT";
|
|
1656
|
+
}
|
|
1657
|
+
function recordWorkspaceDisposition(store, taskId, workItem, now) {
|
|
1658
|
+
const item = store.getWorkItem(taskId, workItem.workItemId);
|
|
1659
|
+
if (item === null)
|
|
1660
|
+
throw new Error(`Work item not found: ${workItem.workItemId}.`);
|
|
1661
|
+
store.saveWorkItem(taskId, recordWorkItemWorkspaceDisposition(item, workItem.disposition, now));
|
|
1662
|
+
}
|
|
1663
|
+
async function ensureWorkspaceView(root, entries) {
|
|
1664
|
+
await mkdir(root, { recursive: true });
|
|
1665
|
+
const expected = new Map(entries.map((entry) => [entry.directory, entry.path]));
|
|
1666
|
+
for (const current of await readdir(root, { withFileTypes: true })) {
|
|
1667
|
+
const target = expected.get(current.name);
|
|
1668
|
+
if (!current.isSymbolicLink()) {
|
|
1669
|
+
throw new Error(`Managed workspace contains an unexpected entry: ${join(root, current.name)}.`);
|
|
1670
|
+
}
|
|
1671
|
+
const path = join(root, current.name);
|
|
1672
|
+
const linked = resolve(root, await readlink(path));
|
|
1673
|
+
if (target === undefined) {
|
|
1674
|
+
await unlink(path);
|
|
1675
|
+
continue;
|
|
235
1676
|
}
|
|
1677
|
+
if (linked !== target) {
|
|
1678
|
+
await unlink(path);
|
|
1679
|
+
await symlink(target, path, "dir");
|
|
1680
|
+
}
|
|
1681
|
+
expected.delete(current.name);
|
|
1682
|
+
}
|
|
1683
|
+
for (const [directory, target] of expected) {
|
|
1684
|
+
await symlink(target, join(root, directory), "dir");
|
|
236
1685
|
}
|
|
237
1686
|
}
|
|
238
|
-
async function
|
|
1687
|
+
async function removeWorkspaceView(root) {
|
|
1688
|
+
let entries;
|
|
239
1689
|
try {
|
|
240
|
-
await
|
|
1690
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
241
1691
|
}
|
|
242
1692
|
catch (error) {
|
|
243
|
-
if (
|
|
244
|
-
|
|
1693
|
+
if (errorCode(error) === "ENOENT")
|
|
1694
|
+
return;
|
|
1695
|
+
throw error;
|
|
245
1696
|
}
|
|
1697
|
+
for (const entry of entries) {
|
|
1698
|
+
if (!entry.isSymbolicLink()) {
|
|
1699
|
+
throw new Error(`Managed workspace contains an unexpected entry: ${join(root, entry.name)}.`);
|
|
1700
|
+
}
|
|
1701
|
+
await unlink(join(root, entry.name));
|
|
1702
|
+
}
|
|
1703
|
+
await rmdir(root);
|
|
246
1704
|
}
|
|
247
|
-
function
|
|
248
|
-
|
|
249
|
-
|
|
1705
|
+
function safePathSegment(value) {
|
|
1706
|
+
const normalized = value.trim();
|
|
1707
|
+
if (normalized.length === 0 || [".", ".."].includes(normalized) || /[\/\\\0]/.test(normalized)) {
|
|
1708
|
+
throw new Error("Identity is invalid for managed workspace layout.");
|
|
1709
|
+
}
|
|
1710
|
+
return normalized;
|
|
250
1711
|
}
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
1712
|
+
function sameCommit(left, right) {
|
|
1713
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
1714
|
+
}
|
|
1715
|
+
function remoteDefaultProjects(store, taskId) {
|
|
1716
|
+
const projectIds = new Set();
|
|
1717
|
+
for (const event of store.listEvents(taskId)) {
|
|
1718
|
+
if (event.type === "task.created") {
|
|
1719
|
+
for (const projectId of (event.payload.defaultProjectIds ?? "").split(",")) {
|
|
1720
|
+
const normalized = projectId.trim();
|
|
1721
|
+
if (normalized.length > 0)
|
|
1722
|
+
projectIds.add(normalized);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
return projectIds;
|
|
1727
|
+
}
|
|
1728
|
+
function looksLikeCommit(value) {
|
|
1729
|
+
return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/iu.test(value);
|
|
1730
|
+
}
|
|
1731
|
+
export function resolveWorktreeRoot(home, workspace) {
|
|
1732
|
+
return join(resolveWorkspaceRoot(home, workspace), "worktree");
|
|
1733
|
+
}
|
|
1734
|
+
export function resolveTaskRoot(home, workspace) {
|
|
1735
|
+
return join(resolveWorkspaceRoot(home, workspace), "tasks");
|
|
1736
|
+
}
|
|
1737
|
+
function resolveWorkspaceRoot(home, workspace) {
|
|
1738
|
+
if (workspace === undefined) {
|
|
1739
|
+
throw new Error("Project workspace is not configured; run yui setup.");
|
|
1740
|
+
}
|
|
1741
|
+
const homeRoot = resolve(home);
|
|
1742
|
+
const workspaceRoot = resolve(workspace);
|
|
1743
|
+
const fromHome = relative(homeRoot, workspaceRoot);
|
|
1744
|
+
if (fromHome === "" || (!fromHome.startsWith("..") && !isAbsolute(fromHome))) {
|
|
1745
|
+
throw new Error("Project workspace must be outside YUI_HOME.");
|
|
1746
|
+
}
|
|
1747
|
+
return workspaceRoot;
|
|
1748
|
+
}
|
|
1749
|
+
function errorCode(error) {
|
|
1750
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
1751
|
+
? String(error.code)
|
|
1752
|
+
: undefined;
|
|
256
1753
|
}
|