@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.
@@ -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.taskId, input.roleName),
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.taskId, input.integrationId),
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.taskId, input.roleName);
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.taskId, input.roleName).directory);
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.taskId, input.integrationId),
531
+ identity: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
479
532
  discardChanges: input.discardChanges
480
533
  });
481
534
  }
@@ -0,0 +1,28 @@
1
+ import { randomBytes } from "node:crypto";
2
+ export const HOME_IDENTITY_PATTERN = /^home-[a-f0-9]{16}$/;
3
+ const HOME_ENTROPY_BYTES = 16;
4
+ export function generateHomeIdentity(now, source = () => randomBytes(HOME_ENTROPY_BYTES)) {
5
+ const entropy = source().toString("hex");
6
+ const homeId = `home-${randomBytes(8).toString("hex")}`;
7
+ return validateHomeIdentity({
8
+ schemaVersion: 1,
9
+ homeId,
10
+ createdAt: now.toISOString(),
11
+ entropy
12
+ });
13
+ }
14
+ export function validateHomeIdentity(identity) {
15
+ if (identity.schemaVersion !== 1) {
16
+ throw new Error("Home identity must use schemaVersion 1.");
17
+ }
18
+ if (typeof identity.homeId !== "string" || !HOME_IDENTITY_PATTERN.test(identity.homeId)) {
19
+ throw new Error("Home identity id is invalid.");
20
+ }
21
+ if (typeof identity.entropy !== "string" || !/^[a-f0-9]{32}$/.test(identity.entropy)) {
22
+ throw new Error("Home identity entropy is invalid.");
23
+ }
24
+ if (typeof identity.createdAt !== "string" || !Number.isFinite(Date.parse(identity.createdAt))) {
25
+ throw new Error("Home identity createdAt is invalid.");
26
+ }
27
+ return identity;
28
+ }
@@ -1,12 +1,18 @@
1
- import { resolve } from "node:path";
1
+ import { join, resolve } from "node:path";
2
+ /** The Yui-owned directory holding a managed Project's canonical repository. */
3
+ export function managedProjectPath(home, projectId) {
4
+ const id = requireIdentity(projectId, "Project id");
5
+ return join(resolve(home), "projects", id);
6
+ }
2
7
  export function createProject(id, name, path, branches, now, metadata = {}) {
3
8
  const timestamp = now.toISOString();
4
9
  return validateProject({
5
- schemaVersion: 2,
10
+ schemaVersion: 3,
6
11
  id: requireIdentity(id, "Project id"),
7
12
  name: validateProjectName(name),
8
13
  aliases: normalizeAliases(metadata.aliases ?? [], name),
9
14
  path: resolve(requireText(path, "Project path")),
15
+ ownership: metadata.ownership ?? "external",
10
16
  ...(metadata.remoteUrl === undefined
11
17
  ? {}
12
18
  : { remoteUrl: requireText(metadata.remoteUrl, "Project remote URL") }),
@@ -132,12 +138,15 @@ export function assertProjectCatalog(projects) {
132
138
  }
133
139
  }
134
140
  export function validateProject(project) {
135
- if (project.schemaVersion !== 2) {
136
- throw new Error("Project must use schemaVersion 2.");
141
+ if (project.schemaVersion !== 3) {
142
+ throw new Error("Project must use schemaVersion 3.");
137
143
  }
138
144
  requireIdentity(project.id, "Project id");
139
145
  requireIdentity(project.name, "Project name");
140
146
  normalizeAliases(project.aliases, project.name);
147
+ if (project.ownership !== "managed" && project.ownership !== "external") {
148
+ throw new Error(`Project ownership is invalid: ${String(project.ownership)}.`);
149
+ }
141
150
  const references = new Set();
142
151
  for (const reference of [project.id, project.name, ...project.aliases]) {
143
152
  const folded = reference.toLocaleLowerCase();
@@ -0,0 +1,158 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ export const TASK_WORKSPACE_TOKEN_PATTERN = /^[a-f0-9]{8}$/;
3
+ /** Strict on-disk ref segment for a Task's managed worktrees. */
4
+ export const TASK_WORKSPACE_REF_SEGMENT_PATTERN = /^(task-[0-9]+)-[a-f0-9]{8}$/;
5
+ const TASK_WORKSPACE_ENTROPY_BYTES = 16;
6
+ const TASK_WORKSPACE_IDENTITY_DOMAIN = "yui-task-workspace-identity/v1";
7
+ /** Unambiguous length-prefixed encoding: every field is `<byteLen>:<value>`. */
8
+ function lengthPrefixedEncoding(parts) {
9
+ return parts
10
+ .map((part) => `${Buffer.byteLength(part, "utf8")}:${part}`)
11
+ .join("\n");
12
+ }
13
+ export function deriveTaskWorkspaceToken(input) {
14
+ const encoding = lengthPrefixedEncoding([
15
+ TASK_WORKSPACE_IDENTITY_DOMAIN,
16
+ input.homeId,
17
+ input.taskId,
18
+ input.generatedAt,
19
+ input.entropy
20
+ ]);
21
+ return createHash("sha256").update(encoding, "utf8").digest("hex").slice(0, 8);
22
+ }
23
+ export function generateTaskWorkspaceIdentity(input) {
24
+ const homeId = requireIdentityPart(input.home.homeId, "Home id");
25
+ const taskId = requireIdentityPart(input.taskId, "Task id");
26
+ const generatedAt = input.now.toISOString();
27
+ const entropy = (input.entropy ?? randomBytes(TASK_WORKSPACE_ENTROPY_BYTES))
28
+ .toString("hex");
29
+ const token = deriveTaskWorkspaceToken({ homeId, taskId, generatedAt, entropy });
30
+ return validateTaskWorkspaceIdentity({
31
+ schemaVersion: 1,
32
+ homeId,
33
+ taskId,
34
+ token,
35
+ generatedAt,
36
+ entropy
37
+ });
38
+ }
39
+ /**
40
+ * The ref segment shared by every managed worktree of one Task, e.g.
41
+ * `task-2-a1b2c3d4`. Git refs and worktree directories are derived from this
42
+ * segment, never from the bare Task id.
43
+ */
44
+ export function taskWorkspaceRefSegmentFromIdentity(identity) {
45
+ const valid = validateTaskWorkspaceIdentity(identity);
46
+ return `${valid.taskId}-${valid.token}`;
47
+ }
48
+ /**
49
+ * The durable ref segment for a Task. A Task with a persisted workspace
50
+ * identity always uses its token-bearing segment; a pre-identity record (a
51
+ * valid v4 Task that never had a managed Git workspace, or one awaiting the
52
+ * controlled rebuild) keeps its bare Task id so its existing worktrees remain
53
+ * addressable until they are rebuilt.
54
+ */
55
+ export function taskWorkspaceRefSegment(task) {
56
+ if (task.workspaceIdentity === undefined)
57
+ return task.id;
58
+ return taskWorkspaceRefSegmentFromIdentity(task.workspaceIdentity);
59
+ }
60
+ /**
61
+ * The strict main-branch ref for a Task workspace. The main branch is always
62
+ * `yui/task-N-<8hex>/main`; any other shape is a foreign or legacy ref.
63
+ */
64
+ export function taskMainBranch(refSegment) {
65
+ if (!TASK_WORKSPACE_REF_SEGMENT_PATTERN.test(refSegment)) {
66
+ throw new Error(`Task workspace ref segment is invalid: ${refSegment}.`);
67
+ }
68
+ return `yui/${refSegment}/main`;
69
+ }
70
+ /**
71
+ * A derived managed branch for one Task workspace member (WorkItem,
72
+ * ReviewRound, or execution lane worktree name). Every derived ref stays
73
+ * under the Task's token-bearing segment, so capture, review, integration,
74
+ * and cleanup fences all resolve through the same persisted identity.
75
+ */
76
+ export function taskDerivedBranch(refSegment, member) {
77
+ if (!TASK_WORKSPACE_REF_SEGMENT_PATTERN.test(refSegment)) {
78
+ throw new Error(`Task workspace ref segment is invalid: ${refSegment}.`);
79
+ }
80
+ const memberSegment = member.trim();
81
+ if (memberSegment.length === 0 || /[~^:?*[\]\s]/.test(memberSegment)
82
+ || memberSegment.includes("..") || memberSegment.startsWith("-")) {
83
+ throw new Error(`Task workspace member is invalid: ${member}.`);
84
+ }
85
+ return `yui/${refSegment}/${memberSegment}`;
86
+ }
87
+ /** The Integration Attempt branch derived from the Task workspace identity. */
88
+ export function taskIntegrationBranch(refSegment, integrationId) {
89
+ return taskDerivedBranch(refSegment, `integration/${integrationId}`);
90
+ }
91
+ /**
92
+ * The non-colliding, auditable archive ref for a legacy Task ref. The Home id
93
+ * in the path keeps two Homes' archives apart even when they share a Project
94
+ * repository; the full original ref name is preserved verbatim.
95
+ * Legacy refs live at `refs/heads/yui/task-N/...`; archives at
96
+ * `refs/yui/archive/<homeId>/heads/yui/task-N/...`.
97
+ */
98
+ export function taskArchiveRef(homeId, sourceRef) {
99
+ const id = requireIdentityPart(homeId, "Home id");
100
+ const ref = sourceRef.trim();
101
+ if (!ref.startsWith("refs/")
102
+ || ref.includes("..")
103
+ || /[~^:?*[\]\s]/.test(ref)) {
104
+ throw new Error(`Archive source ref is invalid: ${sourceRef}.`);
105
+ }
106
+ return `refs/yui/archive/${id}/${ref.slice("refs/".length)}`;
107
+ }
108
+ /**
109
+ * Whether a managed branch belongs to the legacy (pre-identity) layout:
110
+ * `yui/task-N/...` with a bare Task id segment. Identity-bearing branches
111
+ * (`yui/task-N-<8hex>/...`) are never legacy.
112
+ */
113
+ export function isLegacyTaskRef(refName) {
114
+ const match = /^refs\/heads\/(yui\/(task-[0-9]+)\/.+)$/.exec(refName);
115
+ return match !== null && !TASK_WORKSPACE_REF_SEGMENT_PATTERN.test(match[2]);
116
+ }
117
+ export function validateTaskWorkspaceIdentity(identity) {
118
+ if (identity.schemaVersion !== 1) {
119
+ throw new Error("Task workspace identity must use schemaVersion 1.");
120
+ }
121
+ const homeId = requireIdentityPart(identity.homeId, "Home id");
122
+ const taskId = requireIdentityPart(identity.taskId, "Task id");
123
+ if (typeof identity.generatedAt !== "string"
124
+ || !Number.isFinite(Date.parse(identity.generatedAt))) {
125
+ throw new Error("Task workspace identity generatedAt is invalid.");
126
+ }
127
+ if (typeof identity.entropy !== "string"
128
+ || !/^[a-f0-9]{32}$/.test(identity.entropy)) {
129
+ throw new Error("Task workspace identity entropy is invalid.");
130
+ }
131
+ if (typeof identity.token !== "string"
132
+ || !TASK_WORKSPACE_TOKEN_PATTERN.test(identity.token)) {
133
+ throw new Error("Task workspace identity token is invalid.");
134
+ }
135
+ // The token must be the exact SHA-256 discriminator of the persisted fields;
136
+ // a mismatched token is malformed state and is rejected, never repaired.
137
+ const expected = deriveTaskWorkspaceToken({
138
+ homeId,
139
+ taskId,
140
+ generatedAt: identity.generatedAt,
141
+ entropy: identity.entropy
142
+ });
143
+ if (expected !== identity.token) {
144
+ throw new Error("Task workspace identity token does not match its persisted fields.");
145
+ }
146
+ return identity;
147
+ }
148
+ function requireIdentityPart(value, label) {
149
+ if (typeof value !== "string" || value.includes("\0")) {
150
+ throw new Error(`${label} is invalid.`);
151
+ }
152
+ const trimmed = value.trim();
153
+ if (trimmed.length === 0)
154
+ throw new Error(`${label} is required.`);
155
+ if (/[\/\\]/.test(trimmed))
156
+ throw new Error(`${label} must not contain a path separator.`);
157
+ return trimmed;
158
+ }