@nowcrew/daemon 0.6.87-beta.3 → 0.6.87-beta.4
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/cloud-report-uploader.js +27 -25
- package/dist/external-output.js +154 -68
- package/dist/local-executor.js +6 -2
- package/dist/main.js +6 -2
- package/dist/serve.js +17 -11
- package/dist/storage-identity.js +4 -1
- package/dist/storage-ownership.js +93 -5
- package/dist/storage-runtime-controller.js +40 -20
- package/dist/storage-runtime.js +38 -2
- package/dist/storage-serve-adapter.js +22 -5
- package/package.json +9 -10
|
@@ -13,22 +13,17 @@ const fileFingerprint = async (path) => {
|
|
|
13
13
|
const stat = await lstat(path);
|
|
14
14
|
return `${stat.size}:${stat.mtimeMs}`;
|
|
15
15
|
};
|
|
16
|
-
function isReportFile(root, path) {
|
|
16
|
+
function isReportFile(root, path, kind) {
|
|
17
17
|
const parts = relative(root, path).split(sep).filter(Boolean);
|
|
18
18
|
const filename = parts.at(-1) ?? "";
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
21
|
-
|
|
22
|
-
if (parts[0] !== "qa-report-server-v2")
|
|
23
|
-
return false;
|
|
24
|
-
if (parts[1] === "static" && parts.length === 3)
|
|
25
|
-
return extname(filename).toLowerCase() === ".html";
|
|
26
|
-
if (parts[1] === "qa-results" && parts.length >= 4) {
|
|
19
|
+
if (kind === "static")
|
|
20
|
+
return parts.length === 1 && extname(filename).toLowerCase() === ".html";
|
|
21
|
+
if (parts.length >= 2) {
|
|
27
22
|
return REPORT_FILENAMES.has(filename) || extname(filename).toLowerCase() === ".html";
|
|
28
23
|
}
|
|
29
24
|
return false;
|
|
30
25
|
}
|
|
31
|
-
async function walkReports(root, directory, depth, output) {
|
|
26
|
+
async function walkReports(root, directory, depth, output, kind) {
|
|
32
27
|
if (depth > MAX_DEPTH || output.length >= MAX_FILES)
|
|
33
28
|
return;
|
|
34
29
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
@@ -39,28 +34,26 @@ async function walkReports(root, directory, depth, output) {
|
|
|
39
34
|
continue;
|
|
40
35
|
const path = join(directory, entry.name);
|
|
41
36
|
if (entry.isDirectory()) {
|
|
42
|
-
await walkReports(root, path, depth + 1, output);
|
|
37
|
+
await walkReports(root, path, depth + 1, output, kind);
|
|
43
38
|
}
|
|
44
|
-
else if (entry.isFile() && isReportFile(root, path)) {
|
|
39
|
+
else if (entry.isFile() && isReportFile(root, path, kind)) {
|
|
45
40
|
output.push(resolve(path));
|
|
46
41
|
}
|
|
47
42
|
}
|
|
48
43
|
}
|
|
49
|
-
export async function snapshotReportFiles(root) {
|
|
44
|
+
export async function snapshotReportFiles(root, qaReportRoot) {
|
|
50
45
|
const files = [];
|
|
51
46
|
const workspaceRoot = resolve(root);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
join(
|
|
55
|
-
|
|
56
|
-
]) {
|
|
57
|
-
await walkReports(workspaceRoot, directory, 0, files);
|
|
47
|
+
const hub = resolve(qaReportRoot ?? join(workspaceRoot, "qa-report-server-v2"));
|
|
48
|
+
for (const kind of ["tasks", "qa-results", "static"]) {
|
|
49
|
+
const directory = join(kind === "tasks" ? workspaceRoot : hub, kind);
|
|
50
|
+
await walkReports(directory, directory, 0, files, kind);
|
|
58
51
|
}
|
|
59
52
|
const entries = await Promise.all(files.map(async (path) => [path, await fileFingerprint(path)]));
|
|
60
53
|
return new Map(entries);
|
|
61
54
|
}
|
|
62
|
-
export async function discoverReportFiles(root, before) {
|
|
63
|
-
const after = await snapshotReportFiles(root);
|
|
55
|
+
export async function discoverReportFiles(root, before, qaReportRoot) {
|
|
56
|
+
const after = await snapshotReportFiles(root, qaReportRoot);
|
|
64
57
|
return [...after.entries()]
|
|
65
58
|
.filter(([path, fingerprint]) => before.get(path) !== fingerprint)
|
|
66
59
|
.map(([path]) => path)
|
|
@@ -97,7 +90,7 @@ async function verifyOssUrl(url) {
|
|
|
97
90
|
}
|
|
98
91
|
}
|
|
99
92
|
export async function uploadCloudReports(input) {
|
|
100
|
-
const files = await discoverReportFiles(input.root, input.before);
|
|
93
|
+
const files = await discoverReportFiles(input.root, input.before, input.qaReportRoot);
|
|
101
94
|
if (files.length === 0)
|
|
102
95
|
return { urls: [], failures: [] };
|
|
103
96
|
const run = input.execFile ?? (async (file, args) => {
|
|
@@ -131,10 +124,10 @@ export async function uploadCloudReports(input) {
|
|
|
131
124
|
}
|
|
132
125
|
return { urls, failures };
|
|
133
126
|
}
|
|
134
|
-
export async function prepareCloudReportSnapshot(facts, root) {
|
|
127
|
+
export async function prepareCloudReportSnapshot(facts, root, qaReportRoot) {
|
|
135
128
|
if (!facts.enabled)
|
|
136
129
|
return null;
|
|
137
|
-
return snapshotReportFiles(root);
|
|
130
|
+
return snapshotReportFiles(root, qaReportRoot);
|
|
138
131
|
}
|
|
139
132
|
export async function prepareCloudReportExecution(input) {
|
|
140
133
|
const facts = cloudReportUploadFacts(input.env);
|
|
@@ -147,7 +140,7 @@ export async function prepareCloudReportExecution(input) {
|
|
|
147
140
|
return { facts, snapshot: null };
|
|
148
141
|
}
|
|
149
142
|
try {
|
|
150
|
-
return { facts, snapshot: await prepareCloudReportSnapshot(facts, input.root) };
|
|
143
|
+
return { facts, snapshot: await prepareCloudReportSnapshot(facts, input.root, input.qaReportRoot) };
|
|
151
144
|
}
|
|
152
145
|
catch (error) {
|
|
153
146
|
dslog("cloud_report_upload.snapshot_failed", "报告文件快照失败", {
|
|
@@ -177,6 +170,7 @@ export async function deliverCloudReports(input) {
|
|
|
177
170
|
const result = await (input.upload ?? uploadCloudReports)({
|
|
178
171
|
script: input.facts.ossUploadScript,
|
|
179
172
|
root: input.root,
|
|
173
|
+
...(input.qaReportRoot === undefined ? {} : { qaReportRoot: input.qaReportRoot }),
|
|
180
174
|
before: input.before,
|
|
181
175
|
});
|
|
182
176
|
if (result.failures.length > 0) {
|
|
@@ -185,6 +179,14 @@ export async function deliverCloudReports(input) {
|
|
|
185
179
|
uploaded_count: result.urls.length, failed_count: result.failures.length,
|
|
186
180
|
});
|
|
187
181
|
}
|
|
182
|
+
else {
|
|
183
|
+
dslog(result.urls.length === 0 ? "cloud_report_upload.no_files" : "cloud_report_upload.completed", result.urls.length === 0 ? "未发现新增或变化的报告文件" : "报告上传完成", {
|
|
184
|
+
execution_id: input.executionId, agent_handle: input.agentHandle,
|
|
185
|
+
uploaded_count: result.urls.length,
|
|
186
|
+
report_root: input.root,
|
|
187
|
+
qa_report_root: input.qaReportRoot ?? join(input.root, "qa-report-server-v2"),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
188
190
|
return {
|
|
189
191
|
finalText: input.captureFinal && result.urls.length > 0
|
|
190
192
|
? appendReportLinks(input.finalText ?? "", result.urls)
|
package/dist/external-output.js
CHANGED
|
@@ -1,15 +1,104 @@
|
|
|
1
1
|
export const EXTERNAL_ANSWER_OPEN = "<nowwork_external_answer>";
|
|
2
2
|
export const EXTERNAL_ANSWER_CLOSE = "</nowwork_external_answer>";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
const MAX_MARKER_TAG_LENGTH = 96;
|
|
4
|
+
const KNOWN_EXTERNAL_ANSWER_TAG = /^now+orkexternalanswer$/u;
|
|
5
|
+
const CANONICAL_EXTERNAL_ANSWER_TAG = "noworkexternalanswer";
|
|
6
|
+
function editDistanceWithin(value, target, maximum) {
|
|
7
|
+
if (Math.abs(value.length - target.length) > maximum)
|
|
8
|
+
return false;
|
|
9
|
+
let previous = Array.from({ length: target.length + 1 }, (_, index) => index);
|
|
10
|
+
for (let row = 1; row <= value.length; row += 1) {
|
|
11
|
+
const current = [row];
|
|
12
|
+
let rowMinimum = row;
|
|
13
|
+
for (let column = 1; column <= target.length; column += 1) {
|
|
14
|
+
const cost = value[row - 1] === target[column - 1] ? 0 : 1;
|
|
15
|
+
const distance = Math.min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + cost);
|
|
16
|
+
current.push(distance);
|
|
17
|
+
rowMinimum = Math.min(rowMinimum, distance);
|
|
18
|
+
}
|
|
19
|
+
if (rowMinimum > maximum)
|
|
20
|
+
return false;
|
|
21
|
+
previous = current;
|
|
22
|
+
}
|
|
23
|
+
return previous[target.length] <= maximum;
|
|
24
|
+
}
|
|
25
|
+
function resemblesExternalAnswerTag(name) {
|
|
26
|
+
if (name.includes("externalanswer"))
|
|
27
|
+
return true;
|
|
28
|
+
const canonicalized = name.replace(/^now+ork/u, "nowork");
|
|
29
|
+
return editDistanceWithin(canonicalized, CANONICAL_EXTERNAL_ANSWER_TAG, 2);
|
|
30
|
+
}
|
|
31
|
+
function classifyMarkerTag(body) {
|
|
32
|
+
const trimmed = body.trim();
|
|
33
|
+
const closing = trimmed.startsWith("/");
|
|
34
|
+
const name = (closing ? trimmed.slice(1) : trimmed)
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[_\-\s]/gu, "");
|
|
38
|
+
if (KNOWN_EXTERNAL_ANSWER_TAG.test(name))
|
|
39
|
+
return { closing, kind: "known" };
|
|
40
|
+
return {
|
|
41
|
+
closing,
|
|
42
|
+
kind: resemblesExternalAnswerTag(name) ? "suspicious" : "other",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function nextMarkerTag(value, from) {
|
|
46
|
+
let cursor = from;
|
|
47
|
+
while (cursor < value.length) {
|
|
48
|
+
const start = value.indexOf("<", cursor);
|
|
49
|
+
if (start < 0)
|
|
50
|
+
return null;
|
|
51
|
+
const end = value.indexOf(">", start + 1);
|
|
52
|
+
if (end < 0)
|
|
53
|
+
return null;
|
|
54
|
+
const body = value.slice(start + 1, end);
|
|
55
|
+
const classified = classifyMarkerTag(body);
|
|
56
|
+
if (classified.kind === "suspicious"
|
|
57
|
+
|| (classified.kind === "known" && body.length > MAX_MARKER_TAG_LENGTH)) {
|
|
58
|
+
return { start, end: end + 1, closing: classified.closing, kind: "suspicious" };
|
|
59
|
+
}
|
|
60
|
+
if (classified.kind === "known") {
|
|
61
|
+
return { start, end: end + 1, closing: classified.closing, kind: "known" };
|
|
62
|
+
}
|
|
63
|
+
cursor = end + 1;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function parseExternalAnswerText(value) {
|
|
68
|
+
const sections = [];
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
let contentStart = 0;
|
|
71
|
+
let inside = false;
|
|
72
|
+
let found = false;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const tag = nextMarkerTag(value, cursor);
|
|
75
|
+
if (!tag)
|
|
76
|
+
break;
|
|
77
|
+
if (tag.kind === "suspicious")
|
|
78
|
+
return { kind: "rejected", text: "" };
|
|
79
|
+
if (tag.closing) {
|
|
80
|
+
if (!inside)
|
|
81
|
+
return { kind: "rejected", text: "" };
|
|
82
|
+
sections.push(value.slice(contentStart, tag.start));
|
|
83
|
+
inside = false;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (inside)
|
|
87
|
+
return { kind: "rejected", text: "" };
|
|
88
|
+
found = true;
|
|
89
|
+
inside = true;
|
|
90
|
+
contentStart = tag.end;
|
|
91
|
+
}
|
|
92
|
+
cursor = tag.end;
|
|
8
93
|
}
|
|
9
|
-
|
|
94
|
+
if (inside)
|
|
95
|
+
sections.push(value.slice(contentStart));
|
|
96
|
+
if (!found)
|
|
97
|
+
return { kind: "plain", text: value.trim() };
|
|
98
|
+
return { kind: "marked", text: sections.join("").trim() };
|
|
10
99
|
}
|
|
11
100
|
/**
|
|
12
|
-
* 跨 chunk
|
|
101
|
+
* 跨 chunk 的契约标签解码器:push() 在合法闭合后返回完整标签块(供 answerStream 转发);
|
|
13
102
|
* 同时记住整轮里**最后一个完整闭合**的标签块(`lastClosedBlock`),供完结时作为契约答案——
|
|
14
103
|
* 只有闭合了的块才算数:半截块(被工具调用拆开的前半段)与叙述里提到的开标签
|
|
15
104
|
* ("我会把结论包在 <marker> 里,先去跑测试")都不会被当成答案(review of a027bf15, C1)。
|
|
@@ -40,34 +129,63 @@ export class ExternalAnswerDecoder {
|
|
|
40
129
|
}
|
|
41
130
|
consume(value, output) {
|
|
42
131
|
this.pending = "";
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
this.
|
|
53
|
-
|
|
54
|
-
|
|
132
|
+
const appendInside = (text) => {
|
|
133
|
+
if (!text)
|
|
134
|
+
return;
|
|
135
|
+
this.block += text;
|
|
136
|
+
};
|
|
137
|
+
for (const character of value) {
|
|
138
|
+
if (this.pending) {
|
|
139
|
+
this.pending += character;
|
|
140
|
+
if (character === ">") {
|
|
141
|
+
const body = this.pending.slice(1, -1);
|
|
142
|
+
const tag = classifyMarkerTag(body);
|
|
143
|
+
if (tag.kind === "known" && body.length <= MAX_MARKER_TAG_LENGTH) {
|
|
144
|
+
if (tag.closing && this.state === "inside") {
|
|
145
|
+
const completed = this.block.trim();
|
|
146
|
+
if (completed) {
|
|
147
|
+
this.closed = completed;
|
|
148
|
+
output.push(completed);
|
|
149
|
+
}
|
|
150
|
+
this.block = "";
|
|
151
|
+
this.state = "outside";
|
|
152
|
+
}
|
|
153
|
+
else if (!tag.closing && this.state === "outside") {
|
|
154
|
+
this.block = "";
|
|
155
|
+
this.state = "inside";
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
this.block = "";
|
|
159
|
+
this.state = tag.closing ? "outside" : "inside";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (tag.kind === "suspicious" || tag.kind === "known") {
|
|
163
|
+
this.block = "";
|
|
164
|
+
this.state = "outside";
|
|
165
|
+
}
|
|
166
|
+
else if (this.state === "inside") {
|
|
167
|
+
appendInside(this.pending);
|
|
168
|
+
}
|
|
169
|
+
this.pending = "";
|
|
170
|
+
}
|
|
171
|
+
else if (this.pending.length > MAX_MARKER_TAG_LENGTH + 2) {
|
|
172
|
+
const tag = classifyMarkerTag(this.pending.slice(1));
|
|
173
|
+
if (tag.kind === "suspicious" || tag.kind === "known") {
|
|
174
|
+
this.block = "";
|
|
175
|
+
this.state = "outside";
|
|
176
|
+
}
|
|
177
|
+
else if (this.state === "inside") {
|
|
178
|
+
appendInside(this.pending);
|
|
179
|
+
}
|
|
180
|
+
this.pending = "";
|
|
181
|
+
}
|
|
55
182
|
}
|
|
56
|
-
else {
|
|
57
|
-
this.
|
|
58
|
-
|
|
183
|
+
else if (character === "<") {
|
|
184
|
+
this.pending = character;
|
|
185
|
+
}
|
|
186
|
+
else if (this.state === "inside") {
|
|
187
|
+
appendInside(character);
|
|
59
188
|
}
|
|
60
|
-
const remaining = value.slice(markerAt + marker.length);
|
|
61
|
-
if (remaining)
|
|
62
|
-
this.consume(remaining, output);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const retained = retainedMarkerPrefix(value, marker);
|
|
66
|
-
const safe = retained > 0 ? value.slice(0, -retained) : value;
|
|
67
|
-
this.pending = retained > 0 ? value.slice(-retained) : "";
|
|
68
|
-
if (this.state === "inside" && safe) {
|
|
69
|
-
output.push(safe);
|
|
70
|
-
this.block += safe;
|
|
71
189
|
}
|
|
72
190
|
}
|
|
73
191
|
}
|
|
@@ -104,20 +222,7 @@ export function decodeExternalOutputEvent(runtime, event, decoder) {
|
|
|
104
222
|
: [];
|
|
105
223
|
}
|
|
106
224
|
export function stripExternalAnswerMarkers(value) {
|
|
107
|
-
|
|
108
|
-
let cursor = 0;
|
|
109
|
-
while (cursor < value.length) {
|
|
110
|
-
const openAt = value.indexOf(EXTERNAL_ANSWER_OPEN, cursor);
|
|
111
|
-
if (openAt < 0)
|
|
112
|
-
break;
|
|
113
|
-
const contentAt = openAt + EXTERNAL_ANSWER_OPEN.length;
|
|
114
|
-
const closeAt = value.indexOf(EXTERNAL_ANSWER_CLOSE, contentAt);
|
|
115
|
-
if (closeAt < 0)
|
|
116
|
-
return value.slice(contentAt).trim();
|
|
117
|
-
sections.push(value.slice(contentAt, closeAt));
|
|
118
|
-
cursor = closeAt + EXTERNAL_ANSWER_CLOSE.length;
|
|
119
|
-
}
|
|
120
|
-
return (sections.length > 0 ? sections.join("") : value).trim();
|
|
225
|
+
return parseExternalAnswerText(value).text;
|
|
121
226
|
}
|
|
122
227
|
/**
|
|
123
228
|
* 提取 marker 内容:有 marker 返回拼接内容(trim,空→null),无 marker 返回 null。
|
|
@@ -125,25 +230,6 @@ export function stripExternalAnswerMarkers(value) {
|
|
|
125
230
|
* 本函数用于判定"agent 是否给出了频道直接回复"——必须能区分有无 marker。
|
|
126
231
|
*/
|
|
127
232
|
export function extractExternalAnswer(value) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
let rest = value;
|
|
131
|
-
for (;;) {
|
|
132
|
-
const open = rest.indexOf(EXTERNAL_ANSWER_OPEN);
|
|
133
|
-
if (open < 0)
|
|
134
|
-
break;
|
|
135
|
-
found = true;
|
|
136
|
-
const afterOpen = rest.slice(open + EXTERNAL_ANSWER_OPEN.length);
|
|
137
|
-
const close = afterOpen.indexOf(EXTERNAL_ANSWER_CLOSE);
|
|
138
|
-
if (close < 0) {
|
|
139
|
-
parts.push(afterOpen);
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
parts.push(afterOpen.slice(0, close));
|
|
143
|
-
rest = afterOpen.slice(close + EXTERNAL_ANSWER_CLOSE.length);
|
|
144
|
-
}
|
|
145
|
-
if (!found)
|
|
146
|
-
return null;
|
|
147
|
-
const answer = parts.join("").trim();
|
|
148
|
-
return answer.length > 0 ? answer : null;
|
|
233
|
+
const parsed = parseExternalAnswerText(value);
|
|
234
|
+
return parsed.kind === "marked" && parsed.text ? parsed.text : null;
|
|
149
235
|
}
|
package/dist/local-executor.js
CHANGED
|
@@ -254,7 +254,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
254
254
|
let executionWorkspace = workspace;
|
|
255
255
|
let localMemoryTelemetry = null;
|
|
256
256
|
const inheritedEnv = { ...process.env };
|
|
257
|
-
const
|
|
257
|
+
const reportRoots = {
|
|
258
|
+
root: workspace.dir,
|
|
259
|
+
...(input.launch.qaReportRoot === undefined ? {} : { qaReportRoot: input.launch.qaReportRoot }),
|
|
260
|
+
};
|
|
261
|
+
const cloudReportPreparation = await prepareCloudReportExecution({ env: inheritedEnv, ...reportRoots, executionId: input.executionId, agentHandle: input.handle });
|
|
258
262
|
const { facts: cloudReportUpload, snapshot: reportSnapshot } = cloudReportPreparation;
|
|
259
263
|
for (const key of Object.keys(inheritedEnv)) {
|
|
260
264
|
if (key.startsWith("CREW_AGENT_MEMORY_") || ["CREW_CLOUD_PROVIDER", "CREW_OSS_UPLOAD_SCRIPT", "CREW_OSS_REPORT_UPLOAD"].includes(key))
|
|
@@ -1018,7 +1022,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
1018
1022
|
}
|
|
1019
1023
|
const cloudReportResult = exitCode === 0
|
|
1020
1024
|
? await deliverCloudReports({
|
|
1021
|
-
facts: cloudReportUpload,
|
|
1025
|
+
facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText,
|
|
1022
1026
|
externalAnswer: externalOutput.lastClosedBlock
|
|
1023
1027
|
?? (finalText === null ? null : extractExternalAnswer(finalText)),
|
|
1024
1028
|
captureFinal: input.captureFinal, executionId: input.executionId, agentHandle: input.handle,
|
package/dist/main.js
CHANGED
|
@@ -24,7 +24,7 @@ import { formatDaemonStartupError } from "./daemon-startup-error.js";
|
|
|
24
24
|
import { assertManagedServiceStartup } from "./managed-service-startup.js";
|
|
25
25
|
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
26
26
|
import { acquireDaemonInstallationLease, runWithDaemonInstallationLease, } from "./daemon-installation-lease.js";
|
|
27
|
-
import { loadStorageRuntime } from "./storage-runtime.js";
|
|
27
|
+
import { createStorageRuntimeOwnershipBinding, loadStorageRuntime } from "./storage-runtime.js";
|
|
28
28
|
import { readStorageMigrationReceipt } from "./storage-migration-controller.js";
|
|
29
29
|
import { storageMigrationStatusReplay } from "./storage-status-replay.js";
|
|
30
30
|
import { runStorageWorkerCommand } from "./storage-worker-command.js";
|
|
@@ -160,6 +160,9 @@ async function main() {
|
|
|
160
160
|
...(process.getuid === undefined ? {} : { uid: process.getuid() }),
|
|
161
161
|
environment: process.env,
|
|
162
162
|
});
|
|
163
|
+
const targetStorageBinding = storageRuntime === null
|
|
164
|
+
? null
|
|
165
|
+
: createStorageRuntimeOwnershipBinding(storageRuntime);
|
|
163
166
|
const targetConfirmation = storageRuntime?.identity.storageMigrationId === undefined
|
|
164
167
|
? null
|
|
165
168
|
: createStorageTargetConfirmationHandler({
|
|
@@ -172,7 +175,8 @@ async function main() {
|
|
|
172
175
|
? {
|
|
173
176
|
ready: false,
|
|
174
177
|
statusOutboxPath: join(storageRuntime.local.stateRoot, "storage-status-outbox.json"),
|
|
175
|
-
identity:
|
|
178
|
+
identity: targetStorageBinding.identity,
|
|
179
|
+
bindServerIdentity: targetStorageBinding.bind,
|
|
176
180
|
...(targetConfirmation === null ? {} : { confirm: targetConfirmation.handle }),
|
|
177
181
|
...(storageRuntime.identity.storageMigrationId === undefined ? {} : {
|
|
178
182
|
replayStatus: async (send) => {
|
package/dist/serve.js
CHANGED
|
@@ -54,7 +54,7 @@ import { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
|
54
54
|
import { createServeMigrationController, handleMigrationControlMessage } from "./daemon-migration-wiring.js";
|
|
55
55
|
import { createCodexHomeModeController } from "./codex-home-mode-controller.js";
|
|
56
56
|
import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
|
|
57
|
-
import { attachStorageMigrationRuntime, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
|
|
57
|
+
import { attachStorageMigrationRuntime, bindStorageMigrationServerIdentity, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
|
|
58
58
|
export { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
59
59
|
// normalize.ts 的活动种类 → activity 枚举
|
|
60
60
|
const ACTIVITY_MAP = {
|
|
@@ -316,6 +316,7 @@ export function serve(config, opts = {}) {
|
|
|
316
316
|
let connectionEpochToken;
|
|
317
317
|
ws.on("open", () => {
|
|
318
318
|
connectionEpochToken = projectSkillsController.beginConnectionEpoch();
|
|
319
|
+
storageMigrationRuntime.beginIdentityBinding();
|
|
319
320
|
const openedSocket = ws;
|
|
320
321
|
backoff = 1000;
|
|
321
322
|
connectedAt = Date.now();
|
|
@@ -811,16 +812,21 @@ export function serve(config, opts = {}) {
|
|
|
811
812
|
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
812
813
|
const r = msg;
|
|
813
814
|
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
814
|
-
if (opts.storageMigration
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
}
|
|
823
|
-
|
|
815
|
+
if (opts.storageMigration !== undefined && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
|
|
816
|
+
const bindingSocket = ws;
|
|
817
|
+
bindStorageMigrationServerIdentity(opts.storageMigration, { workspaceId: r.workspaceId, machineId: r.machineId }, {
|
|
818
|
+
onBound: () => {
|
|
819
|
+
if (bindingSocket !== ws || bindingSocket?.readyState !== WebSocket.OPEN)
|
|
820
|
+
return;
|
|
821
|
+
storageMigrationRuntime.completeIdentityBinding();
|
|
822
|
+
machineHelloPublisher.republish();
|
|
823
|
+
},
|
|
824
|
+
onError: (error) => {
|
|
825
|
+
dslog("daemon.storage_identity_bind_failed", "存储迁移 Workspace 身份绑定失败", { level: "ERROR", error_message: error.message });
|
|
826
|
+
if (bindingSocket === ws && bindingSocket?.readyState === WebSocket.OPEN)
|
|
827
|
+
bindingSocket.close(1011, "storage identity binding failed");
|
|
828
|
+
},
|
|
829
|
+
});
|
|
824
830
|
}
|
|
825
831
|
// 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
|
|
826
832
|
serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
|
package/dist/storage-identity.js
CHANGED
|
@@ -4,6 +4,9 @@ import { createStorageOwnershipClaim } from "./storage-ownership.js";
|
|
|
4
4
|
function fingerprint(namespace, value) {
|
|
5
5
|
return `sha256:${createHash("sha256").update(`${namespace}\0${value}`).digest("hex")}`;
|
|
6
6
|
}
|
|
7
|
+
export function storageWorkspaceRootFingerprint(workspaceRoot) {
|
|
8
|
+
return fingerprint("workspace-root", workspaceRoot);
|
|
9
|
+
}
|
|
7
10
|
export function storageIdFingerprint(storageId) {
|
|
8
11
|
return fingerprint("storage", storageId);
|
|
9
12
|
}
|
|
@@ -15,7 +18,7 @@ export function storageHelloIdentity(input) {
|
|
|
15
18
|
|| installation.runtimeRoot !== layout.runtimeRoot) {
|
|
16
19
|
throw new Error("installation storage identity does not match resolved layout");
|
|
17
20
|
}
|
|
18
|
-
const workspaceRootFingerprint =
|
|
21
|
+
const workspaceRootFingerprint = storageWorkspaceRootFingerprint(layout.workspaceRoot);
|
|
19
22
|
let ownership;
|
|
20
23
|
if (input.ownership !== undefined) {
|
|
21
24
|
const { claimFingerprint: reportedFingerprint, ...claimIdentity } = input.ownership;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { atomicRenameNoReplace } from "./atomic-no-replace-rename.js";
|
|
6
|
+
import { durableDirectorySync } from "./atomic-private-write.js";
|
|
5
7
|
const FingerprintSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
|
|
8
|
+
const UuidSchema = z.string().uuid();
|
|
6
9
|
const StorageOwnershipClaimSchema = z.object({
|
|
7
10
|
version: z.literal(1),
|
|
8
11
|
machineId: z.string().uuid(),
|
|
@@ -38,8 +41,10 @@ export function createStorageOwnershipClaim(input) {
|
|
|
38
41
|
export function storageOwnershipClaimPath(nowworkHome) {
|
|
39
42
|
return join(nowworkHome, "storage-layout.json");
|
|
40
43
|
}
|
|
41
|
-
export
|
|
42
|
-
|
|
44
|
+
export function workspaceStorageOwnershipClaimPath(nowworkHome, workspaceId) {
|
|
45
|
+
return join(nowworkHome, ".storage-ownership", `${UuidSchema.parse(workspaceId).toLowerCase()}.json`);
|
|
46
|
+
}
|
|
47
|
+
async function readClaim(path) {
|
|
43
48
|
const metadata = await lstat(path);
|
|
44
49
|
if (!metadata.isFile())
|
|
45
50
|
throw new Error("storage ownership claim must be a file");
|
|
@@ -53,3 +58,86 @@ export async function readStorageOwnershipClaim(nowworkHome) {
|
|
|
53
58
|
}
|
|
54
59
|
return expected;
|
|
55
60
|
}
|
|
61
|
+
function missingClaim(message) {
|
|
62
|
+
return Object.assign(new Error(message), { code: "ENOENT" });
|
|
63
|
+
}
|
|
64
|
+
export async function readStorageOwnershipClaim(nowworkHome, workspaceId) {
|
|
65
|
+
if (workspaceId !== undefined) {
|
|
66
|
+
const normalizedWorkspaceId = UuidSchema.parse(workspaceId).toLowerCase();
|
|
67
|
+
try {
|
|
68
|
+
return await readClaim(workspaceStorageOwnershipClaimPath(nowworkHome, normalizedWorkspaceId));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== "ENOENT")
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
const legacy = await readClaim(storageOwnershipClaimPath(nowworkHome));
|
|
75
|
+
if (legacy.workspaceId !== normalizedWorkspaceId) {
|
|
76
|
+
throw missingClaim(`storage ownership claim is not provisioned for workspace '${normalizedWorkspaceId}'`);
|
|
77
|
+
}
|
|
78
|
+
return legacy;
|
|
79
|
+
}
|
|
80
|
+
return readClaim(storageOwnershipClaimPath(nowworkHome));
|
|
81
|
+
}
|
|
82
|
+
export async function resolveWorkspaceStorageOwnershipClaim(input) {
|
|
83
|
+
const machineId = UuidSchema.parse(input.machineId).toLowerCase();
|
|
84
|
+
const workspaceId = UuidSchema.parse(input.workspaceId).toLowerCase();
|
|
85
|
+
try {
|
|
86
|
+
const existing = await readStorageOwnershipClaim(input.nowworkHome, workspaceId);
|
|
87
|
+
const expected = createStorageOwnershipClaim({
|
|
88
|
+
machineId,
|
|
89
|
+
workspaceId,
|
|
90
|
+
environment: input.environment,
|
|
91
|
+
volumeFingerprint: existing.volumeFingerprint,
|
|
92
|
+
workspaceRootFingerprint: input.workspaceRootFingerprint,
|
|
93
|
+
ownerEpoch: existing.ownerEpoch,
|
|
94
|
+
});
|
|
95
|
+
if (existing.claimFingerprint !== expected.claimFingerprint) {
|
|
96
|
+
throw new Error("storage ownership claim conflicts with the authenticated workspace identity");
|
|
97
|
+
}
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code !== "ENOENT")
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const anchor = await readClaim(storageOwnershipClaimPath(input.nowworkHome));
|
|
105
|
+
if (anchor.environment !== input.environment.toLowerCase()) {
|
|
106
|
+
throw new Error("storage ownership volume anchor environment does not match");
|
|
107
|
+
}
|
|
108
|
+
const claim = createStorageOwnershipClaim({
|
|
109
|
+
machineId,
|
|
110
|
+
workspaceId,
|
|
111
|
+
environment: input.environment,
|
|
112
|
+
volumeFingerprint: anchor.volumeFingerprint,
|
|
113
|
+
workspaceRootFingerprint: input.workspaceRootFingerprint,
|
|
114
|
+
ownerEpoch: 1,
|
|
115
|
+
});
|
|
116
|
+
const path = workspaceStorageOwnershipClaimPath(input.nowworkHome, workspaceId);
|
|
117
|
+
const directory = dirname(path);
|
|
118
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
119
|
+
await chmod(directory, 0o700);
|
|
120
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
121
|
+
let handle = null;
|
|
122
|
+
try {
|
|
123
|
+
handle = await open(temporary, "wx", 0o600);
|
|
124
|
+
await handle.writeFile(`${JSON.stringify({ version: 1, ...claim })}\n`, "utf8");
|
|
125
|
+
await handle.sync();
|
|
126
|
+
await handle.close();
|
|
127
|
+
handle = null;
|
|
128
|
+
const published = await atomicRenameNoReplace(temporary, path);
|
|
129
|
+
if (published === "published") {
|
|
130
|
+
await durableDirectorySync(directory);
|
|
131
|
+
return claim;
|
|
132
|
+
}
|
|
133
|
+
const raced = await readClaim(path);
|
|
134
|
+
if (raced.claimFingerprint !== claim.claimFingerprint) {
|
|
135
|
+
throw new Error("storage ownership claim was concurrently provisioned for another identity");
|
|
136
|
+
}
|
|
137
|
+
return raced;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await handle?.close().catch(() => undefined);
|
|
141
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -5,12 +5,13 @@ import { buildServiceSpec } from "./computer-service.js";
|
|
|
5
5
|
import { buildManagedServiceRecord } from "./managed-service-lifecycle.js";
|
|
6
6
|
import { createStorageControlController } from "./storage-control.js";
|
|
7
7
|
import { runStorageDoctor } from "./storage-doctor.js";
|
|
8
|
-
import { storageHelloIdentity } from "./storage-identity.js";
|
|
8
|
+
import { storageHelloIdentity, storageWorkspaceRootFingerprint, } from "./storage-identity.js";
|
|
9
9
|
import { storageLayout, validateStorageEnvironment } from "./storage-layout.js";
|
|
10
10
|
import { StorageMigrationFailure } from "./storage-migration-controller.js";
|
|
11
11
|
import { launchStorageMigrationWorker, writeStorageWorkerHandoff } from "./storage-worker-handoff.js";
|
|
12
12
|
import { writeStorageWorkerOperation } from "./storage-worker-operation.js";
|
|
13
13
|
import { latestSourceStorageMigrationReceipt, monitorStorageMigrationStatus, storageMigrationStatusReplay, } from "./storage-status-replay.js";
|
|
14
|
+
import { resolveWorkspaceStorageOwnershipClaim, } from "./storage-ownership.js";
|
|
14
15
|
const UUID = z.string().uuid();
|
|
15
16
|
function fingerprint(value) {
|
|
16
17
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
@@ -22,13 +23,13 @@ function deterministicUuid(value) {
|
|
|
22
23
|
const hex = chars.join("");
|
|
23
24
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
24
25
|
}
|
|
25
|
-
function storageId(input, workspaceId) {
|
|
26
|
-
if (
|
|
26
|
+
function storageId(input, workspaceId, ownership) {
|
|
27
|
+
if (ownership.workspaceId !== workspaceId || ownership.environment !== input.environment) {
|
|
27
28
|
throw new Error("Storage ownership claim does not match the migration identity");
|
|
28
29
|
}
|
|
29
|
-
return `storage_${
|
|
30
|
+
return `storage_${ownership.claimFingerprint.slice("sha256:".length)}`;
|
|
30
31
|
}
|
|
31
|
-
function installationBase(input, workspaceId, migrationId, createdAt) {
|
|
32
|
+
function installationBase(input, workspaceId, migrationId, createdAt, ownership) {
|
|
32
33
|
const layout = storageLayout({
|
|
33
34
|
nowworkHome: input.nowworkHome,
|
|
34
35
|
environment: input.environment,
|
|
@@ -43,7 +44,7 @@ function installationBase(input, workspaceId, migrationId, createdAt) {
|
|
|
43
44
|
serverUrl: input.serverUrl,
|
|
44
45
|
environment: input.environment,
|
|
45
46
|
workspaceId,
|
|
46
|
-
storageId: storageId(input, workspaceId),
|
|
47
|
+
storageId: storageId(input, workspaceId, ownership),
|
|
47
48
|
nowworkHome: input.nowworkHome,
|
|
48
49
|
runtimeRoot: layout.runtimeRoot,
|
|
49
50
|
credentialRef: `profile:${input.profile}`,
|
|
@@ -99,6 +100,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
99
100
|
writeHandoff: writeStorageWorkerHandoff,
|
|
100
101
|
launchWorker: launchStorageMigrationWorker,
|
|
101
102
|
monitorWorker: monitorStorageMigrationStatus,
|
|
103
|
+
resolveOwnership: resolveWorkspaceStorageOwnershipClaim,
|
|
102
104
|
now: () => new Date(),
|
|
103
105
|
...injected,
|
|
104
106
|
};
|
|
@@ -108,6 +110,8 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
108
110
|
let gate = null;
|
|
109
111
|
let sendStatus = null;
|
|
110
112
|
let control = null;
|
|
113
|
+
let ownership = input.ownership;
|
|
114
|
+
let identityBindTail = Promise.resolve();
|
|
111
115
|
const pendingControlMessages = [];
|
|
112
116
|
let drainingPendingControlMessages = false;
|
|
113
117
|
const drainPendingControlMessages = () => {
|
|
@@ -197,8 +201,8 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
197
201
|
backupsRoot: input.local.backupsRoot,
|
|
198
202
|
backupArtifacts: input.backupArtifacts.map((artifact) => ({ ...artifact })),
|
|
199
203
|
ownership: {
|
|
200
|
-
ownerEpoch:
|
|
201
|
-
ownershipClaimFingerprint:
|
|
204
|
+
ownerEpoch: ownership.ownerEpoch,
|
|
205
|
+
ownershipClaimFingerprint: ownership.claimFingerprint,
|
|
202
206
|
},
|
|
203
207
|
acceptedWarnings: [...input.policy.acceptedWarnings],
|
|
204
208
|
},
|
|
@@ -214,7 +218,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
214
218
|
installation: {
|
|
215
219
|
configRoot: input.local.configRoot,
|
|
216
220
|
target: {
|
|
217
|
-
...installationBase(input, workspaceId, doctor.manifest.migrationId, createdAt),
|
|
221
|
+
...installationBase(input, workspaceId, doctor.manifest.migrationId, createdAt, ownership),
|
|
218
222
|
storageMigrationId: doctor.manifest.migrationId,
|
|
219
223
|
},
|
|
220
224
|
},
|
|
@@ -236,8 +240,8 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
236
240
|
sourceStorageFingerprint,
|
|
237
241
|
targetStorageFingerprint: identity.storageIdFingerprint,
|
|
238
242
|
ownership: {
|
|
239
|
-
ownerEpoch:
|
|
240
|
-
ownershipClaimFingerprint:
|
|
243
|
+
ownerEpoch: ownership.ownerEpoch,
|
|
244
|
+
ownershipClaimFingerprint: ownership.claimFingerprint,
|
|
241
245
|
},
|
|
242
246
|
runDoctor: async (migrationId) => dependencies.runDoctor(await doctorInput(migrationId)),
|
|
243
247
|
runMigration: async (doctor) => {
|
|
@@ -294,7 +298,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
294
298
|
sendStatus,
|
|
295
299
|
});
|
|
296
300
|
};
|
|
297
|
-
const
|
|
301
|
+
const bindServerIdentityOnce = async (server) => {
|
|
298
302
|
const nextWorkspaceId = UUID.parse(server.workspaceId).toLowerCase();
|
|
299
303
|
const nextMachineId = UUID.parse(server.machineId).toLowerCase();
|
|
300
304
|
if (workspaceId !== null && workspaceId !== nextWorkspaceId) {
|
|
@@ -303,13 +307,6 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
303
307
|
if (machineId !== null && machineId !== nextMachineId) {
|
|
304
308
|
throw new Error("Storage migration server machine identity changed");
|
|
305
309
|
}
|
|
306
|
-
if (input.ownership.workspaceId !== nextWorkspaceId
|
|
307
|
-
|| input.ownership.machineId !== nextMachineId
|
|
308
|
-
|| input.ownership.environment !== input.environment) {
|
|
309
|
-
throw new Error("Storage ownership claim does not match authenticated server identity");
|
|
310
|
-
}
|
|
311
|
-
const createdAt = dependencies.now().toISOString();
|
|
312
|
-
const installation = installationBase(input, nextWorkspaceId, undefined, createdAt);
|
|
313
310
|
const layout = storageLayout({
|
|
314
311
|
nowworkHome: input.nowworkHome,
|
|
315
312
|
environment: input.environment,
|
|
@@ -317,13 +314,36 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
317
314
|
runtimeBase: input.local.runtimeBase,
|
|
318
315
|
platform: input.platform,
|
|
319
316
|
});
|
|
320
|
-
|
|
317
|
+
if (ownership.workspaceId !== nextWorkspaceId
|
|
318
|
+
|| ownership.machineId !== nextMachineId
|
|
319
|
+
|| ownership.environment !== input.environment) {
|
|
320
|
+
ownership = await dependencies.resolveOwnership({
|
|
321
|
+
nowworkHome: input.nowworkHome,
|
|
322
|
+
machineId: nextMachineId,
|
|
323
|
+
workspaceId: nextWorkspaceId,
|
|
324
|
+
environment: input.environment,
|
|
325
|
+
workspaceRootFingerprint: storageWorkspaceRootFingerprint(layout.workspaceRoot),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (ownership.workspaceId !== nextWorkspaceId
|
|
329
|
+
|| ownership.machineId !== nextMachineId
|
|
330
|
+
|| ownership.environment !== input.environment) {
|
|
331
|
+
throw new Error("Storage ownership claim does not match authenticated server identity");
|
|
332
|
+
}
|
|
333
|
+
const createdAt = dependencies.now().toISOString();
|
|
334
|
+
const installation = installationBase(input, nextWorkspaceId, undefined, createdAt, ownership);
|
|
335
|
+
const nextIdentity = storageHelloIdentity({ installation, layout, ownership });
|
|
321
336
|
workspaceId = nextWorkspaceId;
|
|
322
337
|
machineId = nextMachineId;
|
|
323
338
|
identity = nextIdentity;
|
|
324
339
|
ensureControl();
|
|
325
340
|
drainPendingControlMessages();
|
|
326
341
|
};
|
|
342
|
+
const bindServerIdentity = (server) => {
|
|
343
|
+
const current = identityBindTail.then(() => bindServerIdentityOnce(server));
|
|
344
|
+
identityBindTail = current.catch(() => undefined);
|
|
345
|
+
return current;
|
|
346
|
+
};
|
|
327
347
|
const attach = (runtime) => {
|
|
328
348
|
gate = runtime.gate;
|
|
329
349
|
sendStatus = runtime.sendStatus;
|
package/dist/storage-runtime.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { findInstallationForProfile, readInstallations, resolveLocalStorageRoots, } from "./storage-config.js";
|
|
2
2
|
import { storageHelloIdentity } from "./storage-identity.js";
|
|
3
3
|
import { storageLayout } from "./storage-layout.js";
|
|
4
|
-
import { readStorageOwnershipClaim } from "./storage-ownership.js";
|
|
4
|
+
import { readStorageOwnershipClaim, resolveWorkspaceStorageOwnershipClaim, } from "./storage-ownership.js";
|
|
5
5
|
const canonicalServerUrl = (value) => value.replace(/\/+$/u, "");
|
|
6
6
|
export function resolveStorageRuntime(input) {
|
|
7
7
|
const installation = findInstallationForProfile(input.records, input.profile);
|
|
@@ -45,7 +45,7 @@ export async function loadStorageRuntime(input) {
|
|
|
45
45
|
return null;
|
|
46
46
|
let ownership;
|
|
47
47
|
try {
|
|
48
|
-
ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot);
|
|
48
|
+
ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot, baseline.layout.workspaceId);
|
|
49
49
|
}
|
|
50
50
|
catch (error) {
|
|
51
51
|
if (error.code !== "ENOENT")
|
|
@@ -53,3 +53,39 @@ export async function loadStorageRuntime(input) {
|
|
|
53
53
|
}
|
|
54
54
|
return ownership === undefined ? baseline : resolveStorageRuntime({ ...input, records, ownership });
|
|
55
55
|
}
|
|
56
|
+
export async function bindStorageRuntimeOwnership(runtime, server) {
|
|
57
|
+
const workspaceId = server.workspaceId.toLowerCase();
|
|
58
|
+
const machineId = server.machineId.toLowerCase();
|
|
59
|
+
if (runtime.installation.workspaceId !== workspaceId) {
|
|
60
|
+
throw new Error("Storage runtime server workspace identity changed");
|
|
61
|
+
}
|
|
62
|
+
const existing = runtime.identity.storageOwnership;
|
|
63
|
+
if (existing !== undefined) {
|
|
64
|
+
if (existing.machineId !== machineId)
|
|
65
|
+
throw new Error("Storage runtime server machine identity changed");
|
|
66
|
+
return runtime;
|
|
67
|
+
}
|
|
68
|
+
const ownership = await resolveWorkspaceStorageOwnershipClaim({
|
|
69
|
+
nowworkHome: runtime.layout.persistentRoot,
|
|
70
|
+
machineId,
|
|
71
|
+
workspaceId,
|
|
72
|
+
environment: runtime.installation.environment,
|
|
73
|
+
workspaceRootFingerprint: runtime.identity.workspaceRootFingerprint,
|
|
74
|
+
});
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
...runtime,
|
|
77
|
+
identity: storageHelloIdentity({ installation: runtime.installation, layout: runtime.layout, ownership }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function createStorageRuntimeOwnershipBinding(initial) {
|
|
81
|
+
let runtime = initial;
|
|
82
|
+
let bindTail = Promise.resolve();
|
|
83
|
+
const bind = (server) => {
|
|
84
|
+
const current = bindTail.then(async () => {
|
|
85
|
+
runtime = await bindStorageRuntimeOwnership(runtime, server);
|
|
86
|
+
});
|
|
87
|
+
bindTail = current.catch(() => undefined);
|
|
88
|
+
return current;
|
|
89
|
+
};
|
|
90
|
+
return Object.freeze({ identity: () => runtime.identity, bind });
|
|
91
|
+
}
|
|
@@ -9,6 +9,15 @@ export function storageMigrationCapabilities(configured, options) {
|
|
|
9
9
|
...(options.ready ? [STORAGE_MIGRATION_CAPABILITY] : []),
|
|
10
10
|
])]);
|
|
11
11
|
}
|
|
12
|
+
export function bindStorageMigrationServerIdentity(options, identity, callbacks) {
|
|
13
|
+
const bind = options?.bindServerIdentity;
|
|
14
|
+
if (bind === undefined) {
|
|
15
|
+
if (options?.ready === true)
|
|
16
|
+
callbacks.onBound();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
void Promise.resolve().then(() => bind(identity)).then(callbacks.onBound).catch(callbacks.onError);
|
|
20
|
+
}
|
|
12
21
|
export function createServeStorageStatusDelivery(options, dependencies) {
|
|
13
22
|
return createStorageStatusDelivery({
|
|
14
23
|
...(options?.statusOutboxPath === undefined ? {} : { outboxPath: options.statusOutboxPath }),
|
|
@@ -17,28 +26,36 @@ export function createServeStorageStatusDelivery(options, dependencies) {
|
|
|
17
26
|
});
|
|
18
27
|
}
|
|
19
28
|
export function attachStorageMigrationRuntime(options, dependencies) {
|
|
20
|
-
let
|
|
29
|
+
let migrationAdmissionClosed = false;
|
|
30
|
+
let identityAdmissionClosed = options?.bindServerIdentity !== undefined;
|
|
21
31
|
if (options?.ready === true) {
|
|
22
32
|
options.attach?.({
|
|
23
33
|
gate: {
|
|
24
34
|
isBusy: dependencies.isBusy,
|
|
25
35
|
closeAndFlush: async () => {
|
|
26
|
-
|
|
36
|
+
migrationAdmissionClosed = true;
|
|
27
37
|
await dependencies.waitForExecutionFrames();
|
|
28
38
|
if (dependencies.isBusy()) {
|
|
29
|
-
|
|
39
|
+
migrationAdmissionClosed = false;
|
|
30
40
|
throw Object.assign(new Error("active execution entered before storage migration quiesce"), {
|
|
31
41
|
code: "runtime_busy",
|
|
32
42
|
});
|
|
33
43
|
}
|
|
34
44
|
await dependencies.flushLocalState();
|
|
35
45
|
},
|
|
36
|
-
reopen: () => {
|
|
46
|
+
reopen: () => { migrationAdmissionClosed = false; },
|
|
37
47
|
},
|
|
38
48
|
sendStatus: dependencies.sendStatus,
|
|
39
49
|
});
|
|
40
50
|
}
|
|
41
|
-
return Object.freeze({
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
isAdmissionClosed: () => migrationAdmissionClosed || identityAdmissionClosed,
|
|
53
|
+
beginIdentityBinding: () => {
|
|
54
|
+
if (options?.bindServerIdentity !== undefined)
|
|
55
|
+
identityAdmissionClosed = true;
|
|
56
|
+
},
|
|
57
|
+
completeIdentityBinding: () => { identityAdmissionClosed = false; },
|
|
58
|
+
});
|
|
42
59
|
}
|
|
43
60
|
export function replayStorageMigrationStatus(options, socket, isCurrent, onError, deliver) {
|
|
44
61
|
return options?.replayStatus?.((frame) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowcrew/daemon",
|
|
3
|
-
"version": "0.6.87-beta.
|
|
3
|
+
"version": "0.6.87-beta.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -16,14 +16,6 @@
|
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
|
|
21
|
-
"build": "tsc -p tsconfig.json",
|
|
22
|
-
"codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
|
|
23
|
-
"prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
|
|
24
|
-
"test": "vitest run",
|
|
25
|
-
"typecheck": "tsc --noEmit"
|
|
26
|
-
},
|
|
27
19
|
"dependencies": {
|
|
28
20
|
"@agentclientprotocol/sdk": "1.2.1",
|
|
29
21
|
"@nowcrew/cli": "0.4.13",
|
|
@@ -44,5 +36,12 @@
|
|
|
44
36
|
"tsx": "^4.19.0",
|
|
45
37
|
"typescript": "^5.6.0",
|
|
46
38
|
"vitest": "^2.1.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
|
|
42
|
+
"build": "tsc -p tsconfig.json",
|
|
43
|
+
"codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
47
46
|
}
|
|
48
|
-
}
|
|
47
|
+
}
|