@nowcrew/daemon 0.6.87-beta.3 → 0.6.87

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.
Files changed (28) hide show
  1. package/dist/external-output.js +154 -68
  2. package/dist/main.js +6 -2
  3. package/dist/serve.js +17 -11
  4. package/dist/storage-identity.js +4 -1
  5. package/dist/storage-ownership.js +93 -5
  6. package/dist/storage-runtime-controller.js +40 -20
  7. package/dist/storage-runtime.js +38 -2
  8. package/dist/storage-serve-adapter.js +22 -5
  9. package/package.json +9 -10
  10. package/dist/project-skills/capability.js +0 -109
  11. package/dist/project-skills/controller-convergence.js +0 -57
  12. package/dist/project-skills/projection-set-switch.js +0 -419
  13. package/dist/project-skills/projection-state-domain.js +0 -170
  14. package/dist/project-skills/projection-state-store.js +0 -842
  15. package/dist/project-skills/projection-state-transaction.js +0 -322
  16. package/dist/project-skills/projection-state.js +0 -3
  17. package/dist/project-skills/runtime-root-bootstrap.js +0 -47
  18. package/dist/project-skills/runtime-root-domain.js +0 -268
  19. package/dist/project-skills/runtime-root-gc.js +0 -293
  20. package/dist/project-skills/runtime-root-lease-artifact.js +0 -46
  21. package/dist/project-skills/runtime-root-leases.js +0 -487
  22. package/dist/project-skills/runtime-root-source-identity.js +0 -60
  23. package/dist/project-skills/runtime-root-startup.js +0 -49
  24. package/dist/project-skills/runtime-root-state-artifact-domain.js +0 -143
  25. package/dist/project-skills/runtime-root-state-index.js +0 -356
  26. package/dist/project-skills/runtime-root-store.js +0 -722
  27. package/dist/project-skills/serve-capability.js +0 -28
  28. package/dist/project-skills/serve-startup.js +0 -37
@@ -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
- function retainedMarkerPrefix(value, marker) {
4
- const maximum = Math.min(value.length, marker.length - 1);
5
- for (let length = maximum; length > 0; length -= 1) {
6
- if (marker.startsWith(value.slice(-length)))
7
- return length;
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
- return 0;
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 的契约标签流式解码器:push() 逐段返回标签内文本(供 answerStream 实时转发);
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 marker = this.state === "outside" ? EXTERNAL_ANSWER_OPEN : EXTERNAL_ANSWER_CLOSE;
44
- const markerAt = value.indexOf(marker);
45
- if (markerAt >= 0) {
46
- if (this.state === "inside") {
47
- const head = value.slice(0, markerAt);
48
- if (head)
49
- output.push(head);
50
- const completed = `${this.block}${head}`.trim();
51
- if (completed)
52
- this.closed = completed;
53
- this.block = "";
54
- this.state = "outside";
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.block = "";
58
- this.state = "inside";
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
- const sections = [];
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 parts = [];
129
- let found = false;
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/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: storageRuntime.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?.ready === true && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
815
- try {
816
- opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId, machineId: r.machineId });
817
- machineHelloPublisher.republish();
818
- }
819
- catch (error) {
820
- dslog("daemon.storage_identity_bind_failed", "存储迁移 Workspace 身份绑定失败", {
821
- level: "ERROR", error_message: error.message,
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)
@@ -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 = fingerprint("workspace-root", layout.workspaceRoot);
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 async function readStorageOwnershipClaim(nowworkHome) {
42
- const path = storageOwnershipClaimPath(nowworkHome);
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 (input.ownership.workspaceId !== workspaceId || input.ownership.environment !== input.environment) {
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_${input.ownership.claimFingerprint.slice("sha256:".length)}`;
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: input.ownership.ownerEpoch,
201
- ownershipClaimFingerprint: input.ownership.claimFingerprint,
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: input.ownership.ownerEpoch,
240
- ownershipClaimFingerprint: input.ownership.claimFingerprint,
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 bindServerIdentity = (server) => {
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
- const nextIdentity = storageHelloIdentity({ installation, layout, ownership: input.ownership });
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;