@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
|
@@ -226,6 +226,36 @@ const taskChildren = [
|
|
|
226
226
|
options: ["--integrated", "--abandon"]
|
|
227
227
|
},
|
|
228
228
|
{ name: "reconcile", summary: "Run one immediate Controller reconciliation.", usage: "yui task reconcile <id>" },
|
|
229
|
+
{
|
|
230
|
+
name: "rebuild",
|
|
231
|
+
summary: "Rebuild a legacy Task workspace under its canonical identity.",
|
|
232
|
+
usage: "yui task rebuild <task>",
|
|
233
|
+
options: []
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "history",
|
|
237
|
+
summary: "Inspect and archive legacy Task refs in the Home repository.",
|
|
238
|
+
sections: [{ id: "manage", title: "Commands", entries: ["list", "archive"] }],
|
|
239
|
+
children: [
|
|
240
|
+
{
|
|
241
|
+
name: "list",
|
|
242
|
+
summary: "List legacy Task refs and their live owners.",
|
|
243
|
+
usage: "yui task history list [--task <task>]"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "archive",
|
|
247
|
+
summary: "Archive legacy Task refs without a live owner.",
|
|
248
|
+
usage: "yui task history archive [<task>] [--force]",
|
|
249
|
+
options: ["--force"]
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "replace",
|
|
255
|
+
summary: "Create a draft successor for a terminal Task.",
|
|
256
|
+
usage: "yui task replace <task> [--title <text>]",
|
|
257
|
+
options: ["--title"]
|
|
258
|
+
},
|
|
229
259
|
{
|
|
230
260
|
name: "message",
|
|
231
261
|
summary: "Manage durable Task messages.",
|
|
@@ -860,7 +890,7 @@ export const ROOT_COMMAND = buildNode({
|
|
|
860
890
|
name: "task",
|
|
861
891
|
summary: "Manage Tasks, WorkItems, Agent Runs, and integration.",
|
|
862
892
|
sections: [
|
|
863
|
-
{ id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "update", "activate", "complete", "reopen", "retire", "list", "show", "context", "archive", "reconcile"] },
|
|
893
|
+
{ id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "update", "activate", "complete", "reopen", "retire", "list", "show", "context", "archive", "rebuild", "history", "replace", "reconcile"] },
|
|
864
894
|
{ id: "collaboration", title: "Collaboration", entries: ["message", "input", "work", "run", "review", "integration", "role", "enter"] },
|
|
865
895
|
{ id: "knowledge", title: "Task Knowledge", entries: ["brief", "decision", "milestone", "event"] }
|
|
866
896
|
],
|
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { runProfileCommand } from "./commands/profileCommands.js";
|
|
|
28
28
|
import { dispatchPreparedReviewRound, failPendingReviewRound, RESUMED_PENDING_FINAL_REVIEW, TERMINALIZED_LEADER_BEFORE_FINAL_REVIEW, TaskFinalReviewDispatchDriftError, preserveReviewRoundWorkspace, parseTaskCompletionRequest, preflightTaskCompletion, runTaskCommand, normalizedExecutionLanePlan, validateTaskArchiveRequest } from "./commands/taskCommands.js";
|
|
29
29
|
import { taskActor } from "./commands/taskActor.js";
|
|
30
30
|
import { runTaskIntegrationCommand } from "./commands/taskIntegrationCommands.js";
|
|
31
|
+
import { runTaskWorkspaceCommand } from "./commands/taskWorkspaceCommands.js";
|
|
31
32
|
import { reconcileTaskRemoteBaselines } from "./commands/taskCompletionGate.js";
|
|
32
33
|
import { FileCompletionManager, resolveCliIdentity } from "./completion/fileCompletionManager.js";
|
|
33
34
|
import { assertFileTaskControllerStorageCompatible, ensureFileTaskController, FileTaskWorkflowRuntime, refreshRunningFileTaskControllerConfiguration, refreshRunningFileTaskControllerEnvironment, restartFileTaskController, stopFileTaskController } from "./controller/clientRuntime.js";
|
|
@@ -446,6 +447,13 @@ export async function main() {
|
|
|
446
447
|
emit(result.output, false, result.data);
|
|
447
448
|
return;
|
|
448
449
|
}
|
|
450
|
+
if (resolved[1] === "rebuild"
|
|
451
|
+
|| resolved[1] === "history"
|
|
452
|
+
|| resolved[1] === "replace") {
|
|
453
|
+
const result = await runTaskWorkspaceCommand(resolved.slice(1), store, workspacePreparer);
|
|
454
|
+
emit(result.output, false, result.data);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
449
457
|
const enteringTask = (resolved[1] === "enter")
|
|
450
458
|
|| (resolved[1] === "role" && resolved[2] === "enter");
|
|
451
459
|
if (enteringTask) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { readdir, rm } from "node:fs/promises";
|
|
2
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
4
|
import { usageError } from "../errors/cliError.js";
|
|
4
5
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
5
6
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
6
|
-
import { addProjectKnowledge, createProject, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProjectName } from "../repository/project.js";
|
|
7
|
+
import { addProjectKnowledge, createProject, managedProjectPath, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
|
|
7
8
|
export async function runProjectCommand(args, store, options = {}) {
|
|
8
9
|
const [command, ...rest] = args;
|
|
9
10
|
if (command === "add" || command === "clone") {
|
|
@@ -24,6 +25,15 @@ export async function runProjectCommand(args, store, options = {}) {
|
|
|
24
25
|
data: refreshed
|
|
25
26
|
};
|
|
26
27
|
}
|
|
28
|
+
if (command === "migrate") {
|
|
29
|
+
const migrated = await migrateProject(rest, store, options);
|
|
30
|
+
return {
|
|
31
|
+
output: migrated.preflight
|
|
32
|
+
? `Project ${migrated.project.id} can be migrated to a Home-managed repository at ${migrated.path}\n`
|
|
33
|
+
: `Migrated project ${migrated.project.id} to a Home-managed repository at ${migrated.path}\n`,
|
|
34
|
+
data: migrated
|
|
35
|
+
};
|
|
36
|
+
}
|
|
27
37
|
if (command === "update") {
|
|
28
38
|
const project = await updateProject(rest, store, options);
|
|
29
39
|
return {
|
|
@@ -123,24 +133,37 @@ function renderDiscoveredProjects(projects) {
|
|
|
123
133
|
]), defaultTableWidth())}\n`;
|
|
124
134
|
}
|
|
125
135
|
async function cloneProject(args, store, options) {
|
|
126
|
-
const usage = "Project clone usage: yui project clone <name> <remote> [--alias <name> ...] [--stable <ref>] [--development <ref>].";
|
|
136
|
+
const usage = "Project clone usage: yui project clone <name> <remote> [--alias <name> ...] [--stable <ref>] [--development <ref>] [--external].";
|
|
127
137
|
const parsed = parseCloneArguments(args, usage);
|
|
128
|
-
const workspace = store.getConfig().defaultWorkspace;
|
|
129
|
-
if (workspace === undefined)
|
|
130
|
-
throw usageError("No default workspace is configured; run yui setup.");
|
|
131
138
|
const git = options.git ?? new NodeGitWorkspace();
|
|
132
139
|
const name = validateProjectName(parsed.name);
|
|
133
|
-
const workspaceRoot = resolve(workspace);
|
|
134
|
-
const destination = resolve(workspaceRoot, name);
|
|
135
|
-
if (dirname(destination) !== workspaceRoot) {
|
|
136
|
-
throw usageError("Project clone destination must be directly inside the configured workspace.");
|
|
137
|
-
}
|
|
138
|
-
assertOutsideManagedWorktrees(destination, workspace);
|
|
139
140
|
const stableRef = parsed.stable ?? "HEAD";
|
|
140
141
|
const developmentRef = parsed.development ?? stableRef;
|
|
141
142
|
const projectId = store.nextProjectId();
|
|
142
143
|
const now = (options.now ?? (() => new Date()))();
|
|
143
|
-
|
|
144
|
+
// A remote-URL binding is Home-managed by default: its canonical repository
|
|
145
|
+
// lives below the persistent Home, so the runtime never depends on a
|
|
146
|
+
// user-controlled checkout path. --external is an explicit opt-in to the
|
|
147
|
+
// legacy clone-inside-the-workspace mode.
|
|
148
|
+
let destination;
|
|
149
|
+
let ownership;
|
|
150
|
+
if (parsed.external) {
|
|
151
|
+
const workspace = store.getConfig().defaultWorkspace;
|
|
152
|
+
if (workspace === undefined)
|
|
153
|
+
throw usageError("No default workspace is configured; run yui setup.");
|
|
154
|
+
const workspaceRoot = resolve(workspace);
|
|
155
|
+
destination = resolve(workspaceRoot, name);
|
|
156
|
+
if (dirname(destination) !== workspaceRoot) {
|
|
157
|
+
throw usageError("Project clone destination must be directly inside the configured workspace.");
|
|
158
|
+
}
|
|
159
|
+
assertOutsideManagedWorktrees(destination, workspace);
|
|
160
|
+
ownership = "external";
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
destination = managedProjectPath(store.rootDirectory(), projectId);
|
|
164
|
+
ownership = "managed";
|
|
165
|
+
}
|
|
166
|
+
const candidate = createProject(projectId, name, destination, { stable: stableRef, development: developmentRef }, now, { aliases: parsed.aliases, remoteUrl: parsed.remote, ownership });
|
|
144
167
|
assertProjectAvailable(store, candidate);
|
|
145
168
|
let cloned = false;
|
|
146
169
|
try {
|
|
@@ -157,7 +180,16 @@ async function cloneProject(args, store, options) {
|
|
|
157
180
|
if (developmentRef !== stableRef) {
|
|
158
181
|
await git.ensureLocalBranch(destination, developmentRef);
|
|
159
182
|
}
|
|
160
|
-
|
|
183
|
+
// Verify both configured branches against the SHAs the remote advertises
|
|
184
|
+
// now. A network race or a ref that moved during the clone fails closed
|
|
185
|
+
// before any binding is persisted.
|
|
186
|
+
await assertRemoteBranchesVerified(git, destination, parsed.remote, [
|
|
187
|
+
{ ref: stableRef, localCommit: head.baseCommit },
|
|
188
|
+
...(developmentRef === stableRef
|
|
189
|
+
? []
|
|
190
|
+
: [{ ref: developmentRef, localCommit: (await git.inspect(destination, developmentRef)).baseCommit }])
|
|
191
|
+
]);
|
|
192
|
+
const project = createProject(projectId, name, head.root, { stable: stableRef, development: developmentRef }, now, { aliases: parsed.aliases, remoteUrl: parsed.remote, ownership });
|
|
161
193
|
assertProjectAvailable(store, project);
|
|
162
194
|
const created = store.createProjectIfAbsent(project);
|
|
163
195
|
if (created === null)
|
|
@@ -171,6 +203,135 @@ async function cloneProject(args, store, options) {
|
|
|
171
203
|
throw error;
|
|
172
204
|
}
|
|
173
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Migrate an external Project binding to a Home-managed repository. The
|
|
208
|
+
* remote is cloned and both configured branches are verified against the
|
|
209
|
+
* advertised remote SHAs before the catalog record switches; the old
|
|
210
|
+
* checkout is never touched and stays usable until the switch commits. A
|
|
211
|
+
* failed migration leaves no partial state: the unfinished managed clone is
|
|
212
|
+
* removed so the command can be retried cleanly.
|
|
213
|
+
*/
|
|
214
|
+
async function migrateProject(args, store, options) {
|
|
215
|
+
const usage = "Project migrate usage: yui project migrate <project> [--preflight].";
|
|
216
|
+
const parsed = parseMigrateArguments(args, usage);
|
|
217
|
+
const project = requireProject(store, parsed.project);
|
|
218
|
+
if (project.ownership === "managed") {
|
|
219
|
+
throw usageError(`Project is already Home-managed: ${project.id}.`);
|
|
220
|
+
}
|
|
221
|
+
if (project.remoteUrl === undefined) {
|
|
222
|
+
throw usageError(`Project migrate requires a remote URL: ${project.id}.`);
|
|
223
|
+
}
|
|
224
|
+
const git = options.git ?? new NodeGitWorkspace();
|
|
225
|
+
const destination = managedProjectPath(store.rootDirectory(), project.id);
|
|
226
|
+
// Preflight verifies the remote into a throwaway clone: it changes neither
|
|
227
|
+
// the catalog nor the persistent projects directory.
|
|
228
|
+
const verifyRoot = parsed.preflight
|
|
229
|
+
? join(store.rootDirectory(), "projects", `.preflight-${project.id}`)
|
|
230
|
+
: destination;
|
|
231
|
+
if (!parsed.preflight) {
|
|
232
|
+
// A crashed earlier attempt can leave an unreferenced managed clone
|
|
233
|
+
// behind. It is safe to remove only because no catalog record points at
|
|
234
|
+
// it; a registered path fails closed instead.
|
|
235
|
+
await removeUnreferencedClone(store, destination, project.id);
|
|
236
|
+
}
|
|
237
|
+
else if (existsSync(verifyRoot)) {
|
|
238
|
+
// A crashed preflight can only leave its own throwaway clone behind.
|
|
239
|
+
await rm(verifyRoot, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
let prepared = false;
|
|
242
|
+
try {
|
|
243
|
+
const head = await git.clone({
|
|
244
|
+
remoteUrl: project.remoteUrl,
|
|
245
|
+
destination: verifyRoot,
|
|
246
|
+
...(project.stableBranch === "HEAD" ? {} : { branch: project.stableBranch })
|
|
247
|
+
});
|
|
248
|
+
prepared = true;
|
|
249
|
+
const stable = project.stableBranch === "HEAD"
|
|
250
|
+
? head
|
|
251
|
+
: await git.inspect(verifyRoot, project.stableBranch);
|
|
252
|
+
if (head.baseCommit !== stable.baseCommit) {
|
|
253
|
+
throw new Error(`Project checkout is not on its stable ref: ${project.stableBranch}.`);
|
|
254
|
+
}
|
|
255
|
+
if (project.developmentBranch !== project.stableBranch) {
|
|
256
|
+
await git.ensureLocalBranch(verifyRoot, project.developmentBranch);
|
|
257
|
+
}
|
|
258
|
+
await assertRemoteBranchesVerified(git, verifyRoot, project.remoteUrl, [
|
|
259
|
+
{ ref: project.stableBranch, localCommit: head.baseCommit },
|
|
260
|
+
...(project.developmentBranch === project.stableBranch
|
|
261
|
+
? []
|
|
262
|
+
: [{
|
|
263
|
+
ref: project.developmentBranch,
|
|
264
|
+
localCommit: (await git.inspect(verifyRoot, project.developmentBranch)).baseCommit
|
|
265
|
+
}])
|
|
266
|
+
]);
|
|
267
|
+
if (parsed.preflight) {
|
|
268
|
+
return { project, path: destination, preflight: true };
|
|
269
|
+
}
|
|
270
|
+
const switched = store.transaction((tx) => {
|
|
271
|
+
const latest = requireProject(tx, project.id);
|
|
272
|
+
if (latest.ownership !== "external"
|
|
273
|
+
|| latest.path !== project.path
|
|
274
|
+
|| latest.remoteUrl !== project.remoteUrl) {
|
|
275
|
+
throw new Error(`Project changed while migrating: ${project.id}.`);
|
|
276
|
+
}
|
|
277
|
+
// The switch only changes ownership and path; every other field is
|
|
278
|
+
// frozen, and the catalog validator re-checks the whole record.
|
|
279
|
+
const next = validateProject({
|
|
280
|
+
...latest,
|
|
281
|
+
path: destination,
|
|
282
|
+
ownership: "managed",
|
|
283
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString()
|
|
284
|
+
});
|
|
285
|
+
assertProjectAvailable(tx, next, latest.id);
|
|
286
|
+
tx.saveProject(next);
|
|
287
|
+
return next;
|
|
288
|
+
});
|
|
289
|
+
return { project: switched, path: switched.path, preflight: false };
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (prepared && !parsed.preflight
|
|
293
|
+
&& !store.listProjects().some(({ path }) => path === destination)) {
|
|
294
|
+
await rm(destination, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
if (parsed.preflight) {
|
|
300
|
+
await rm(verifyRoot, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Remove a managed clone left behind by a crashed migration attempt. Such a
|
|
306
|
+
* directory is unreferenced garbage; a path any catalog record points at is
|
|
307
|
+
* never deleted.
|
|
308
|
+
*/
|
|
309
|
+
async function removeUnreferencedClone(store, destination, migratingProjectId) {
|
|
310
|
+
if (!existsSync(destination))
|
|
311
|
+
return;
|
|
312
|
+
const registered = store.listProjects().find(({ path }) => path === destination);
|
|
313
|
+
if (registered !== undefined && registered.id !== migratingProjectId) {
|
|
314
|
+
throw new Error(`Managed Project path is already registered: ${destination}.`);
|
|
315
|
+
}
|
|
316
|
+
await rm(destination, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Verify that the local SHAs of the configured branches exactly match the
|
|
320
|
+
* commits the remote advertises right now. Any mismatch fails closed.
|
|
321
|
+
*/
|
|
322
|
+
async function assertRemoteBranchesVerified(git, repositoryPath, remoteUrl, branches) {
|
|
323
|
+
for (const branch of branches) {
|
|
324
|
+
const resolved = await git.resolveRemoteBaseline({
|
|
325
|
+
repositoryPath,
|
|
326
|
+
remoteUrl,
|
|
327
|
+
developmentRef: branch.ref
|
|
328
|
+
});
|
|
329
|
+
if (resolved.commit.toLowerCase() !== branch.localCommit.toLowerCase()) {
|
|
330
|
+
throw new Error(`Project remote branch ${resolved.branch} moved while it was fetched: `
|
|
331
|
+
+ `advertised ${resolved.commit}, local ${branch.localCommit}.`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
174
335
|
async function addProject(args, store, options) {
|
|
175
336
|
const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
|
|
176
337
|
const parsed = parseAddArguments(args, usage);
|
|
@@ -246,6 +407,7 @@ function renderAddedProject(created) {
|
|
|
246
407
|
return [
|
|
247
408
|
`Added project ${created.id}`,
|
|
248
409
|
`Name: ${created.name}`,
|
|
410
|
+
`Ownership: ${created.ownership}`,
|
|
249
411
|
`Path: ${created.path}`,
|
|
250
412
|
`Stable: ${created.stableBranch}`,
|
|
251
413
|
`Development: ${created.developmentBranch}`
|
|
@@ -286,11 +448,17 @@ function listProjects(args, store) {
|
|
|
286
448
|
return `${renderTable("Projects", [
|
|
287
449
|
{ header: "Project", minWidth: 8, maxWidth: 24 },
|
|
288
450
|
{ header: "Name", minWidth: 4, maxWidth: 28 },
|
|
451
|
+
{ header: "Ownership", minWidth: 9, maxWidth: 10 },
|
|
289
452
|
{ header: "Path", minWidth: 8, maxWidth: 64 },
|
|
290
453
|
{ header: "Stable", minWidth: 6, maxWidth: 24 },
|
|
291
454
|
{ header: "Development", minWidth: 11, maxWidth: 24 }
|
|
292
455
|
], projects.map((project) => [
|
|
293
|
-
project.id,
|
|
456
|
+
project.id,
|
|
457
|
+
project.name,
|
|
458
|
+
project.ownership,
|
|
459
|
+
project.path,
|
|
460
|
+
project.stableBranch,
|
|
461
|
+
project.developmentBranch
|
|
294
462
|
]), defaultTableWidth())}\n`;
|
|
295
463
|
}
|
|
296
464
|
function showProject(args, store) {
|
|
@@ -300,6 +468,7 @@ function showProject(args, store) {
|
|
|
300
468
|
return [
|
|
301
469
|
`Project: ${project.id}`,
|
|
302
470
|
`Name: ${project.name}`,
|
|
471
|
+
`Ownership: ${project.ownership}`,
|
|
303
472
|
`Aliases: ${project.aliases.length === 0 ? "-" : project.aliases.join(", ")}`,
|
|
304
473
|
`Path: ${project.path}`,
|
|
305
474
|
...(project.remoteUrl === undefined ? [] : [`Remote: ${project.remoteUrl}`]),
|
|
@@ -579,8 +748,13 @@ function parseCloneArguments(args, usage) {
|
|
|
579
748
|
const aliases = [];
|
|
580
749
|
let stable;
|
|
581
750
|
let development;
|
|
751
|
+
let external = false;
|
|
582
752
|
for (let index = 0; index < args.length; index += 1) {
|
|
583
753
|
const value = args[index];
|
|
754
|
+
if (value === "--external") {
|
|
755
|
+
external = true;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
584
758
|
if (["--alias", "--stable", "--development"].includes(value)) {
|
|
585
759
|
const next = args[index + 1];
|
|
586
760
|
if (next === undefined || next.startsWith("--")) {
|
|
@@ -613,9 +787,26 @@ function parseCloneArguments(args, usage) {
|
|
|
613
787
|
remote: requireText(positionals[1], "Project remote"),
|
|
614
788
|
aliases,
|
|
615
789
|
...(stable === undefined ? {} : { stable }),
|
|
616
|
-
...(development === undefined ? {} : { development })
|
|
790
|
+
...(development === undefined ? {} : { development }),
|
|
791
|
+
external
|
|
617
792
|
};
|
|
618
793
|
}
|
|
794
|
+
function parseMigrateArguments(args, usage) {
|
|
795
|
+
const positionals = [];
|
|
796
|
+
let preflight = false;
|
|
797
|
+
for (const value of args) {
|
|
798
|
+
if (value === "--preflight") {
|
|
799
|
+
preflight = true;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (value.startsWith("--"))
|
|
803
|
+
throw usageError(`Unknown option: ${value}. ${usage}`);
|
|
804
|
+
positionals.push(value);
|
|
805
|
+
}
|
|
806
|
+
if (positionals.length !== 1)
|
|
807
|
+
throw usageError(usage);
|
|
808
|
+
return { project: requireText(positionals[0], "Project reference"), preflight };
|
|
809
|
+
}
|
|
619
810
|
function parseSingleOption(args, option, usage) {
|
|
620
811
|
const positionals = [];
|
|
621
812
|
let found;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { usageError } from "../errors/cliError.js";
|
|
2
|
+
import { createMilestone } from "../milestone/milestone.js";
|
|
3
|
+
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
4
|
+
import { runTaskCommand } from "./taskCommands.js";
|
|
5
|
+
/**
|
|
6
|
+
* Task workspace lifecycle commands that sit outside the sync task command
|
|
7
|
+
* surface because they drive Git: controlled rebuild of a legacy Task
|
|
8
|
+
* workspace, legacy ref history inspection/archival, and the
|
|
9
|
+
* replacement-creating path for terminal Tasks.
|
|
10
|
+
*/
|
|
11
|
+
export async function runTaskWorkspaceCommand(args, store, preparer, options = {}) {
|
|
12
|
+
const [command, ...rest] = args;
|
|
13
|
+
if (command === "rebuild")
|
|
14
|
+
return rebuildTaskCommand(rest, preparer);
|
|
15
|
+
if (command === "history")
|
|
16
|
+
return historyCommand(rest, preparer);
|
|
17
|
+
if (command === "replace")
|
|
18
|
+
return replaceTaskCommand(rest, store, options);
|
|
19
|
+
throw usageError("Unknown task workspace command. Available: yui task rebuild <task>, "
|
|
20
|
+
+ "yui task history list|archive [task], yui task replace <task>.");
|
|
21
|
+
}
|
|
22
|
+
async function rebuildTaskCommand(args, preparer) {
|
|
23
|
+
const usage = "Task rebuild usage: yui task rebuild <task>.";
|
|
24
|
+
const taskId = args[0];
|
|
25
|
+
if (taskId === undefined || args.length !== 1)
|
|
26
|
+
throw usageError(usage);
|
|
27
|
+
const result = await preparer.rebuildTaskWorkspace(taskId);
|
|
28
|
+
const archived = result.archived.length === 0
|
|
29
|
+
? "no legacy refs"
|
|
30
|
+
: `${result.archived.length} legacy ref(s) archived`;
|
|
31
|
+
const resumed = result.resumed ? " (resumed pending cleanup)" : "";
|
|
32
|
+
return {
|
|
33
|
+
output: `Rebuilt Task workspace ${result.task.id} at ${result.task.cwd ?? "its configured workspace"}: `
|
|
34
|
+
+ `${archived}${resumed}.`,
|
|
35
|
+
data: {
|
|
36
|
+
taskId: result.task.id,
|
|
37
|
+
archived: result.archived,
|
|
38
|
+
resumed: result.resumed
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function historyCommand(args, preparer) {
|
|
43
|
+
const usage = "Task history usage: yui task history list|archive [task].";
|
|
44
|
+
const action = args[0];
|
|
45
|
+
if (action !== "list" && action !== "archive")
|
|
46
|
+
throw usageError(usage);
|
|
47
|
+
const taskId = args[1];
|
|
48
|
+
if (args.length > 2)
|
|
49
|
+
throw usageError(usage);
|
|
50
|
+
if (action === "list") {
|
|
51
|
+
const refs = await preparer.listLegacyTaskRefs(taskId);
|
|
52
|
+
if (refs.length === 0) {
|
|
53
|
+
return { output: "No legacy Task refs.", data: { refs: [] } };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
output: renderTable("Legacy Task refs", [
|
|
57
|
+
{ header: "Project", minWidth: 8, maxWidth: 24 },
|
|
58
|
+
{ header: "Task", minWidth: 8, maxWidth: 16 },
|
|
59
|
+
{ header: "Legacy ref", minWidth: 16, maxWidth: 64 }
|
|
60
|
+
], refs.map((entry) => [entry.projectId, entry.taskId, entry.ref]), defaultTableWidth()),
|
|
61
|
+
data: { refs }
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const result = await preparer.archiveLegacyTaskRefs(taskId);
|
|
65
|
+
const lines = [];
|
|
66
|
+
if (result.archived.length > 0) {
|
|
67
|
+
lines.push(`Archived ${result.archived.length} legacy ref(s):`);
|
|
68
|
+
lines.push(...result.archived.map((entry) => ` ${entry}`));
|
|
69
|
+
}
|
|
70
|
+
if (result.refused.length > 0) {
|
|
71
|
+
lines.push(`Refused ${result.refused.length} live ref(s) owned by an open Task:`);
|
|
72
|
+
lines.push(...result.refused.map((entry) => ` ${entry}`));
|
|
73
|
+
}
|
|
74
|
+
if (lines.length === 0)
|
|
75
|
+
lines.push("No legacy Task refs to archive.");
|
|
76
|
+
return {
|
|
77
|
+
output: lines.join("\n"),
|
|
78
|
+
data: { archived: result.archived, refused: result.refused }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A terminal (retired/completed/archived) Task is never rewritten in place.
|
|
83
|
+
* This creates a fresh draft Task bound to the same Projects at the same
|
|
84
|
+
* bases and records the relationship as a milestone on the replacement, so
|
|
85
|
+
* the original Task, its refs, and its evidence stay untouched.
|
|
86
|
+
*/
|
|
87
|
+
function replaceTaskCommand(args, store, options) {
|
|
88
|
+
const usage = "Task replace usage: yui task replace <task> [--title <text>].";
|
|
89
|
+
const oldId = args[0];
|
|
90
|
+
if (oldId === undefined)
|
|
91
|
+
throw usageError(usage);
|
|
92
|
+
const old = store.getTask(oldId);
|
|
93
|
+
if (old === null)
|
|
94
|
+
throw usageError(`Task not found: ${oldId}.`);
|
|
95
|
+
if (old.status === "draft" || old.status === "active") {
|
|
96
|
+
throw usageError(`Task is open and can be rebuilt instead of replaced: ${old.id}.`);
|
|
97
|
+
}
|
|
98
|
+
let title = `Replaces ${old.id}: ${old.title}`;
|
|
99
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
100
|
+
if (args[index] === "--title") {
|
|
101
|
+
const value = args[index + 1];
|
|
102
|
+
if (value === undefined || value.startsWith("--"))
|
|
103
|
+
throw usageError(usage);
|
|
104
|
+
title = value;
|
|
105
|
+
index += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
throw usageError(usage);
|
|
109
|
+
}
|
|
110
|
+
const createArgs = ["create", title];
|
|
111
|
+
for (const binding of old.projectBindings) {
|
|
112
|
+
createArgs.push("--project", binding.projectId);
|
|
113
|
+
createArgs.push("--base", `${binding.projectId}=${binding.baseRef}`);
|
|
114
|
+
}
|
|
115
|
+
if (old.requireIntegration)
|
|
116
|
+
createArgs.push("--require-integration");
|
|
117
|
+
const created = runTaskCommand(createArgs, store);
|
|
118
|
+
if (created.kind !== "output") {
|
|
119
|
+
throw new Error(`Task replacement could not be created for ${old.id}.`);
|
|
120
|
+
}
|
|
121
|
+
const data = created.data;
|
|
122
|
+
const replacement = data?.task;
|
|
123
|
+
if (replacement === undefined) {
|
|
124
|
+
throw new Error(`Task replacement result did not name the new Task for ${old.id}.`);
|
|
125
|
+
}
|
|
126
|
+
const now = options.now?.() ?? new Date();
|
|
127
|
+
const milestone = createMilestone(store.nextMilestoneId(replacement.id), replacement.id, `Replaces ${old.id}`, `Replacement Task for ${old.id} (${old.status}); the original Task, its refs, `
|
|
128
|
+
+ "and its evidence are preserved and were not rewritten.", now);
|
|
129
|
+
store.saveMilestone(replacement.id, milestone);
|
|
130
|
+
return {
|
|
131
|
+
output: `${created.output}\nRecorded replacement of ${old.id} on ${replacement.id} `
|
|
132
|
+
+ `(milestone ${milestone.id}).`,
|
|
133
|
+
data: { task: replacement, replaces: old.id, milestone: milestone.id }
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -7,6 +7,7 @@ import { selectEnvironment } from "../agent/launchEnvironment.js";
|
|
|
7
7
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
8
8
|
import { NodeGitWorkspace, RemoteBaselineConflictError } from "../repository/gitWorkspace.js";
|
|
9
9
|
import { resolveWorktreeRoot } from "../repository/taskWorkspacePreparer.js";
|
|
10
|
+
import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
|
|
10
11
|
import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
|
|
11
12
|
import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
12
13
|
import { requireLeaderDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
|
|
@@ -78,7 +79,7 @@ export class GitIntegrationService {
|
|
|
78
79
|
prepared = await this.git.ensureIntegrationWorktree({
|
|
79
80
|
repositoryPath: project.path,
|
|
80
81
|
container: join(this.worktreeRoot, project.name),
|
|
81
|
-
|
|
82
|
+
taskSegment: taskWorkspaceRefSegment(task),
|
|
82
83
|
integrationId: initial.id,
|
|
83
84
|
baseRef: initial.expectedHead
|
|
84
85
|
});
|
|
@@ -212,7 +213,7 @@ export class GitIntegrationService {
|
|
|
212
213
|
const result = await this.git.removeIntegrationWorktree({
|
|
213
214
|
repositoryPath: project.path,
|
|
214
215
|
container: join(this.worktreeRoot, project.name),
|
|
215
|
-
|
|
216
|
+
taskSegment: taskWorkspaceRefSegment(task),
|
|
216
217
|
integrationId: integration.id,
|
|
217
218
|
discardChanges: integration.status === "failed"
|
|
218
219
|
});
|
|
@@ -171,6 +171,13 @@ export class NodeGitWorkspace {
|
|
|
171
171
|
"-C", initial.root,
|
|
172
172
|
"rev-parse", "--verify", "--end-of-options", "FETCH_HEAD^{commit}"
|
|
173
173
|
])).toLowerCase();
|
|
174
|
+
// The advertised SHA must match what was fetched. A network race, a ref
|
|
175
|
+
// that moved mid-fetch, or any inconsistency fails closed: the stable
|
|
176
|
+
// checkout is never advanced to an unverified commit.
|
|
177
|
+
const advertisedCommit = await resolveRemoteBranchCommit(remote, stableBranch);
|
|
178
|
+
if (fetchedCommit !== advertisedCommit) {
|
|
179
|
+
throw new Error(`Project remote stable branch changed while it was fetched: ${stableBranch}.`);
|
|
180
|
+
}
|
|
174
181
|
await this.#assertRefreshCheckout(initial.root, stableBranch, initial.baseCommit);
|
|
175
182
|
if (fetchedCommit === initial.baseCommit) {
|
|
176
183
|
return {
|
|
@@ -300,6 +307,52 @@ export class NodeGitWorkspace {
|
|
|
300
307
|
baseCommit: baseCommit.toLowerCase()
|
|
301
308
|
};
|
|
302
309
|
}
|
|
310
|
+
async refExists(repositoryPath, ref) {
|
|
311
|
+
const root = (await this.inspect(repositoryPath)).root;
|
|
312
|
+
return gitSucceeds([
|
|
313
|
+
"-C", root, "rev-parse", "--verify", "--quiet", "--end-of-options",
|
|
314
|
+
`${safeRef(ref)}^{commit}`
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
async listRefs(repositoryPath, pattern) {
|
|
318
|
+
const root = (await this.inspect(repositoryPath)).root;
|
|
319
|
+
const output = await git([
|
|
320
|
+
"-C", root, "for-each-ref", "--format=%(refname)", "--", safeRef(pattern)
|
|
321
|
+
]);
|
|
322
|
+
return output.length === 0 ? [] : output.split("\n").map((line) => line.trim())
|
|
323
|
+
.filter((line) => line.length > 0);
|
|
324
|
+
}
|
|
325
|
+
async archiveRef(input) {
|
|
326
|
+
const root = (await this.inspect(input.repositoryPath)).root;
|
|
327
|
+
const source = safeRef(input.sourceRef);
|
|
328
|
+
const target = safeRef(input.archiveRef);
|
|
329
|
+
const commit = (await gitLine([
|
|
330
|
+
"-C", root, "rev-parse", "--verify", "--end-of-options", `${source}^{commit}`
|
|
331
|
+
])).toLowerCase();
|
|
332
|
+
if (await this.refExists(root, target)) {
|
|
333
|
+
// Resumable: a previous attempt already created the archive ref. Only
|
|
334
|
+
// the same commit may be resumed; a different archive fails closed.
|
|
335
|
+
const archived = (await gitLine([
|
|
336
|
+
"-C", root, "rev-parse", "--verify", "--end-of-options", `${target}^{commit}`
|
|
337
|
+
])).toLowerCase();
|
|
338
|
+
if (archived !== commit) {
|
|
339
|
+
throw new Error(`Archive ref already exists at a different commit: ${target}.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
// Create the archive ref only if it does not exist yet (old value zero).
|
|
344
|
+
await git([
|
|
345
|
+
"-C", root, "update-ref", "--no-deref", target, commit, "0".repeat(40)
|
|
346
|
+
]);
|
|
347
|
+
}
|
|
348
|
+
// Delete the source only if it still exists and still points at the
|
|
349
|
+
// archived commit; an already-deleted source makes the archive a no-op.
|
|
350
|
+
if (await this.refExists(root, source)) {
|
|
351
|
+
await git([
|
|
352
|
+
"-C", root, "update-ref", "-d", "--no-deref", source, commit
|
|
353
|
+
]);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
303
356
|
async isAncestor(repositoryPath, ancestor, descendant) {
|
|
304
357
|
const root = (await this.inspect(repositoryPath)).root;
|
|
305
358
|
return gitSucceeds([
|
|
@@ -387,7 +440,7 @@ export class NodeGitWorkspace {
|
|
|
387
440
|
return this.#ensureManagedWorktree({
|
|
388
441
|
repositoryPath: input.repositoryPath,
|
|
389
442
|
container: input.container,
|
|
390
|
-
identity: worktreeIdentity(input.
|
|
443
|
+
identity: worktreeIdentity(input.taskSegment, input.roleName),
|
|
391
444
|
baseRef: input.baseRef
|
|
392
445
|
});
|
|
393
446
|
}
|
|
@@ -395,7 +448,7 @@ export class NodeGitWorkspace {
|
|
|
395
448
|
return this.#ensureManagedWorktree({
|
|
396
449
|
repositoryPath: input.repositoryPath,
|
|
397
450
|
container: input.container,
|
|
398
|
-
identity: integrationWorktreeIdentity(input.
|
|
451
|
+
identity: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
|
|
399
452
|
baseRef: input.baseRef
|
|
400
453
|
});
|
|
401
454
|
}
|
|
@@ -441,7 +494,7 @@ export class NodeGitWorkspace {
|
|
|
441
494
|
if (state === "dirty")
|
|
442
495
|
return state;
|
|
443
496
|
const container = resolve(input.container);
|
|
444
|
-
const identity = worktreeIdentity(input.
|
|
497
|
+
const identity = worktreeIdentity(input.taskSegment, input.roleName);
|
|
445
498
|
const path = managedPath(container, identity.directory);
|
|
446
499
|
const project = await this.inspect(input.repositoryPath);
|
|
447
500
|
if (state === "missing") {
|
|
@@ -459,7 +512,7 @@ export class NodeGitWorkspace {
|
|
|
459
512
|
}
|
|
460
513
|
async inspectWorktree(input) {
|
|
461
514
|
const container = resolve(input.container);
|
|
462
|
-
const path = managedPath(container, worktreeIdentity(input.
|
|
515
|
+
const path = managedPath(container, worktreeIdentity(input.taskSegment, input.roleName).directory);
|
|
463
516
|
const kind = await pathKind(path);
|
|
464
517
|
if (kind === undefined)
|
|
465
518
|
return "missing";
|
|
@@ -475,7 +528,7 @@ export class NodeGitWorkspace {
|
|
|
475
528
|
return this.#removeManagedWorktree({
|
|
476
529
|
repositoryPath: input.repositoryPath,
|
|
477
530
|
container: input.container,
|
|
478
|
-
identity: integrationWorktreeIdentity(input.
|
|
531
|
+
identity: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
|
|
479
532
|
discardChanges: input.discardChanges
|
|
480
533
|
});
|
|
481
534
|
}
|