@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
|
@@ -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
|
}
|
|
@@ -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:
|
|
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 !==
|
|
136
|
-
throw new Error("Project must use schemaVersion
|
|
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
|
+
}
|