@zq-silk/yui 0.5.1 → 0.5.3

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.
@@ -229,8 +229,8 @@ const taskChildren = [
229
229
  {
230
230
  name: "rebuild",
231
231
  summary: "Rebuild a legacy Task workspace under its canonical identity.",
232
- usage: "yui task rebuild <task>",
233
- options: []
232
+ usage: "yui task rebuild <task> [--latest]",
233
+ options: ["--latest"]
234
234
  },
235
235
  {
236
236
  name: "history",
@@ -205,11 +205,11 @@ async function cloneProject(args, store, options) {
205
205
  }
206
206
  /**
207
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.
208
+ * remote is cloned, both configured branches are verified against the
209
+ * advertised remote SHAs, and local Yui refs are imported before the catalog
210
+ * record switches. The old checkout is never touched. A failed migration
211
+ * leaves no partial state: the unfinished managed clone is removed so the
212
+ * command can be retried cleanly.
213
213
  */
214
214
  async function migrateProject(args, store, options) {
215
215
  const usage = "Project migrate usage: yui project migrate <project> [--preflight].";
@@ -264,6 +264,15 @@ async function migrateProject(args, store, options) {
264
264
  localCommit: (await git.inspect(verifyRoot, project.developmentBranch)).baseCommit
265
265
  }])
266
266
  ]);
267
+ const copyRefs = git.copyRefs;
268
+ if (typeof copyRefs !== "function") {
269
+ throw new Error(`Git workspace cannot preserve local Yui refs for Project: ${project.id}.`);
270
+ }
271
+ await copyRefs.call(git, {
272
+ sourceRepositoryPath: project.path,
273
+ destinationRepositoryPath: verifyRoot,
274
+ patterns: ["refs/heads/yui/", "refs/yui/archive/"]
275
+ });
267
276
  if (parsed.preflight) {
268
277
  return { project, path: destination, preflight: true };
269
278
  }
@@ -20,11 +20,17 @@ export async function runTaskWorkspaceCommand(args, store, preparer, options = {
20
20
  + "yui task history list|archive [task], yui task replace <task>.");
21
21
  }
