@zq-silk/yui 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commandCatalog.js +38 -2
- package/dist/cli/interactionPolicy.js +12 -0
- 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.",
|
|
@@ -755,7 +785,7 @@ export const ROOT_COMMAND = buildNode({
|
|
|
755
785
|
{
|
|
756
786
|
name: "project",
|
|
757
787
|
summary: "Manage Projects, stable checkouts, branches, and Yui knowledge.",
|
|
758
|
-
sections: [{ id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "update", "discover", "list", "show", "knowledge"] }],
|
|
788
|
+
sections: [{ id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "migrate", "update", "discover", "list", "show", "knowledge"] }],
|
|
759
789
|
children: [
|
|
760
790
|
{
|
|
761
791
|
name: "add",
|
|
@@ -775,6 +805,12 @@ export const ROOT_COMMAND = buildNode({
|
|
|
775
805
|
summary: "Fast-forward a clean stable checkout from its configured remote.",
|
|
776
806
|
usage: "yui project refresh <project>"
|
|
777
807
|
},
|
|
808
|
+
{
|
|
809
|
+
name: "migrate",
|
|
810
|
+
summary: "Move an external Project into a Home-managed repository.",
|
|
811
|
+
usage: "yui project migrate <project> [--preflight]",
|
|
812
|
+
options: ["--preflight"]
|
|
813
|
+
},
|
|
778
814
|
{
|
|
779
815
|
name: "update",
|
|
780
816
|
summary: "Update a bound Project's aliases, remote, or branch refs.",
|
|
@@ -860,7 +896,7 @@ export const ROOT_COMMAND = buildNode({
|
|
|
860
896
|
name: "task",
|
|
861
897
|
summary: "Manage Tasks, WorkItems, Agent Runs, and integration.",
|
|
862
898
|
sections: [
|
|
863
|
-
{ id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "update", "activate", "complete", "reopen", "retire", "list", "show", "context", "archive", "reconcile"] },
|
|
899
|
+
{ id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "update", "activate", "complete", "reopen", "retire", "list", "show", "context", "archive", "rebuild", "history", "replace", "reconcile"] },
|
|
864
900
|
{ id: "collaboration", title: "Collaboration", entries: ["message", "input", "work", "run", "review", "integration", "role", "enter"] },
|
|
865
901
|
{ id: "knowledge", title: "Task Knowledge", entries: ["brief", "decision", "milestone", "event"] }
|
|
866
902
|
],
|
|
@@ -18,6 +18,18 @@ export const INTERACTION_POLICIES = Object.freeze([
|
|
|
18
18
|
actionTarget: true
|
|
19
19
|
}]
|
|
20
20
|
},
|
|
21
|
+
{
|
|
22
|
+
commandPath: ["project", "migrate"],
|
|
23
|
+
selectors: [{
|
|
24
|
+
argumentIndex: 2,
|
|
25
|
+
entity: "project",
|
|
26
|
+
provider: "projects",
|
|
27
|
+
actionTarget: true
|
|
28
|
+
}],
|
|
29
|
+
trailingOptions: {
|
|
30
|
+
"--preflight": "flag"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
21
33
|
{
|
|
22
34
|
commandPath: ["project", "update"],
|
|
23
35
|
selectors: [{
|
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
|
});
|