@zq-silk/yui 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commandCatalog.js +31 -1
- package/dist/cli.js +8 -0
- package/dist/commands/projectCommands.js +206 -15
- package/dist/commands/taskWorkspaceCommands.js +135 -0
- package/dist/integration/gitIntegrationService.js +3 -2
- package/dist/repository/gitWorkspace.js +58 -5
- package/dist/repository/homeIdentity.js +28 -0
- package/dist/repository/project.js +13 -4
- package/dist/repository/taskWorkspaceIdentity.js +158 -0
- package/dist/repository/taskWorkspacePreparer.js +352 -29
- package/dist/storage/migration/productionRegistry.js +180 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskStore.js +13 -2
- package/dist/task/task.js +36 -3
- package/package.json +1 -1
|
@@ -4,9 +4,11 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
4
4
|
import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
|
|
5
5
|
import { updateRole } from "../role/role.js";
|
|
6
6
|
import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
|
|
7
|
+
import { bindTaskWorkspaceIdentity } from "../task/task.js";
|
|
7
8
|
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
|
|
8
9
|
import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
|
|
9
10
|
import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
|
|
11
|
+
import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity } from "./taskWorkspaceIdentity.js";
|
|
10
12
|
const MAIN_WORKTREE = "main";
|
|
11
13
|
const LEADER_ROLE = "leader";
|
|
12
14
|
export class WorkspaceCleanupBlockedError extends Error {
|
|
@@ -99,6 +101,15 @@ export class FileTaskWorkspacePreparer {
|
|
|
99
101
|
if (existing !== null && existing.owner.type !== "task") {
|
|
100
102
|
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
101
103
|
}
|
|
104
|
+
// The durable workspace identity is minted once, only for a Task that has
|
|
105
|
+
// never owned a managed Git workspace. A Task with an existing workspace
|
|
106
|
+
// record predates the identity (legacy) and keeps its refs until the
|
|
107
|
+
// controlled rebuild; a second prepare reuses the persisted identity.
|
|
108
|
+
const workspaceIdentity = task.workspaceIdentity === undefined
|
|
109
|
+
&& (existing === null || existing.entries.length === 0)
|
|
110
|
+
? await this.#mintTaskWorkspaceIdentity(task)
|
|
111
|
+
: undefined;
|
|
112
|
+
const taskSegment = taskWorkspaceRefSegment(workspaceIdentity === undefined ? task : { ...task, workspaceIdentity });
|
|
102
113
|
const root = this.#taskWorkspaceRoot(task.id);
|
|
103
114
|
const prepared = [];
|
|
104
115
|
const defaultProjects = remoteDefaultProjects(this.store, task.id);
|
|
@@ -160,7 +171,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
160
171
|
const physical = await this.git.ensureWorktree({
|
|
161
172
|
repositoryPath: project.path,
|
|
162
173
|
container: this.#projectContainer(project.name),
|
|
163
|
-
|
|
174
|
+
taskSegment,
|
|
164
175
|
roleName: MAIN_WORKTREE,
|
|
165
176
|
baseRef: previous?.baseCommit ?? baseline.baseRef
|
|
166
177
|
});
|
|
@@ -209,6 +220,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
209
220
|
let persistedTask = isDeepStrictEqual(pinnedBindings, latest.projectBindings)
|
|
210
221
|
? latest
|
|
211
222
|
: { ...latest, projectBindings: pinnedBindings, updatedAt: timestamp.toISOString() };
|
|
223
|
+
if (workspaceIdentity !== undefined) {
|
|
224
|
+
// The identity is persisted only now that every managed ref was
|
|
225
|
+
// created successfully. A concurrent prepare that bound the same
|
|
226
|
+
// identity is a no-op; a different one fails closed.
|
|
227
|
+
persistedTask = bindTaskWorkspaceIdentity(persistedTask, workspaceIdentity, timestamp);
|
|
228
|
+
}
|
|
212
229
|
if (persistedTask.cwd !== root) {
|
|
213
230
|
persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
|
|
214
231
|
}
|
|
@@ -236,10 +253,39 @@ export class FileTaskWorkspacePreparer {
|
|
|
236
253
|
return { taskId, status: "ready", path: root };
|
|
237
254
|
}
|
|
238
255
|
catch (error) {
|
|
239
|
-
await this.#discardUnadoptedEntries(task, prepared, MAIN_WORKTREE);
|
|
256
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE);
|
|
240
257
|
throw error;
|
|
241
258
|
}
|
|
242
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Mint the Task's durable workspace identity with create-not-exists
|
|
262
|
+
* semantics: the candidate main branch must not already exist in any bound
|
|
263
|
+
* Project repository. On conflict (a stale ref from a crashed attempt, or
|
|
264
|
+
* another Home's work) a fresh identity is generated; only an identity whose
|
|
265
|
+
* refs were actually created is ever persisted.
|
|
266
|
+
*/
|
|
267
|
+
async #mintTaskWorkspaceIdentity(task) {
|
|
268
|
+
const home = this.store.getHomeIdentity();
|
|
269
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
270
|
+
const identity = generateTaskWorkspaceIdentity({
|
|
271
|
+
home,
|
|
272
|
+
taskId: task.id,
|
|
273
|
+
now: this.now()
|
|
274
|
+
});
|
|
275
|
+
const mainBranch = taskMainBranch(taskWorkspaceRefSegmentFromIdentity(identity));
|
|
276
|
+
let conflict = false;
|
|
277
|
+
for (const binding of task.projectBindings) {
|
|
278
|
+
const project = requireProject(this.store, binding.projectId);
|
|
279
|
+
if (await this.git.refExists(project.path, mainBranch)) {
|
|
280
|
+
conflict = true;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!conflict)
|
|
285
|
+
return identity;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(`Could not mint a unique Task workspace identity for ${task.id}.`);
|
|
288
|
+
}
|
|
243
289
|
async snapshotCandidateWorkspace(workspace) {
|
|
244
290
|
if (workspace.owner.type === "review-round") {
|
|
245
291
|
throw new Error("ReviewRound workspace cannot become a WorkItem Candidate source.");
|
|
@@ -331,6 +377,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
331
377
|
const task = requireTask(this.store, item.taskId);
|
|
332
378
|
assertWorkItemWorkspaceEligible(this.store, task, item);
|
|
333
379
|
await this.prepareTaskWorkspace(task.id);
|
|
380
|
+
// prepareTaskWorkspace may have just minted and persisted the workspace
|
|
381
|
+
// identity; derive the segment from the persisted Task.
|
|
382
|
+
const taskSegment = this.#taskSegment(requireTask(this.store, task.id));
|
|
334
383
|
const main = this.store.getTaskWorkspace(task.id);
|
|
335
384
|
if (main === null || main.owner.type !== "task") {
|
|
336
385
|
throw new Error(`Task main workspace is not ready: ${task.id}.`);
|
|
@@ -378,7 +427,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
378
427
|
const physical = await this.git.ensureWorktree({
|
|
379
428
|
repositoryPath: project.path,
|
|
380
429
|
container: this.#projectContainer(project.name),
|
|
381
|
-
|
|
430
|
+
taskSegment,
|
|
382
431
|
roleName: item.id,
|
|
383
432
|
baseRef
|
|
384
433
|
});
|
|
@@ -467,7 +516,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
467
516
|
});
|
|
468
517
|
}
|
|
469
518
|
catch (error) {
|
|
470
|
-
await this.#discardUnadoptedEntries(task, prepared, item.id);
|
|
519
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, item.id);
|
|
471
520
|
throw error;
|
|
472
521
|
}
|
|
473
522
|
}
|
|
@@ -485,6 +534,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
485
534
|
if (source === null) {
|
|
486
535
|
throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
|
|
487
536
|
}
|
|
537
|
+
const taskSegment = this.#taskSegment(task);
|
|
488
538
|
const owner = lineage.purpose === "execution"
|
|
489
539
|
? {
|
|
490
540
|
type: "execution-lane",
|
|
@@ -513,7 +563,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
513
563
|
const physical = await this.git.ensureWorktree({
|
|
514
564
|
repositoryPath: project.path,
|
|
515
565
|
container: this.#projectContainer(project.name),
|
|
516
|
-
|
|
566
|
+
taskSegment,
|
|
517
567
|
roleName: managedWorktreeName(owner),
|
|
518
568
|
baseRef: entry.baseCommit
|
|
519
569
|
});
|
|
@@ -541,7 +591,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
541
591
|
const physical = await this.git.ensureWorktree({
|
|
542
592
|
repositoryPath: project.path,
|
|
543
593
|
container: this.#projectContainer(project.name),
|
|
544
|
-
|
|
594
|
+
taskSegment,
|
|
545
595
|
roleName: managedWorktreeName(owner),
|
|
546
596
|
baseRef: sourceEntry.baseCommit
|
|
547
597
|
});
|
|
@@ -579,7 +629,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
579
629
|
return workspace;
|
|
580
630
|
}
|
|
581
631
|
catch (error) {
|
|
582
|
-
await this.#discardUnadoptedEntries(task, prepared, managedWorktreeName(owner), true);
|
|
632
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, managedWorktreeName(owner), true);
|
|
583
633
|
throw error;
|
|
584
634
|
}
|
|
585
635
|
}
|
|
@@ -780,7 +830,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
780
830
|
&& owner.executionLaneId === executionLaneId));
|
|
781
831
|
if (workspace === undefined)
|
|
782
832
|
return "missing";
|
|
783
|
-
const state = await this.#inspectEntries(
|
|
833
|
+
const state = await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
784
834
|
if (state === "dirty")
|
|
785
835
|
return "dirty";
|
|
786
836
|
let removed = false;
|
|
@@ -789,7 +839,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
789
839
|
const result = await this.git.removeWorktree({
|
|
790
840
|
repositoryPath: project.path,
|
|
791
841
|
container: this.#projectContainer(project.name),
|
|
792
|
-
|
|
842
|
+
taskSegment: this.#taskSegment(task),
|
|
793
843
|
roleName: managedWorktreeName(workspace.owner),
|
|
794
844
|
deleteBranch: true
|
|
795
845
|
});
|
|
@@ -808,7 +858,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
808
858
|
}
|
|
809
859
|
if (this.store.getManagedWorkspace(workspace.owner) !== null)
|
|
810
860
|
return "missing";
|
|
811
|
-
const
|
|
861
|
+
const task = requireTask(this.store, workspace.owner.taskId);
|
|
862
|
+
const taskSegment = this.#taskSegment(task);
|
|
863
|
+
const state = await this.#inspectEntries(taskSegment, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
812
864
|
if (state === "dirty")
|
|
813
865
|
return "dirty";
|
|
814
866
|
let removed = false;
|
|
@@ -817,7 +869,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
817
869
|
const result = await this.git.removeWorktree({
|
|
818
870
|
repositoryPath: project.path,
|
|
819
871
|
container: this.#projectContainer(project.name),
|
|
820
|
-
|
|
872
|
+
taskSegment,
|
|
821
873
|
roleName: managedWorktreeName(workspace.owner),
|
|
822
874
|
deleteBranch: true
|
|
823
875
|
});
|
|
@@ -878,6 +930,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
878
930
|
* never used as a ChangeSet capture source. */
|
|
879
931
|
async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
880
932
|
const task = requireTask(this.store, taskId);
|
|
933
|
+
const taskSegment = this.#taskSegment(task);
|
|
881
934
|
const round = this.store.getReviewRound(task.id, reviewRoundId);
|
|
882
935
|
if (round === null)
|
|
883
936
|
throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
|
|
@@ -915,7 +968,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
915
968
|
throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
|
|
916
969
|
}
|
|
917
970
|
const project = requireProject(this.store, binding.projectId);
|
|
918
|
-
const identity = worktreeIdentity(
|
|
971
|
+
const identity = worktreeIdentity(taskSegment, round.id);
|
|
919
972
|
return {
|
|
920
973
|
projectId: binding.projectId,
|
|
921
974
|
directory: binding.directory,
|
|
@@ -967,7 +1020,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
967
1020
|
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
|
|
968
1021
|
}
|
|
969
1022
|
const project = requireProject(this.store, entry.projectId);
|
|
970
|
-
const identity = worktreeIdentity(
|
|
1023
|
+
const identity = worktreeIdentity(taskSegment, round.id);
|
|
971
1024
|
const expectedPath = join(this.#projectContainer(project.name), identity.directory);
|
|
972
1025
|
if (entry.path !== expectedPath || entry.branch !== identity.branch) {
|
|
973
1026
|
throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
|
|
@@ -1053,7 +1106,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1053
1106
|
const physical = await this.git.ensureWorktree({
|
|
1054
1107
|
repositoryPath: project.path,
|
|
1055
1108
|
container: this.#projectContainer(project.name),
|
|
1056
|
-
|
|
1109
|
+
taskSegment,
|
|
1057
1110
|
roleName: round.id,
|
|
1058
1111
|
baseRef: source.baseCommit
|
|
1059
1112
|
});
|
|
@@ -1155,11 +1208,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
1155
1208
|
});
|
|
1156
1209
|
}
|
|
1157
1210
|
catch (error) {
|
|
1158
|
-
await this.#discardUnadoptedEntries(task, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
|
|
1211
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
|
|
1159
1212
|
throw error;
|
|
1160
1213
|
}
|
|
1161
1214
|
}
|
|
1162
1215
|
async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
1216
|
+
const task = requireTask(this.store, taskId);
|
|
1163
1217
|
const round = this.store.getReviewRound(taskId, reviewRoundId);
|
|
1164
1218
|
if (round === null)
|
|
1165
1219
|
throw new Error(`ReviewRound not found: ${taskId}/${reviewRoundId}.`);
|
|
@@ -1170,7 +1224,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1170
1224
|
if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, workspace)) {
|
|
1171
1225
|
throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
|
|
1172
1226
|
}
|
|
1173
|
-
return this.#inspectEntries(
|
|
1227
|
+
return this.#inspectEntries(this.#taskSegment(task), round.id, workspace.entries);
|
|
1174
1228
|
}
|
|
1175
1229
|
async snapshotReviewRoundResult(taskId, reviewRoundId) {
|
|
1176
1230
|
const round = this.store.getReviewRound(taskId, reviewRoundId);
|
|
@@ -1241,13 +1295,13 @@ export class FileTaskWorkspacePreparer {
|
|
|
1241
1295
|
return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
|
|
1242
1296
|
}
|
|
1243
1297
|
async inspectExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
|
|
1244
|
-
requireTask(this.store, taskId);
|
|
1298
|
+
const task = requireTask(this.store, taskId);
|
|
1245
1299
|
const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
|
|
1246
1300
|
&& owner.executionGroupId === executionGroupId
|
|
1247
1301
|
&& owner.executionLaneId === executionLaneId));
|
|
1248
1302
|
if (workspace === undefined)
|
|
1249
1303
|
return "missing";
|
|
1250
|
-
return this.#inspectEntries(
|
|
1304
|
+
return this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
|
|
1251
1305
|
}
|
|
1252
1306
|
async cleanupReviewRoundWorkspace(taskId, reviewRoundId) {
|
|
1253
1307
|
const task = requireTask(this.store, taskId);
|
|
@@ -1271,7 +1325,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1271
1325
|
for (const lane of round.executionGroup?.lanes ?? []) {
|
|
1272
1326
|
assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
|
|
1273
1327
|
}
|
|
1274
|
-
if (await this.#inspectEntries(task
|
|
1328
|
+
if (await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries) === "dirty")
|
|
1275
1329
|
return "dirty";
|
|
1276
1330
|
let removed = false;
|
|
1277
1331
|
for (const entry of workspace.entries) {
|
|
@@ -1279,7 +1333,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1279
1333
|
const result = await this.git.removeWorktree({
|
|
1280
1334
|
repositoryPath: project.path,
|
|
1281
1335
|
container: this.#projectContainer(project.name),
|
|
1282
|
-
|
|
1336
|
+
taskSegment: this.#taskSegment(task),
|
|
1283
1337
|
roleName: managedWorktreeName(workspace.owner),
|
|
1284
1338
|
deleteBranch: true
|
|
1285
1339
|
});
|
|
@@ -1336,7 +1390,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1336
1390
|
assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
|
|
1337
1391
|
}
|
|
1338
1392
|
const writable = workspace.entries.filter(({ access }) => access === "write");
|
|
1339
|
-
if (await this.#inspectEntries(task
|
|
1393
|
+
if (await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), writable) === "dirty")
|
|
1340
1394
|
return "dirty";
|
|
1341
1395
|
let removed = false;
|
|
1342
1396
|
for (const entry of writable) {
|
|
@@ -1344,7 +1398,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1344
1398
|
const result = await this.git.removeWorktree({
|
|
1345
1399
|
repositoryPath: project.path,
|
|
1346
1400
|
container: this.#projectContainer(project.name),
|
|
1347
|
-
|
|
1401
|
+
taskSegment: this.#taskSegment(task),
|
|
1348
1402
|
roleName: managedWorktreeName(workspace.owner),
|
|
1349
1403
|
deleteBranch: true
|
|
1350
1404
|
});
|
|
@@ -1375,7 +1429,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1375
1429
|
if (main.owner.type !== "task") {
|
|
1376
1430
|
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
1377
1431
|
}
|
|
1378
|
-
return this.#inspectEntries(task
|
|
1432
|
+
return this.#inspectEntries(this.#taskSegment(task), MAIN_WORKTREE, main.entries);
|
|
1379
1433
|
}
|
|
1380
1434
|
async cleanupTaskForArchive(taskId) {
|
|
1381
1435
|
const task = requireTask(this.store, taskId);
|
|
@@ -1393,7 +1447,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1393
1447
|
const main = this.store.getTaskWorkspace(task.id);
|
|
1394
1448
|
if (main !== null) {
|
|
1395
1449
|
assertTaskArchiveState(requireTask(this.store, task.id), task);
|
|
1396
|
-
if (await this.#inspectEntries(task
|
|
1450
|
+
if (await this.#inspectEntries(this.#taskSegment(task), MAIN_WORKTREE, main.entries) === "dirty") {
|
|
1397
1451
|
return {
|
|
1398
1452
|
taskId,
|
|
1399
1453
|
status: "retained-dirty",
|
|
@@ -1406,7 +1460,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1406
1460
|
const result = await this.git.removeWorktree({
|
|
1407
1461
|
repositoryPath: project.path,
|
|
1408
1462
|
container: this.#projectContainer(project.name),
|
|
1409
|
-
|
|
1463
|
+
taskSegment: this.#taskSegment(task),
|
|
1410
1464
|
roleName: MAIN_WORKTREE
|
|
1411
1465
|
});
|
|
1412
1466
|
if (result === "dirty") {
|
|
@@ -1421,7 +1475,203 @@ export class FileTaskWorkspacePreparer {
|
|
|
1421
1475
|
this.#clearTaskWorkspace(task, this.#fallbackWorkspace());
|
|
1422
1476
|
return { taskId, status: "removed" };
|
|
1423
1477
|
}
|
|
1424
|
-
|
|
1478
|
+
/**
|
|
1479
|
+
* Rebuild an eligible legacy Task's managed Git workspace under a fresh
|
|
1480
|
+
* workspace identity. Legacy refs are archived (never deleted outright),
|
|
1481
|
+
* the new main worktrees start from verified remote SHAs, and the Task
|
|
1482
|
+
* record switches only after every Git side effect succeeded, so a crash
|
|
1483
|
+
* at any point leaves the old layout usable and the command resumable.
|
|
1484
|
+
*
|
|
1485
|
+
* A Task that already carries an identity takes the resume path: only the
|
|
1486
|
+
* pending legacy archive and old-worktree removal run.
|
|
1487
|
+
*/
|
|
1488
|
+
async rebuildTaskWorkspace(taskId) {
|
|
1489
|
+
const task = requireTask(this.store, taskId);
|
|
1490
|
+
if (!["draft", "active"].includes(task.status)) {
|
|
1491
|
+
throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
|
|
1492
|
+
}
|
|
1493
|
+
if (task.workspaceIdentity !== undefined) {
|
|
1494
|
+
const current = this.store.getTaskWorkspace(task.id);
|
|
1495
|
+
if (current !== null && current.owner.type === "task") {
|
|
1496
|
+
await ensureWorkspaceView(current.root, current.entries);
|
|
1497
|
+
}
|
|
1498
|
+
// Remove the legacy worktrees before archiving their refs: a worktree
|
|
1499
|
+
// whose checked-out branch was just deleted reports an unborn-branch
|
|
1500
|
+
// ("dirty") status and can no longer be removed cleanly.
|
|
1501
|
+
await this.#removeLegacyWorktrees(task, this.store.listProjects().map((project) => project.id));
|
|
1502
|
+
const archived = await this.#archiveLegacyRefs(task);
|
|
1503
|
+
return { task: requireTask(this.store, task.id), archived, resumed: true };
|
|
1504
|
+
}
|
|
1505
|
+
assertTaskHasNoEvidence(this.store, task.id);
|
|
1506
|
+
const existing = this.store.getTaskWorkspace(task.id);
|
|
1507
|
+
if (existing !== null && existing.owner.type !== "task") {
|
|
1508
|
+
throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
|
|
1509
|
+
}
|
|
1510
|
+
if (existing !== null
|
|
1511
|
+
&& await this.#inspectEntries(task.id, MAIN_WORKTREE, existing.entries) === "dirty") {
|
|
1512
|
+
throw new Error(`Task workspace is dirty and blocks the rebuild: ${task.id}.`);
|
|
1513
|
+
}
|
|
1514
|
+
// Resolve verified remote SHAs before any Git side effect, mirroring the
|
|
1515
|
+
// first-prepare pinning: a remote default Project is fetched and its
|
|
1516
|
+
// exact advertised SHA is pinned; a local or explicit ref is validated.
|
|
1517
|
+
const defaultProjects = remoteDefaultProjects(this.store, task.id);
|
|
1518
|
+
const pins = new Map();
|
|
1519
|
+
for (const binding of task.projectBindings) {
|
|
1520
|
+
const project = requireProject(this.store, binding.projectId);
|
|
1521
|
+
const useRemoteDefault = defaultProjects.has(project.id)
|
|
1522
|
+
&& project.remoteUrl !== undefined
|
|
1523
|
+
&& !looksLikeCommit(binding.baseRef);
|
|
1524
|
+
if (useRemoteDefault) {
|
|
1525
|
+
const resolver = this.git.resolveRemoteBaseline;
|
|
1526
|
+
if (typeof resolver !== "function") {
|
|
1527
|
+
throw new Error(`Git workspace cannot resolve the remote baseline for Project: ${project.id}.`);
|
|
1528
|
+
}
|
|
1529
|
+
const remote = await resolver.call(this.git, {
|
|
1530
|
+
repositoryPath: project.path,
|
|
1531
|
+
remoteUrl: project.remoteUrl,
|
|
1532
|
+
// The binding captured the configured development ref at Task
|
|
1533
|
+
// creation; use that snapshot even if the Project catalog changed.
|
|
1534
|
+
developmentRef: binding.baseRef
|
|
1535
|
+
});
|
|
1536
|
+
pins.set(project.id, remote.commit);
|
|
1537
|
+
}
|
|
1538
|
+
else {
|
|
1539
|
+
await this.git.inspect(project.path, binding.baseRef);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const identity = await this.#mintTaskWorkspaceIdentity(task);
|
|
1543
|
+
const taskSegment = taskWorkspaceRefSegmentFromIdentity(identity);
|
|
1544
|
+
const root = this.#taskWorkspaceRoot(task.id);
|
|
1545
|
+
const prepared = [];
|
|
1546
|
+
try {
|
|
1547
|
+
for (const binding of task.projectBindings) {
|
|
1548
|
+
const project = requireProject(this.store, binding.projectId);
|
|
1549
|
+
const baseRef = pins.get(project.id) ?? binding.baseRef;
|
|
1550
|
+
const physical = await this.git.ensureWorktree({
|
|
1551
|
+
repositoryPath: project.path,
|
|
1552
|
+
container: this.#projectContainer(project.name),
|
|
1553
|
+
taskSegment,
|
|
1554
|
+
roleName: MAIN_WORKTREE,
|
|
1555
|
+
baseRef
|
|
1556
|
+
});
|
|
1557
|
+
if (pins.has(project.id) && physical.baseCommit !== pins.get(project.id)) {
|
|
1558
|
+
throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
|
|
1559
|
+
}
|
|
1560
|
+
prepared.push({
|
|
1561
|
+
project,
|
|
1562
|
+
entry: {
|
|
1563
|
+
projectId: project.id,
|
|
1564
|
+
directory: binding.directory,
|
|
1565
|
+
access: "write",
|
|
1566
|
+
path: physical.path,
|
|
1567
|
+
branch: physical.branch,
|
|
1568
|
+
baseRef,
|
|
1569
|
+
baseCommit: physical.baseCommit
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
1574
|
+
const workspace = createManagedWorkspace({
|
|
1575
|
+
owner: { type: "task", taskId: task.id },
|
|
1576
|
+
root,
|
|
1577
|
+
entries: prepared.map(({ entry }) => entry)
|
|
1578
|
+
}, this.now());
|
|
1579
|
+
// Switch the durable record only now that every new ref/worktree exists.
|
|
1580
|
+
this.store.transaction((tx) => {
|
|
1581
|
+
const latest = requireTask(tx, task.id);
|
|
1582
|
+
if (!["draft", "active"].includes(latest.status)
|
|
1583
|
+
|| latest.workspaceIdentity !== undefined
|
|
1584
|
+
|| !isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
|
|
1585
|
+
throw new Error(`Task changed while rebuilding its workspace: ${task.id}.`);
|
|
1586
|
+
}
|
|
1587
|
+
assertTaskHasNoEvidence(tx, task.id);
|
|
1588
|
+
const current = tx.getTaskWorkspace(task.id);
|
|
1589
|
+
if (current === null
|
|
1590
|
+
? existing !== null
|
|
1591
|
+
: existing === null || !sameManagedWorkspace(current, existing)) {
|
|
1592
|
+
throw new Error(`Task workspace changed while rebuilding: ${task.id}.`);
|
|
1593
|
+
}
|
|
1594
|
+
const pinnedBindings = latest.projectBindings.map((binding) => {
|
|
1595
|
+
const pinned = pins.get(binding.projectId);
|
|
1596
|
+
return pinned !== undefined ? { ...binding, baseRef: pinned } : binding;
|
|
1597
|
+
});
|
|
1598
|
+
const timestamp = this.now();
|
|
1599
|
+
let persistedTask = isDeepStrictEqual(pinnedBindings, latest.projectBindings)
|
|
1600
|
+
? latest
|
|
1601
|
+
: { ...latest, projectBindings: pinnedBindings, updatedAt: timestamp.toISOString() };
|
|
1602
|
+
persistedTask = bindTaskWorkspaceIdentity(persistedTask, identity, timestamp);
|
|
1603
|
+
if (persistedTask.cwd !== root) {
|
|
1604
|
+
persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
|
|
1605
|
+
}
|
|
1606
|
+
if (!isDeepStrictEqual(persistedTask, latest)) {
|
|
1607
|
+
tx.saveTask(persistedTask);
|
|
1608
|
+
}
|
|
1609
|
+
if (existing !== null)
|
|
1610
|
+
tx.removeManagedWorkspace(existing.owner);
|
|
1611
|
+
tx.saveManagedWorkspace(workspace);
|
|
1612
|
+
});
|
|
1613
|
+
// Record switched: retire the legacy layout. The worktrees leave first
|
|
1614
|
+
// (their branches are retained), then the refs are archived and
|
|
1615
|
+
// deleted; each step is resumable.
|
|
1616
|
+
await this.#removeLegacyWorktrees(task, existing === null
|
|
1617
|
+
? this.store.listProjects().map((project) => project.id)
|
|
1618
|
+
: existing.entries.map(({ projectId }) => projectId));
|
|
1619
|
+
const archived = await this.#archiveLegacyRefs(task, existing === null ? undefined : [...new Set(existing.entries.map(({ projectId }) => projectId))]);
|
|
1620
|
+
return { task: requireTask(this.store, task.id), archived, resumed: false };
|
|
1621
|
+
}
|
|
1622
|
+
catch (error) {
|
|
1623
|
+
await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
|
|
1624
|
+
throw error;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* List legacy (pre-identity) Task refs across the Home's Project
|
|
1629
|
+
* repositories, optionally restricted to one Task. Identity-bearing
|
|
1630
|
+
* branches (`yui/task-N-<8hex>/...`) are never legacy.
|
|
1631
|
+
*/
|
|
1632
|
+
async listLegacyTaskRefs(taskId) {
|
|
1633
|
+
const found = [];
|
|
1634
|
+
for (const project of this.store.listProjects()) {
|
|
1635
|
+
const refs = await this.git.listRefs(project.path, "refs/heads/yui/");
|
|
1636
|
+
for (const ref of refs) {
|
|
1637
|
+
if (!isLegacyTaskRef(ref))
|
|
1638
|
+
continue;
|
|
1639
|
+
const ownerTaskId = ref.slice("refs/heads/yui/".length).split("/")[0];
|
|
1640
|
+
if (taskId !== undefined && ownerTaskId !== taskId)
|
|
1641
|
+
continue;
|
|
1642
|
+
found.push({ projectId: project.id, taskId: ownerTaskId, ref });
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return found;
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Archive legacy Task refs into the Home-scoped archive namespace
|
|
1649
|
+
* (`refs/yui/archive/<homeId>/heads/...`). Refs owned by a draft/active
|
|
1650
|
+
* Task are refused: their worktrees may still be live. Terminal and
|
|
1651
|
+
* unknown-owner refs are archived; an already-archived or missing ref is
|
|
1652
|
+
* simply absent on retry.
|
|
1653
|
+
*/
|
|
1654
|
+
async archiveLegacyTaskRefs(taskId) {
|
|
1655
|
+
const home = this.store.getHomeIdentity();
|
|
1656
|
+
const refused = [];
|
|
1657
|
+
const archived = [];
|
|
1658
|
+
for (const entry of await this.listLegacyTaskRefs(taskId)) {
|
|
1659
|
+
const owner = this.store.getTask(entry.taskId);
|
|
1660
|
+
if (owner !== null && ["draft", "active"].includes(owner.status)) {
|
|
1661
|
+
refused.push(`${entry.projectId}:${entry.ref}`);
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1665
|
+
await this.git.archiveRef({
|
|
1666
|
+
repositoryPath: project.path,
|
|
1667
|
+
sourceRef: entry.ref,
|
|
1668
|
+
archiveRef: taskArchiveRef(home.homeId, entry.ref)
|
|
1669
|
+
});
|
|
1670
|
+
archived.push(`${entry.projectId}:${entry.ref}`);
|
|
1671
|
+
}
|
|
1672
|
+
return { archived, refused };
|
|
1673
|
+
}
|
|
1674
|
+
async #inspectEntries(taskSegment, roleName, entries) {
|
|
1425
1675
|
if (entries.length === 0)
|
|
1426
1676
|
return "missing";
|
|
1427
1677
|
let found = false;
|
|
@@ -1430,7 +1680,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1430
1680
|
const state = await this.git.inspectWorktree({
|
|
1431
1681
|
repositoryPath: project.path,
|
|
1432
1682
|
container: this.#projectContainer(project.name),
|
|
1433
|
-
|
|
1683
|
+
taskSegment,
|
|
1434
1684
|
roleName
|
|
1435
1685
|
});
|
|
1436
1686
|
if (state === "dirty")
|
|
@@ -1463,6 +1713,15 @@ export class FileTaskWorkspacePreparer {
|
|
|
1463
1713
|
#projectContainer(projectName) {
|
|
1464
1714
|
return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
|
|
1465
1715
|
}
|
|
1716
|
+
/**
|
|
1717
|
+
* The Task workspace ref segment for Git worktree derivation: the
|
|
1718
|
+
* token-bearing segment for a Task with a persisted identity, its bare id
|
|
1719
|
+
* for a pre-identity record. Every managed ref/path for one Task resolves
|
|
1720
|
+
* through this single helper.
|
|
1721
|
+
*/
|
|
1722
|
+
#taskSegment(task) {
|
|
1723
|
+
return taskWorkspaceRefSegment(task);
|
|
1724
|
+
}
|
|
1466
1725
|
#taskWorkspaceRoot(taskId) {
|
|
1467
1726
|
return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "main");
|
|
1468
1727
|
}
|
|
@@ -1478,13 +1737,13 @@ export class FileTaskWorkspacePreparer {
|
|
|
1478
1737
|
#fallbackWorkspace() {
|
|
1479
1738
|
return this.store.getConfig().defaultWorkspace ?? process.cwd();
|
|
1480
1739
|
}
|
|
1481
|
-
async #discardUnadoptedEntries(task, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
|
|
1740
|
+
async #discardUnadoptedEntries(task, taskSegment, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
|
|
1482
1741
|
.flatMap((workspace) => workspace.entries.map(({ path }) => path)))) {
|
|
1483
1742
|
for (const { project, entry } of prepared.filter(({ entry }) => entry.access === "write" && !adoptedPaths.has(entry.path))) {
|
|
1484
1743
|
const removal = await this.git.removeWorktree({
|
|
1485
1744
|
repositoryPath: project.path,
|
|
1486
1745
|
container: this.#projectContainer(project.name),
|
|
1487
|
-
|
|
1746
|
+
taskSegment,
|
|
1488
1747
|
roleName,
|
|
1489
1748
|
...(deleteBranch ? { deleteBranch: true } : {})
|
|
1490
1749
|
});
|
|
@@ -1493,6 +1752,57 @@ export class FileTaskWorkspacePreparer {
|
|
|
1493
1752
|
}
|
|
1494
1753
|
}
|
|
1495
1754
|
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Archive one Task's legacy refs (`refs/heads/yui/<taskId>/...`) into the
|
|
1757
|
+
* Home-scoped archive namespace across the given Project repositories. When
|
|
1758
|
+
* no Project list is given, every Project in the Home is scanned, so the
|
|
1759
|
+
* resume path also finds refs in Projects that left the Task binding.
|
|
1760
|
+
*/
|
|
1761
|
+
async #archiveLegacyRefs(task, projectIds) {
|
|
1762
|
+
const home = this.store.getHomeIdentity();
|
|
1763
|
+
const projects = projectIds === undefined
|
|
1764
|
+
? this.store.listProjects()
|
|
1765
|
+
: projectIds.map((id) => requireProject(this.store, id));
|
|
1766
|
+
const seen = new Set();
|
|
1767
|
+
const archived = [];
|
|
1768
|
+
for (const project of projects) {
|
|
1769
|
+
const refs = await this.git.listRefs(project.path, `refs/heads/yui/${task.id}/`);
|
|
1770
|
+
for (const ref of refs) {
|
|
1771
|
+
const ownerTaskId = ref.slice("refs/heads/yui/".length).split("/")[0];
|
|
1772
|
+
if (ownerTaskId !== task.id || !isLegacyTaskRef(ref))
|
|
1773
|
+
continue;
|
|
1774
|
+
const key = `${project.id}:${ref}`;
|
|
1775
|
+
if (seen.has(key))
|
|
1776
|
+
continue;
|
|
1777
|
+
seen.add(key);
|
|
1778
|
+
await this.git.archiveRef({
|
|
1779
|
+
repositoryPath: project.path,
|
|
1780
|
+
sourceRef: ref,
|
|
1781
|
+
archiveRef: taskArchiveRef(home.homeId, ref)
|
|
1782
|
+
});
|
|
1783
|
+
archived.push(key);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return archived;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Remove the legacy worktrees of a Task (the bare `<taskId>/main` layout).
|
|
1790
|
+
* A missing worktree is expected on retry and ignored; a dirty one blocks.
|
|
1791
|
+
*/
|
|
1792
|
+
async #removeLegacyWorktrees(task, projectIds) {
|
|
1793
|
+
for (const projectId of projectIds) {
|
|
1794
|
+
const project = requireProject(this.store, projectId);
|
|
1795
|
+
const removal = await this.git.removeWorktree({
|
|
1796
|
+
repositoryPath: project.path,
|
|
1797
|
+
container: this.#projectContainer(project.name),
|
|
1798
|
+
taskSegment: task.id,
|
|
1799
|
+
roleName: MAIN_WORKTREE
|
|
1800
|
+
});
|
|
1801
|
+
if (removal === "dirty") {
|
|
1802
|
+
throw new Error(`Legacy Task worktree is dirty and blocks the rebuild: ${task.id}/${project.id}.`);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1496
1806
|
#recordWorkspaceRemoval(task, workspace, fallback, workItem) {
|
|
1497
1807
|
this.store.transaction((tx) => {
|
|
1498
1808
|
if (workspace.owner.type === "task") {
|
|
@@ -1555,6 +1865,19 @@ function assertTaskArchiveState(current, expected) {
|
|
|
1555
1865
|
throw new WorkspaceCleanupBlockedError("task-changed", `task:${expected.id}`, true, `Task changed during archive cleanup: ${expected.id}.`);
|
|
1556
1866
|
}
|
|
1557
1867
|
}
|
|
1868
|
+
/**
|
|
1869
|
+
* A controlled rebuild is only safe for a Task that owns no delivery
|
|
1870
|
+
* evidence: a Run, WorkItem, ChangeSet, or Integration would pin the old
|
|
1871
|
+
* refs and worktrees as historical proof.
|
|
1872
|
+
*/
|
|
1873
|
+
function assertTaskHasNoEvidence(store, taskId) {
|
|
1874
|
+
if (store.listAgentRuns(taskId).length > 0
|
|
1875
|
+
|| store.listWorkItems(taskId).length > 0
|
|
1876
|
+
|| store.listChangeSets(taskId).length > 0
|
|
1877
|
+
|| store.listIntegrationAttempts(taskId).length > 0) {
|
|
1878
|
+
throw new Error(`Task has Run, Work item, Change set, or Integration evidence and cannot be rebuilt in place: ${taskId}.`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1558
1881
|
function requireProject(store, projectId) {
|
|
1559
1882
|
const project = store.getProject(projectId);
|
|
1560
1883
|
if (project === null)
|