@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
|
@@ -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
|
+
}
|