22
22
  async function rebuildTaskCommand(args, preparer) {
23
- const usage = "Task rebuild usage: yui task rebuild <task>.";
23
+ const usage = "Task rebuild usage: yui task rebuild <task> [--latest].";
24
24
  const taskId = args[0];
25
- if (taskId === undefined || args.length !== 1)
25
+ const options = new Set(args.slice(1));
26
+ if (taskId === undefined
27
+ || options.size !== args.length - 1
28
+ || [...options].some((option) => option !== "--latest")) {
26
29
  throw usageError(usage);
27
- const result = await preparer.rebuildTaskWorkspace(taskId);
30
+ }
31
+ const result = await preparer.rebuildTaskWorkspace(taskId, {
32
+ latestRemote: options.has("--latest")
33
+ });
28
34
  const archived = result.archived.length === 0
29
35
  ? "no legacy refs"
30
36
  : `${result.archived.length} legacy ref(s) archived`;
@@ -35,7 +41,8 @@ async function rebuildTaskCommand(args, preparer) {
35
41
  data: {
36
42
  taskId: result.task.id,
37
43
  archived: result.archived,
38
- resumed: result.resumed
44
+ resumed: result.resumed,
45
+ latestRemote: options.has("--latest")
39
46
  }
40
47
  };
41
48
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
3
  import { lstat, mkdir, realpath, rm } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
@@ -322,6 +322,73 @@ export class NodeGitWorkspace {
322
322
  return output.length === 0 ? [] : output.split("\n").map((line) => line.trim())
323
323
  .filter((line) => line.length > 0);
324
324
  }
325
+ async copyRefs(input) {
326
+ const source = await this.inspect(input.sourceRepositoryPath);
327
+ const destination = await this.inspect(input.destinationRepositoryPath);
328
+ if (source.gitDirectory === destination.gitDirectory)
329
+ return [];
330
+ const refs = [...new Set((await Promise.all(input.patterns.map((pattern) => this.listRefs(source.root, pattern)))).flat())].sort();
331
+ const snapshots = [];
332
+ for (const ref of refs) {
333
+ const name = safeRef(ref);
334
+ const commit = await resolveRefCommit(source.root, name);
335
+ if (await this.refExists(destination.root, name)) {
336
+ const existing = await resolveRefCommit(destination.root, name);
337
+ if (existing !== commit) {
338
+ throw new Error(`Destination ref already exists at a different commit: ${name}.`);
339
+ }
340
+ }
341
+ snapshots.push({ ref: name, commit });
342
+ }
343
+ const importRoot = `refs/yui/migration-import/${randomBytes(16).toString("hex")}`;
344
+ const temporary = [];
345
+ try {
346
+ for (const [index, snapshot] of snapshots.entries()) {
347
+ const importedRef = `${importRoot}/${index}`;
348
+ await git([
349
+ "-C", destination.root,
350
+ "fetch", "--no-tags", "--no-write-fetch-head", "--",
351
+ source.root, `${snapshot.ref}:${importedRef}`
352
+ ]);
353
+ const imported = await resolveRefCommit(destination.root, importedRef);
354
+ if (imported !== snapshot.commit) {
355
+ throw new Error(`Imported ref did not preserve its exact commit: ${snapshot.ref}.`);
356
+ }
357
+ temporary.push({ ref: importedRef, commit: imported });
358
+ }
359
+ // Freeze the source snapshots through publication. A moving local ref is
360
+ // never silently copied under an earlier/later identity.
361
+ for (const snapshot of snapshots) {
362
+ if (await resolveRefCommit(source.root, snapshot.ref) !== snapshot.commit) {
363
+ throw new Error(`Source ref changed while it was being copied: ${snapshot.ref}.`);
364
+ }
365
+ }
366
+ for (const snapshot of snapshots) {
367
+ if (await this.refExists(destination.root, snapshot.ref)) {
368
+ if (await resolveRefCommit(destination.root, snapshot.ref) !== snapshot.commit) {
369
+ throw new Error(`Destination ref changed while it was being copied: ${snapshot.ref}.`);
370
+ }
371
+ continue;
372
+ }
373
+ await git([
374
+ "-C", destination.root,
375
+ "update-ref", "--no-deref", snapshot.ref, snapshot.commit,
376
+ "0".repeat(snapshot.commit.length)
377
+ ]);
378
+ }
379
+ return snapshots.map(({ ref }) => ref);
380
+ }
381
+ finally {
382
+ for (const entry of temporary) {
383
+ if (await this.refExists(destination.root, entry.ref)) {
384
+ await git([
385
+ "-C", destination.root,
386
+ "update-ref", "-d", "--no-deref", entry.ref, entry.commit
387
+ ]);
388
+ }
389
+ }
390
+ }
391
+ }
325
392
  async archiveRef(input) {
326
393
  const root = (await this.inspect(input.repositoryPath)).root;
327
394
  const source = safeRef(input.sourceRef);
@@ -342,7 +409,7 @@ export class NodeGitWorkspace {
342
409
  else {
343
410
  // Create the archive ref only if it does not exist yet (old value zero).
344
411
  await git([
345
- "-C", root, "update-ref", "--no-deref", target, commit, "0".repeat(40)
412
+ "-C", root, "update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
346
413
  ]);
347
414
  }
348
415
  // Delete the source only if it still exists and still points at the
@@ -510,6 +577,37 @@ export class NodeGitWorkspace {
510
577
  }
511
578
  return "removed";
512
579
  }
580
+ async inspectRecordedWorktree(input) {
581
+ const inspected = await inspectExactRecordedWorktree(input);
582
+ return inspected?.state ?? "missing";
583
+ }
584
+ async removeRecordedWorktree(input) {
585
+ const inspected = await inspectExactRecordedWorktree(input);
586
+ if (inspected === undefined)
587
+ return "missing";
588
+ if (inspected.state === "dirty")
589
+ return "dirty";
590
+ // Revalidate immediately before asking the worktree's own Git common-dir
591
+ // to remove it. This never relies on the Project catalog's former path.
592
+ const current = await inspectExactRecordedWorktree(input);
593
+ if (current === undefined)
594
+ return "missing";
595
+ if (current.state === "dirty")
596
+ return "dirty";
597
+ await retainCommitRef(current.destinationRoot, input.retainedRef, current.head);
598
+ await git(["-C", current.path, "worktree", "remove", "--", current.path]);
599
+ if (await pathKind(current.path) !== undefined) {
600
+ throw new Error(`Recorded managed worktree remained after removal: ${current.path}.`);
601
+ }
602
+ if (current.gitDirectory !== current.destinationGitDirectory) {
603
+ await git([
604
+ `--git-dir=${current.gitDirectory}`,
605
+ "update-ref", "-d", "--no-deref", `refs/heads/${safeRef(input.branch)}`,
606
+ current.head
607
+ ]);
608
+ }
609
+ return "removed";
610
+ }
513
611
  async inspectWorktree(input) {
514
612
  const container = resolve(input.container);
515
613
  const path = managedPath(container, worktreeIdentity(input.taskSegment, input.roleName).directory);
@@ -625,6 +723,75 @@ async function assertOwnedWorktree(project, container, path) {
625
723
  throw new Error("Managed worktree belongs to another project.");
626
724
  }
627
725
  }
726
+ async function inspectExactRecordedWorktree(input) {
727
+ const container = resolve(input.container);
728
+ const identity = worktreeIdentity(input.taskSegment, input.roleName);
729
+ const expectedPath = managedPath(container, identity.directory);
730
+ if (resolve(input.path) !== expectedPath || input.branch !== identity.branch) {
731
+ throw new Error("Recorded managed worktree identity is invalid.");
732
+ }
733
+ const kind = await pathKind(expectedPath);
734
+ if (kind === undefined)
735
+ return undefined;
736
+ if (kind === "symlink") {
737
+ throw new Error("Recorded managed worktree path must not be a symbolic link.");
738
+ }
739
+ const canonicalContainerPath = await canonicalContainer(container, false);
740
+ const path = await canonicalDirectory(expectedPath, "Recorded managed worktree");
741
+ assertContained(canonicalContainerPath, path);
742
+ if (path !== expectedPath) {
743
+ throw new Error("Recorded managed worktree resolves through a symbolic link.");
744
+ }
745
+ const root = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--show-toplevel"]), "Recorded managed worktree root");
746
+ if (root !== path) {
747
+ throw new Error("Recorded managed worktree root does not match its deterministic path.");
748
+ }
749
+ const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
750
+ if (branch !== input.branch) {
751
+ throw new Error(`Recorded managed worktree is on an unexpected branch: ${branch}.`);
752
+ }
753
+ const gitDirectory = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]), "Recorded managed Git common directory");
754
+ const destination = await new NodeGitWorkspace().inspect(input.repositoryPath);
755
+ const head = await resolveRefCommit(path, "HEAD");
756
+ const retained = await resolveRefCommit(destination.root, `refs/heads/${input.branch}`);
757
+ if (retained !== head) {
758
+ throw new Error(`Recorded managed worktree is not retained by the current Project: ${input.branch}.`);
759
+ }
760
+ const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
761
+ return {
762
+ path,
763
+ gitDirectory,
764
+ destinationGitDirectory: destination.gitDirectory,
765
+ destinationRoot: destination.root,
766
+ head,
767
+ state: status.length === 0 ? "clean" : "dirty"
768
+ };
769
+ }
770
+ async function resolveRefCommit(repositoryPath, ref) {
771
+ const commit = (await gitLine([
772
+ "-C", repositoryPath,
773
+ "rev-parse", "--verify", "--end-of-options", `${safeRef(ref)}^{commit}`
774
+ ])).toLowerCase();
775
+ if (!isCommit(commit))
776
+ throw new Error("Git returned an invalid ref commit.");
777
+ return commit;
778
+ }
779
+ async function retainCommitRef(repositoryPath, ref, commit) {
780
+ const target = safeRef(ref);
781
+ if (await gitSucceeds([
782
+ "-C", repositoryPath,
783
+ "rev-parse", "--verify", "--quiet", "--end-of-options", `${target}^{commit}`
784
+ ])) {
785
+ if (await resolveRefCommit(repositoryPath, target) !== commit) {
786
+ throw new Error(`Retained ref already exists at a different commit: ${target}.`);
787
+ }
788
+ return;
789
+ }
790
+ await git([
791
+ "-C", repositoryPath,
792
+ "update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
793
+ ]);
794
+ }
628
795
  async function canonicalContainer(path, create) {
629
796
  const lexical = resolve(path);
630
797
  if (create)
@@ -1485,7 +1485,7 @@ export class FileTaskWorkspacePreparer {
1485
1485
  * A Task that already carries an identity takes the resume path: only the
1486
1486
  * pending legacy archive and old-worktree removal run.
1487
1487
  */
1488
- async rebuildTaskWorkspace(taskId) {
1488
+ async rebuildTaskWorkspace(taskId, options = {}) {
1489
1489
  const task = requireTask(this.store, taskId);
1490
1490
  if (!["draft", "active"].includes(task.status)) {
1491
1491
  throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
@@ -1508,7 +1508,7 @@ export class FileTaskWorkspacePreparer {
1508
1508
  throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
1509
1509
  }
1510
1510
  if (existing !== null
1511
- && await this.#inspectEntries(task.id, MAIN_WORKTREE, existing.entries) === "dirty") {
1511
+ && await this.#inspectLegacyTaskEntries(task, existing.entries) === "dirty") {
1512
1512
  throw new Error(`Task workspace is dirty and blocks the rebuild: ${task.id}.`);
1513
1513
  }
1514
1514
  // Resolve verified remote SHAs before any Git side effect, mirroring the
@@ -1518,9 +1518,12 @@ export class FileTaskWorkspacePreparer {
1518
1518
  const pins = new Map();
1519
1519
  for (const binding of task.projectBindings) {
1520
1520
  const project = requireProject(this.store, binding.projectId);
1521
- const useRemoteDefault = defaultProjects.has(project.id)
1522
- && project.remoteUrl !== undefined
1523
- && !looksLikeCommit(binding.baseRef);
1521
+ // `--latest` explicitly re-resolves every remote-backed Project. Without
1522
+ // it, only a still-symbolic creation default is refreshed; an explicit
1523
+ // or previously pinned commit retains the established rebuild behavior.
1524
+ const useRemoteDefault = (options.latestRemote === true
1525
+ || (defaultProjects.has(project.id) && !looksLikeCommit(binding.baseRef)))
1526
+ && project.remoteUrl !== undefined;
1524
1527
  if (useRemoteDefault) {
1525
1528
  const resolver = this.git.resolveRemoteBaseline;
1526
1529
  if (typeof resolver !== "function") {
@@ -1529,9 +1532,9 @@ export class FileTaskWorkspacePreparer {
1529
1532
  const remote = await resolver.call(this.git, {
1530
1533
  repositoryPath: project.path,
1531
1534
  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
+ developmentRef: options.latestRemote === true
1536
+ ? project.developmentBranch
1537
+ : binding.baseRef
1535
1538
  });
1536
1539
  pins.set(project.id, remote.commit);
1537
1540
  }
@@ -1792,9 +1795,14 @@ export class FileTaskWorkspacePreparer {
1792
1795
  async #removeLegacyWorktrees(task, projectIds) {
1793
1796
  for (const projectId of projectIds) {
1794
1797
  const project = requireProject(this.store, projectId);
1795
- const removal = await this.git.removeWorktree({
1798
+ const container = this.#projectContainer(project.name);
1799
+ const branch = worktreeIdentity(task.id, MAIN_WORKTREE).branch;
1800
+ const removal = await this.git.removeRecordedWorktree({
1796
1801
  repositoryPath: project.path,
1797
- container: this.#projectContainer(project.name),
1802
+ container,
1803
+ path: join(container, task.id, MAIN_WORKTREE),
1804
+ branch,
1805
+ retainedRef: taskArchiveRef(this.store.getHomeIdentity().homeId, `refs/heads/${branch}`),
1798
1806
  taskSegment: task.id,
1799
1807
  roleName: MAIN_WORKTREE
1800
1808
  });
@@ -1803,6 +1811,24 @@ export class FileTaskWorkspacePreparer {
1803
1811
  }
1804
1812
  }
1805
1813
  }
1814
+ async #inspectLegacyTaskEntries(task, entries) {
1815
+ let found = false;
1816
+ for (const entry of entries) {
1817
+ const project = requireProject(this.store, entry.projectId);
1818
+ const state = await this.git.inspectRecordedWorktree({
1819
+ repositoryPath: project.path,
1820
+ container: this.#projectContainer(project.name),
1821
+ path: entry.path,
1822
+ branch: entry.branch,
1823
+ taskSegment: task.id,
1824
+ roleName: MAIN_WORKTREE
1825
+ });
1826
+ if (state === "dirty")
1827
+ return state;
1828
+ found ||= state === "clean";
1829
+ }
1830
+ return found ? "clean" : "missing";
1831
+ }
1806
1832
  #recordWorkspaceRemoval(task, workspace, fallback, workItem) {
1807
1833
  this.store.transaction((tx) => {
1808
1834
  if (workspace.owner.type === "task") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,