@nowcrew/daemon 0.6.77-beta.7 → 0.6.77-beta.8

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/README.md CHANGED
@@ -479,6 +479,26 @@ failure matrix, rollout limits, and the server-native follow-up design.
479
479
 
480
480
  ## Code Map
481
481
 
482
+ ## Local daemon installation root (experimental)
483
+
484
+ New installations use `~/.nowwork` for machine-local daemon configuration and npm runtimes. The
485
+ Workspace persistence root is still selected independently by `NOWWORK_HOME`; it may be a local
486
+ directory or an ECS-specific NAS directory. Runtime packages, locks, sockets, PIDs and temporary
487
+ files must remain on the machine running the daemon.
488
+
489
+ The selector is controlled by `NOWWORK_LOCAL_HOME_MODE`:
490
+
491
+ - `auto` (default): use an existing `.nowwork` installation, otherwise preserve an existing `.crew`
492
+ installation; a brand-new profile is created under `.nowwork`.
493
+ - `nowwork`: require the `.nowwork` installation root.
494
+ - `legacy`: force the old `.crew` root for rollback.
495
+
496
+ `NOWWORK_STORAGE_MODE` is accepted as a compatibility alias. `NOWWORK_LOCAL_HOME` can override the
497
+ local root and should point to a local filesystem, never to the NAS Workspace root; the mount/device
498
+ check remains a doctor/migration gate. If the same profile
499
+ is found in both roots, startup/install is refused until the duplicate is resolved explicitly. The
500
+ old `.crew` tree is not deleted by this compatibility layer.
501
+
482
502
  - `src/serve.ts`: connection, negotiation, routing, sync, and legacy boundary.
483
503
  - `src/daemon-update-eligibility.ts`, `src/daemon-updater.ts`, and `src/daemon-update-controller.ts`:
484
504
  managed-update eligibility, exact npm replacement, host-wide admission barrier, and restart handoff.
@@ -39,6 +39,7 @@ function defaultDependencies() {
39
39
  platform,
40
40
  userHome,
41
41
  uid: process.getuid?.(),
42
+ environment: process.env,
42
43
  nodePath: process.execPath,
43
44
  version: daemonVersion(),
44
45
  ...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
@@ -231,6 +232,10 @@ function recoveryError(primary, failures) {
231
232
  }
232
233
  export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
233
234
  const machineToken = MachineTokenSchema.parse(input.machineToken);
235
+ const requestedLocalHome = input.requestedLocalHome ?? dependencies.environment.NOWWORK_LOCAL_HOME;
236
+ const requestedLocalHomeMode = input.requestedLocalHomeMode
237
+ ?? dependencies.environment.NOWWORK_LOCAL_HOME_MODE
238
+ ?? dependencies.environment.NOWWORK_STORAGE_MODE;
234
239
  const target = resolveAutomaticInstallTarget({
235
240
  serverUrl: input.serverUrl,
236
241
  ...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
@@ -238,6 +243,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
238
243
  ...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
239
244
  userHome: dependencies.userHome,
240
245
  platform: dependencies.platform,
246
+ ...(requestedLocalHome === undefined ? {} : { requestedLocalHome }),
247
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
241
248
  });
242
249
  if (!isDaemonReleaseVersion(dependencies.version)) {
243
250
  throw new Error(`Invoking daemon package has an invalid release version '${dependencies.version}'`);
@@ -1,6 +1,7 @@
1
1
  import { posix, win32 } from "node:path";
2
2
  import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
3
3
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
4
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
4
5
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
5
6
  const KNOWN_TARGETS = {
6
7
  "nowwork-in.nowcoder.com": {
@@ -72,13 +73,21 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
72
73
  const legacyProfile = environment === "production" ? "in" : environment;
73
74
  assertReleaseTagForServerUrl(input.serverUrl, input.requestedReleaseTag);
74
75
  if (requestedProfile === undefined || requestedProfile === legacyProfile) {
76
+ const local = resolveDaemonLocalHome({
77
+ userHome: input.userHome,
78
+ platform: input.platform,
79
+ profile: legacyProfile,
80
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
81
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
82
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
83
+ });
75
84
  return Object.freeze({
76
85
  kind: "known",
77
86
  profile: legacyProfile,
78
87
  serverUrl: normalizedServerUrl(parsed),
79
- daemonHome: paths.join(input.userHome, ".crew", environment === "production" ? "daemon" : "daemon-dev"),
80
- agentsRoot: paths.join(input.userHome, ".crew", environment === "production" ? "agents" : "agents-dev"),
81
- npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
88
+ daemonHome: local.daemonHome,
89
+ agentsRoot: paths.join(local.root, environment === "production" ? "agents" : "agents-dev"),
90
+ npmPrefix: local.runtimePrefix,
82
91
  releaseTag: environment === "dev" ? "beta" : "latest",
83
92
  });
84
93
  }
@@ -99,15 +108,23 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
99
108
  userHome: input.userHome,
100
109
  platform: input.platform,
101
110
  });
111
+ const local = resolveDaemonLocalHome({
112
+ userHome: input.userHome,
113
+ platform: input.platform,
114
+ profile: layout.profile,
115
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
116
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
117
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
118
+ });
102
119
  return Object.freeze({
103
120
  kind: "known",
104
121
  environment,
105
122
  localInstanceName,
106
123
  profile: layout.profile,
107
124
  serverUrl: normalizedServerUrl(parsed),
108
- daemonHome: layout.daemonHome,
109
- agentsRoot: layout.agentsRoot,
110
- npmPrefix: layout.npmPrefix,
125
+ daemonHome: local.daemonHome,
126
+ agentsRoot: paths.join(local.root, "agents-" + layout.environment + "-" + layout.localInstanceName),
127
+ npmPrefix: local.runtimePrefix,
111
128
  releaseTag: environment === "dev" ? "beta" : "latest",
112
129
  });
113
130
  }
@@ -127,13 +144,21 @@ export function resolveAutomaticInstallTarget(input) {
127
144
  return registryUrl === undefined ? target : Object.freeze({ ...target, registryUrl });
128
145
  }
129
146
  const profile = requireCustomProfile(hostname, input.requestedProfile);
147
+ const local = resolveDaemonLocalHome({
148
+ userHome: input.userHome,
149
+ platform: input.platform,
150
+ profile,
151
+ ...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
152
+ ...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
153
+ ...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
154
+ });
130
155
  return Object.freeze({
131
156
  kind: "custom",
132
157
  profile,
133
158
  serverUrl: normalizedServerUrl(parsed),
134
- daemonHome: paths.join(input.userHome, ".crew", `daemon-${profile}`),
135
- agentsRoot: paths.join(input.userHome, ".crew", `agents-${profile}`),
136
- npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
159
+ daemonHome: local.daemonHome,
160
+ agentsRoot: paths.join(local.root, `agents-${profile}`),
161
+ npmPrefix: local.runtimePrefix,
137
162
  releaseTag: input.requestedReleaseTag ?? "latest",
138
163
  ...(registryUrl === undefined ? {} : { registryUrl }),
139
164
  });
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { homedir } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { posix, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
6
6
  import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, readServiceDescriptor, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
@@ -18,6 +18,7 @@ import { assertManagedServiceIdentity, buildManagedServiceRecord, installManaged
18
18
  import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
19
19
  import { readManagedServiceRegistry } from "./managed-service-registry.js";
20
20
  import { installDaemonAutomatically, } from "./automatic-daemon-installer.js";
21
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
21
22
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
22
23
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
23
24
  return moduleUrl.endsWith(".js")
@@ -67,6 +68,7 @@ function usage(td) {
67
68
  " crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
68
69
  " crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
69
70
  " crew-daemon upgrade [--profile <name>] [--release-tag <latest|beta>] [--registry <https-url>]",
71
+ " NOWWORK_LOCAL_HOME_MODE=auto|nowwork|legacy NOWWORK_LOCAL_HOME=~/.nowwork",
70
72
  "",
71
73
  td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
72
74
  ].join("\n") + "\n";
@@ -154,6 +156,7 @@ export async function runComputerCommand(argv, overrides = {}) {
154
156
  ?? deps.env.CREW_MACHINE_TOKEN)?.trim();
155
157
  if (!machineToken)
156
158
  throw new Error(td("Missing machine token for automatic installation"));
159
+ const requestedLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
157
160
  const result = await deps.installAutomatically({
158
161
  serverUrl: parsed.values["server-url"],
159
162
  machineToken,
@@ -162,11 +165,28 @@ export async function runComputerCommand(argv, overrides = {}) {
162
165
  ...(configuredRegistryUrl === undefined
163
166
  ? {}
164
167
  : { requestedRegistryUrl: configuredRegistryUrl }),
168
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined
169
+ ? {}
170
+ : { requestedLocalHome: deps.env.NOWWORK_LOCAL_HOME }),
171
+ ...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
165
172
  });
166
173
  deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
167
174
  return 0;
168
175
  }
169
- const home = daemonHome(deps.env);
176
+ const resolvedProfile = profileName ?? positionalName;
177
+ const configuredLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
178
+ const home = resolvedProfile !== undefined
179
+ && (deps.env.NOWWORK_LOCAL_HOME !== undefined
180
+ || deps.env.NOWWORK_LOCAL_HOME_MODE !== undefined
181
+ || deps.env.NOWWORK_STORAGE_MODE !== undefined)
182
+ ? resolveDaemonLocalHome({
183
+ userHome: deps.userHome,
184
+ platform: deps.platform,
185
+ profile: resolvedProfile,
186
+ ...(deps.env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: deps.env.NOWWORK_LOCAL_HOME }),
187
+ ...(configuredLocalHomeMode === undefined ? {} : { mode: configuredLocalHomeMode }),
188
+ }).daemonHome
189
+ : daemonHome(deps.env);
170
190
  const profileStorage = {
171
191
  platform: deps.platform,
172
192
  userHome: deps.userHome,
@@ -221,7 +241,9 @@ export async function runComputerCommand(argv, overrides = {}) {
221
241
  }),
222
242
  ...(parsed.values["agents-root"]
223
243
  ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
224
- : {}),
244
+ : (deps.env.NOWWORK_LOCAL_HOME !== undefined || configuredLocalHomeMode !== undefined)
245
+ ? { agentsRoot: resolveAgentsRoot((deps.platform === "win32" ? win32 : posix).join((deps.platform === "win32" ? win32 : posix).dirname(home), `agents-${name}`), deps.userHome, deps.platform) }
246
+ : {}),
225
247
  ...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
226
248
  };
227
249
  await saveProfile(profile, home, profileStorage);
@@ -9,6 +9,7 @@ import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
9
9
  import { formatDaemonText } from "./i18n.js";
10
10
  import { atomicPrivateWrite } from "./atomic-private-write.js";
11
11
  import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
12
+ import { resolveDaemonLocalHome } from "./daemon-local-home.js";
12
13
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
13
14
  export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
14
15
  const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
@@ -94,8 +95,20 @@ export class ProfileAgentsRootConflictError extends Error {
94
95
  }
95
96
  const quotePosixArgument = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
96
97
  const quotePowerShellArgument = (value) => `'${value.replaceAll("'", "''")}'`;
97
- export function daemonHome(env = process.env) {
98
- return resolve(env.CREW_DAEMON_HOME ?? resolve(homedir(), ".crew/daemon"));
98
+ export function daemonHome(env = process.env, profile) {
99
+ if (env.CREW_DAEMON_HOME !== undefined)
100
+ return resolve(env.CREW_DAEMON_HOME);
101
+ const mode = env.NOWWORK_LOCAL_HOME_MODE ?? env.NOWWORK_STORAGE_MODE;
102
+ if (profile !== undefined && (env.NOWWORK_LOCAL_HOME !== undefined || mode !== undefined)) {
103
+ return resolveDaemonLocalHome({
104
+ userHome: homedir(),
105
+ platform: process.platform,
106
+ profile,
107
+ ...(env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: env.NOWWORK_LOCAL_HOME }),
108
+ ...(mode === undefined ? {} : { mode }),
109
+ }).daemonHome;
110
+ }
111
+ return resolve(homedir(), ".crew/daemon");
99
112
  }
100
113
  export function resolveAgentsRoot(configured, userHome = homedir(), platform = process.platform) {
101
114
  const pathApi = platform === "win32" ? win32 : posix;
@@ -0,0 +1,73 @@
1
+ import { existsSync } from "node:fs";
2
+ import { posix, win32 } from "node:path";
3
+ const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
4
+ const CONTROL = /[\0\r\n]/u;
5
+ const pathsFor = (platform) => platform === "win32" ? win32 : posix;
6
+ export function parseDaemonLocalHomeMode(value) {
7
+ if (value === undefined || value.trim() === "")
8
+ return "auto";
9
+ const normalized = value.trim().toLowerCase();
10
+ if (normalized === "auto" || normalized === "nowwork" || normalized === "legacy")
11
+ return normalized;
12
+ throw new Error("NOWWORK_LOCAL_HOME_MODE must be auto, nowwork or legacy");
13
+ }
14
+ function absoluteRoot(value, userHome, platform) {
15
+ const paths = pathsFor(platform);
16
+ if (value.length === 0 || CONTROL.test(value))
17
+ throw new Error("NOWWORK_LOCAL_HOME must be a non-empty single-line path");
18
+ const expanded = value === "~"
19
+ ? userHome
20
+ : value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))
21
+ ? paths.join(userHome, value.slice(2))
22
+ : value;
23
+ if (!paths.isAbsolute(expanded))
24
+ throw new Error("NOWWORK_LOCAL_HOME must be absolute or start with '~/'");
25
+ return paths.normalize(expanded);
26
+ }
27
+ function legacyDaemonDirectory(profile) {
28
+ if (profile === "in" || profile === "production")
29
+ return "daemon";
30
+ if (profile === "dev")
31
+ return "daemon-dev";
32
+ if (profile.startsWith("nw-"))
33
+ return `daemon-${profile.slice(3)}`;
34
+ return `daemon-${profile}`;
35
+ }
36
+ function resolution(mode, root, profile, platform, source) {
37
+ const paths = pathsFor(platform);
38
+ return Object.freeze({
39
+ mode,
40
+ root,
41
+ daemonHome: mode === "nowwork" ? paths.join(root, "config") : paths.join(root, legacyDaemonDirectory(profile)),
42
+ runtimePrefix: paths.join(root, "daemon-runtimes", profile),
43
+ source,
44
+ });
45
+ }
46
+ export function resolveDaemonLocalHome(input) {
47
+ if (!PROFILE.test(input.profile))
48
+ throw new Error("daemon profile is invalid");
49
+ const mode = parseDaemonLocalHomeMode(input.mode);
50
+ const paths = pathsFor(input.platform);
51
+ const nowworkRoot = absoluteRoot(input.explicit ?? paths.join(input.userHome, ".nowwork"), input.userHome, input.platform);
52
+ const legacyRoot = absoluteRoot(paths.join(input.userHome, ".crew"), input.userHome, input.platform);
53
+ if (input.explicit !== undefined && mode !== "legacy") {
54
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "explicit");
55
+ }
56
+ if (mode === "nowwork")
57
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "nowwork_forced");
58
+ if (mode === "legacy")
59
+ return resolution("legacy", legacyRoot, input.profile, input.platform, "legacy_forced");
60
+ const exists = input.exists ?? existsSync;
61
+ const nowwork = resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_nowwork_existing");
62
+ const legacy = resolution("legacy", legacyRoot, input.profile, input.platform, "auto_legacy_existing");
63
+ const hasNowwork = exists(nowwork.runtimePrefix) || exists(paths.join(nowwork.daemonHome, "profiles", `${input.profile}.json`));
64
+ const hasLegacy = exists(legacy.runtimePrefix) || exists(paths.join(legacy.daemonHome, "profiles", `${input.profile}.json`));
65
+ if (hasNowwork && hasLegacy) {
66
+ throw new Error(`Daemon local home conflict: profile '${input.profile}' exists under both .crew and .nowwork`);
67
+ }
68
+ if (hasNowwork)
69
+ return nowwork;
70
+ if (hasLegacy)
71
+ return legacy;
72
+ return resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_new");
73
+ }
@@ -125,7 +125,15 @@ export function createDaemonMigrationController(input) {
125
125
  if (currentProfile.machineToken !== input.machineToken)
126
126
  throw new Error("active profile token does not match this daemon");
127
127
  currentRegistryUrl = currentProfile.registryUrl;
128
- const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: message.targetProfile, userHome, platform });
128
+ const target = resolveAutomaticInstallTarget({
129
+ serverUrl: input.serverUrl,
130
+ requestedProfile: message.targetProfile,
131
+ userHome,
132
+ platform,
133
+ // This controller migrates the legacy service layout; the new local-home
134
+ // standard is handled by the storage migration controller.
135
+ requestedLocalHomeMode: "legacy",
136
+ });
129
137
  if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
130
138
  || resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
131
139
  || resolve(message.targetAgentsRoot) !== resolve(target.agentsRoot))
package/dist/main.js CHANGED
@@ -74,7 +74,7 @@ async function main() {
74
74
  process.env.CREW_DAEMON_HOME = values["daemon-home"];
75
75
  let activeDaemonHome;
76
76
  if (values.profile) {
77
- const home = daemonHome();
77
+ const home = daemonHome(process.env, values.profile);
78
78
  activeDaemonHome = home;
79
79
  const profile = await loadProfile(values.profile, home);
80
80
  if (cmd === "serve") {
package/dist/serve.js CHANGED
@@ -825,9 +825,9 @@ export function serve(config, opts = {}) {
825
825
  // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
826
826
  const r = msg;
827
827
  setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
828
- if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
828
+ if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
829
829
  try {
830
- opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
830
+ opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId, machineId: r.machineId });
831
831
  machineHelloPublisher.republish();
832
832
  }
833
833
  catch (error) {
@@ -5,6 +5,8 @@ const IdentitySchema = z.object({
5
5
  migrationId: z.string().uuid(),
6
6
  environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
7
7
  workspaceId: z.string().uuid(),
8
+ ownerEpoch: z.number().int().positive().safe().optional(),
9
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
8
10
  });
9
11
  const DoctorRequestSchema = IdentitySchema.extend({ type: z.literal("daemon:storage-doctor") }).strict();
10
12
  const MigrateRequestSchema = IdentitySchema.extend({
@@ -26,6 +28,11 @@ export function createStorageControlController(input) {
26
28
  const profile = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u).parse(input.profile);
27
29
  const sourceStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.sourceStorageFingerprint);
28
30
  const targetStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.targetStorageFingerprint);
31
+ const ownership = input.ownership === undefined ? undefined : Object.freeze({
32
+ ownerEpoch: z.number().int().positive().safe().parse(input.ownership.ownerEpoch),
33
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u)
34
+ .parse(input.ownership.ownershipClaimFingerprint),
35
+ });
29
36
  const preflights = new Map();
30
37
  const preflightRuns = new Map();
31
38
  const appliedMigrationIds = new Set();
@@ -39,6 +46,7 @@ export function createStorageControlController(input) {
39
46
  environment,
40
47
  workspaceId,
41
48
  profile,
49
+ ...(ownership === undefined ? {} : ownership),
42
50
  ...extra,
43
51
  });
44
52
  const failure = (identity, errorCode, error, reopenAdmission = false, extra = {}) => {
@@ -59,7 +67,10 @@ export function createStorageControlController(input) {
59
67
  input.onStatusError?.(error, statusFrame);
60
68
  }
61
69
  };
62
- const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId;
70
+ const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId
71
+ && (ownership === undefined
72
+ || (identity.ownerEpoch === ownership.ownerEpoch
73
+ && identity.ownershipClaimFingerprint === ownership.ownershipClaimFingerprint));
63
74
  const preflightKey = (identity) => `${identity.migrationId}\0${identity.environment}\0${identity.workspaceId}`;
64
75
  const runPreflight = (identity, failureFallback = "doctor_failed") => {
65
76
  const key = preflightKey(identity);
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { STORAGE_LAYOUT_VERSION } from "./storage-layout.js";
3
+ import { createStorageOwnershipClaim } from "./storage-ownership.js";
3
4
  function fingerprint(namespace, value) {
4
5
  return `sha256:${createHash("sha256").update(`${namespace}\0${value}`).digest("hex")}`;
5
6
  }
@@ -14,14 +15,27 @@ export function storageHelloIdentity(input) {
14
15
  || installation.runtimeRoot !== layout.runtimeRoot) {
15
16
  throw new Error("installation storage identity does not match resolved layout");
16
17
  }
18
+ const workspaceRootFingerprint = fingerprint("workspace-root", layout.workspaceRoot);
19
+ let ownership;
20
+ if (input.ownership !== undefined) {
21
+ const { claimFingerprint: reportedFingerprint, ...claimIdentity } = input.ownership;
22
+ ownership = createStorageOwnershipClaim(claimIdentity);
23
+ if (ownership.claimFingerprint !== reportedFingerprint
24
+ || ownership.workspaceId !== layout.workspaceId
25
+ || ownership.environment !== layout.environment
26
+ || ownership.workspaceRootFingerprint !== workspaceRootFingerprint) {
27
+ throw new Error("storage ownership identity does not match resolved layout");
28
+ }
29
+ }
17
30
  return Object.freeze({
18
31
  storageLayoutVersion: STORAGE_LAYOUT_VERSION,
19
32
  storageEnvironment: layout.environment,
20
33
  storageWorkspaceId: layout.workspaceId,
21
34
  storageIdFingerprint: storageIdFingerprint(installation.storageId),
22
- workspaceRootFingerprint: fingerprint("workspace-root", layout.workspaceRoot),
35
+ workspaceRootFingerprint,
23
36
  daemonRootFingerprint: fingerprint("daemon-root", layout.daemonRoot),
24
37
  agentRootFingerprint: fingerprint("agent-root", layout.agentItemsRoot),
38
+ ...(ownership === undefined ? {} : { storageOwnership: ownership }),
25
39
  ...(installation.storageMigrationId === undefined
26
40
  ? {}
27
41
  : { storageMigrationId: installation.storageMigrationId }),
@@ -27,6 +27,8 @@ const ReceiptSchema = z.object({
27
27
  environment: z.string().min(1),
28
28
  workspaceId: z.string().uuid(),
29
29
  profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
30
+ ownerEpoch: z.number().int().positive().safe().optional(),
31
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
30
32
  manifestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
31
33
  targetFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
32
34
  status: ReceiptStatusSchema,
@@ -37,13 +39,23 @@ const ReceiptSchema = z.object({
37
39
  sourceStopAttempted: z.boolean().optional(),
38
40
  sourceStopped: z.boolean().optional(),
39
41
  targetTouched: z.boolean().optional(),
40
- }).strict();
42
+ }).strict().superRefine((receipt, context) => {
43
+ if ((receipt.ownerEpoch === undefined) !== (receipt.ownershipClaimFingerprint === undefined)) {
44
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "storage receipt ownership identity must be complete" });
45
+ }
46
+ });
41
47
  const MigrationLockSchema = z.object({
42
48
  version: z.literal(1),
43
49
  migrationId: z.string().uuid(),
44
50
  pid: z.number().int().positive(),
45
51
  createdAt: z.string().datetime({ offset: true }),
46
52
  }).strict();
53
+ const REQUIRED_BACKUP_ARTIFACTS = Object.freeze([
54
+ "profile.json",
55
+ "service-descriptor",
56
+ "managed-service-registry.json",
57
+ "installations.json",
58
+ ]);
47
59
  export class StorageMigrationFailure extends Error {
48
60
  code;
49
61
  receipt;
@@ -204,6 +216,16 @@ function assertOwnedStaging(input) {
204
216
  }
205
217
  }
206
218
  async function backupArtifacts(input, receipt) {
219
+ const providedNames = new Set(input.backupArtifacts.map((artifact) => artifact.name));
220
+ const missing = REQUIRED_BACKUP_ARTIFACTS.filter((name) => !providedNames.has(name));
221
+ if (missing.length > 0) {
222
+ throw new StorageMigrationFailure("backup_artifact_missing", `required rollback artifacts are missing: ${missing.join(", ")}`);
223
+ }
224
+ for (const artifact of input.backupArtifacts) {
225
+ if (!(await lstat(artifact.path)).isFile()) {
226
+ throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact must be a file");
227
+ }
228
+ }
207
229
  const directory = join(input.backupsRoot, receipt.migrationId);
208
230
  const artifactsDirectory = join(directory, "artifacts");
209
231
  await mkdir(artifactsDirectory, { recursive: true, mode: 0o700 });
@@ -217,8 +239,6 @@ async function backupArtifacts(input, receipt) {
217
239
  throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact names must be unique safe file names");
218
240
  }
219
241
  names.add(artifact.name);
220
- if (!(await lstat(artifact.path)).isFile())
221
- throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact must be a file");
222
242
  const destination = join(artifactsDirectory, artifact.name);
223
243
  await copyFile(artifact.path, destination);
224
244
  await chmod(destination, 0o600);
@@ -271,6 +291,7 @@ export async function runStorageMigration(input, dependencies) {
271
291
  environment: input.doctor.environment,
272
292
  workspaceId: input.doctor.workspaceId,
273
293
  profile: input.doctor.profile,
294
+ ...(input.ownership === undefined ? {} : input.ownership),
274
295
  manifestDigest: input.doctor.manifestDigest,
275
296
  targetFingerprint: targetFingerprint(input.targetWorkspaceRoot),
276
297
  status: "preflight_passed",
@@ -301,7 +322,7 @@ export async function runStorageMigration(input, dependencies) {
301
322
  input.assertHealthy?.();
302
323
  await transition("backing_up");
303
324
  receipt = await backupArtifacts(input, receipt).catch((error) => {
304
- failureCode = "backup_failed";
325
+ failureCode = error instanceof StorageMigrationFailure ? error.code : "backup_failed";
305
326
  throw error;
306
327
  });
307
328
  await writeReceipt(path, receipt);
@@ -0,0 +1,55 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ const FingerprintSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
6
+ const StorageOwnershipClaimSchema = z.object({
7
+ version: z.literal(1),
8
+ machineId: z.string().uuid(),
9
+ workspaceId: z.string().uuid(),
10
+ environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
11
+ volumeFingerprint: FingerprintSchema,
12
+ workspaceRootFingerprint: FingerprintSchema,
13
+ ownerEpoch: z.number().int().positive().safe(),
14
+ claimFingerprint: FingerprintSchema,
15
+ }).strict();
16
+ function claimFingerprint(identity) {
17
+ const digest = createHash("sha256").update([
18
+ "nowwork-storage-ownership-v1",
19
+ identity.machineId,
20
+ identity.workspaceId,
21
+ identity.environment,
22
+ identity.volumeFingerprint,
23
+ identity.workspaceRootFingerprint,
24
+ String(identity.ownerEpoch),
25
+ ].join("\0")).digest("hex");
26
+ return `sha256:${digest}`;
27
+ }
28
+ export function createStorageOwnershipClaim(input) {
29
+ const parsed = StorageOwnershipClaimSchema.omit({ version: true, claimFingerprint: true }).parse(input);
30
+ const identity = Object.freeze({
31
+ ...parsed,
32
+ machineId: parsed.machineId.toLowerCase(),
33
+ workspaceId: parsed.workspaceId.toLowerCase(),
34
+ environment: parsed.environment.toLowerCase(),
35
+ });
36
+ return Object.freeze({ ...identity, claimFingerprint: claimFingerprint(identity) });
37
+ }
38
+ export function storageOwnershipClaimPath(nowworkHome) {
39
+ return join(nowworkHome, "storage-layout.json");
40
+ }
41
+ export async function readStorageOwnershipClaim(nowworkHome) {
42
+ const path = storageOwnershipClaimPath(nowworkHome);
43
+ const metadata = await lstat(path);
44
+ if (!metadata.isFile())
45
+ throw new Error("storage ownership claim must be a file");
46
+ if (metadata.size > 16 * 1024)
47
+ throw new Error("storage ownership claim file is too large");
48
+ const parsed = StorageOwnershipClaimSchema.parse(JSON.parse(await readFile(path, "utf8")));
49
+ const { version: _version, claimFingerprint: reportedFingerprint, ...identity } = parsed;
50
+ const expected = createStorageOwnershipClaim(identity);
51
+ if (reportedFingerprint !== expected.claimFingerprint) {
52
+ throw new Error("storage ownership claim fingerprint is invalid");
53
+ }
54
+ return expected;
55
+ }
@@ -23,9 +23,10 @@ function deterministicUuid(value) {
23
23
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
24
24
  }
25
25
  function storageId(input, workspaceId) {
26
- return `storage_${createHash("sha256").update([
27
- input.serverUrl.replace(/\/+$/u, ""), input.profile, workspaceId, input.nowworkHome,
28
- ].join("\0")).digest("hex").slice(0, 32)}`;
26
+ if (input.ownership.workspaceId !== workspaceId || input.ownership.environment !== input.environment) {
27
+ throw new Error("Storage ownership claim does not match the migration identity");
28
+ }
29
+ return `storage_${input.ownership.claimFingerprint.slice("sha256:".length)}`;
29
30
  }
30
31
  function installationBase(input, workspaceId, migrationId, createdAt) {
31
32
  const layout = storageLayout({
@@ -102,6 +103,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
102
103
  ...injected,
103
104
  };
104
105
  let workspaceId = null;
106
+ let machineId = null;
105
107
  let identity;
106
108
  let gate = null;
107
109
  let sendStatus = null;
@@ -194,6 +196,10 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
194
196
  targetWorkspaceRoot: target.workspaceRoot,
195
197
  backupsRoot: input.local.backupsRoot,
196
198
  backupArtifacts: input.backupArtifacts.map((artifact) => ({ ...artifact })),
199
+ ownership: {
200
+ ownerEpoch: input.ownership.ownerEpoch,
201
+ ownershipClaimFingerprint: input.ownership.claimFingerprint,
202
+ },
197
203
  acceptedWarnings: [...input.policy.acceptedWarnings],
198
204
  },
199
205
  service: {
@@ -229,6 +235,10 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
229
235
  profile: input.profile,
230
236
  sourceStorageFingerprint,
231
237
  targetStorageFingerprint: identity.storageIdFingerprint,
238
+ ownership: {
239
+ ownerEpoch: input.ownership.ownerEpoch,
240
+ ownershipClaimFingerprint: input.ownership.claimFingerprint,
241
+ },
232
242
  runDoctor: async (migrationId) => dependencies.runDoctor(await doctorInput(migrationId)),
233
243
  runMigration: async (doctor) => {
234
244
  await gate.closeAndFlush();
@@ -264,6 +274,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
264
274
  environment: operation.environment,
265
275
  workspaceId: operation.workspaceId,
266
276
  profile: operation.profile,
277
+ ...(operation.migration.ownership === undefined ? {} : operation.migration.ownership),
267
278
  }, sendStatus);
268
279
  if (outcome.reopenAdmission)
269
280
  gate.reopen();
@@ -285,20 +296,31 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
285
296
  };
286
297
  const bindServerIdentity = (server) => {
287
298
  const nextWorkspaceId = UUID.parse(server.workspaceId).toLowerCase();
299
+ const nextMachineId = UUID.parse(server.machineId).toLowerCase();
288
300
  if (workspaceId !== null && workspaceId !== nextWorkspaceId) {
289
301
  throw new Error("Storage migration server workspace identity changed");
290
302
  }
291
- workspaceId = nextWorkspaceId;
303
+ if (machineId !== null && machineId !== nextMachineId) {
304
+ throw new Error("Storage migration server machine identity changed");
305
+ }
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
+ }
292
311
  const createdAt = dependencies.now().toISOString();
293
- const installation = installationBase(input, workspaceId, undefined, createdAt);
312
+ const installation = installationBase(input, nextWorkspaceId, undefined, createdAt);
294
313
  const layout = storageLayout({
295
314
  nowworkHome: input.nowworkHome,
296
315
  environment: input.environment,
297
- workspaceId,
316
+ workspaceId: nextWorkspaceId,
298
317
  runtimeBase: input.local.runtimeBase,
299
318
  platform: input.platform,
300
319
  });
301
- identity = storageHelloIdentity({ installation, layout });
320
+ const nextIdentity = storageHelloIdentity({ installation, layout, ownership: input.ownership });
321
+ workspaceId = nextWorkspaceId;
322
+ machineId = nextMachineId;
323
+ identity = nextIdentity;
302
324
  ensureControl();
303
325
  drainPendingControlMessages();
304
326
  };
@@ -5,10 +5,11 @@ import { buildServiceSpec } from "./computer-service.js";
5
5
  import { profilePath } from "./computer-profile.js";
6
6
  import { assertManagedServiceIdentity } from "./managed-service-lifecycle.js";
7
7
  import { managedServiceRegistryPath, readManagedServiceRegistry, } from "./managed-service-registry.js";
8
- import { resolveLocalStorageRoots } from "./storage-config.js";
8
+ import { installationsPath, resolveLocalStorageRoots } from "./storage-config.js";
9
9
  import { readStorageMigrationPolicy } from "./storage-migration-policy.js";
10
10
  import { createStorageMigrationRuntimeController, } from "./storage-runtime-controller.js";
11
11
  import { resolveEnvTag, resolveNowworkHome } from "./storage-layout.js";
12
+ import { readStorageOwnershipClaim } from "./storage-ownership.js";
12
13
  const notReady = (reason) => Object.freeze({ ready: false, reason });
13
14
  function canonicalPath(value) {
14
15
  const absolute = resolve(value);
@@ -106,6 +107,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
106
107
  assertManagedServiceIdentity,
107
108
  readPolicy: readStorageMigrationPolicy,
108
109
  createController: createStorageMigrationRuntimeController,
110
+ readOwnership: readStorageOwnershipClaim,
109
111
  ...injected,
110
112
  };
111
113
  let sourceRecord;
@@ -153,6 +155,17 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
153
155
  catch {
154
156
  return notReady("migration_policy_invalid");
155
157
  }
158
+ let ownership;
159
+ try {
160
+ ownership = await dependencies.readOwnership(nowworkHome);
161
+ }
162
+ catch (error) {
163
+ return notReady(error.code === "ENOENT"
164
+ ? "storage_ownership_claim_missing"
165
+ : "storage_ownership_claim_invalid");
166
+ }
167
+ if (ownership.environment !== environment)
168
+ return notReady("storage_ownership_claim_invalid");
156
169
  const local = resolveLocalStorageRoots({
157
170
  platform: input.platform,
158
171
  userHome: input.userHome,
@@ -169,6 +182,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
169
182
  sourceRecord,
170
183
  local,
171
184
  policy,
185
+ ownership,
172
186
  serviceEnvironment: {
173
187
  NOWWORK_HOME: nowworkHome,
174
188
  NOWWORK_ENV_TAG: environment,
@@ -178,6 +192,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
178
192
  { name: "profile.json", path: profilePath(input.profile, input.currentDaemonHome) },
179
193
  { name: "service-descriptor", path: sourceRecord.descriptorPath },
180
194
  { name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome) },
195
+ { name: "installations.json", path: installationsPath(local.configRoot) },
181
196
  ],
182
197
  }, { assertSourceIdentity });
183
198
  return Object.freeze({ ready: true, runtime });
@@ -1,6 +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
5
  const canonicalServerUrl = (value) => value.replace(/\/+$/u, "");
5
6
  export function resolveStorageRuntime(input) {
6
7
  const installation = findInstallationForProfile(input.records, input.profile);
@@ -24,7 +25,11 @@ export function resolveStorageRuntime(input) {
24
25
  return Object.freeze({
25
26
  installation,
26
27
  layout,
27
- identity: storageHelloIdentity({ installation, layout }),
28
+ identity: storageHelloIdentity({
29
+ installation,
30
+ layout,
31
+ ...(input.ownership === undefined ? {} : { ownership: input.ownership }),
32
+ }),
28
33
  local,
29
34
  });
30
35
  }
@@ -35,5 +40,16 @@ export async function loadStorageRuntime(input) {
35
40
  environment: input.environment,
36
41
  });
37
42
  const records = await readInstallations({ configRoot: local.configRoot });
38
- return resolveStorageRuntime({ ...input, records });
43
+ const baseline = resolveStorageRuntime({ ...input, records });
44
+ if (baseline === null)
45
+ return null;
46
+ let ownership;
47
+ try {
48
+ ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot);
49
+ }
50
+ catch (error) {
51
+ if (error.code !== "ENOENT")
52
+ throw error;
53
+ }
54
+ return ownership === undefined ? baseline : resolveStorageRuntime({ ...input, records, ownership });
39
55
  }
@@ -18,6 +18,8 @@ const DeliveredFrameSchema = z.object({
18
18
  environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
19
19
  workspaceId: z.string().uuid(),
20
20
  profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
21
+ ownerEpoch: z.number().int().positive().safe().optional(),
22
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
21
23
  sourceStorageFingerprint: z.string().optional(),
22
24
  targetStorageFingerprint: z.string().optional(),
23
25
  manifestDigest: z.string().optional(),
@@ -45,6 +47,8 @@ export function storageStatusEventId(frame) {
45
47
  frame.environment,
46
48
  frame.workspaceId,
47
49
  frame.profile,
50
+ frame.ownerEpoch ?? null,
51
+ frame.ownershipClaimFingerprint ?? null,
48
52
  frame.sourceStorageFingerprint ?? null,
49
53
  frame.targetStorageFingerprint ?? null,
50
54
  frame.manifestDigest ?? null,
@@ -35,7 +35,10 @@ export function storageMigrationStatusReplay(receipt, expected) {
35
35
  if (expected !== undefined && (receipt.migrationId !== expected.migrationId
36
36
  || receipt.environment !== expected.environment
37
37
  || receipt.workspaceId !== expected.workspaceId
38
- || receipt.profile !== expected.profile)) {
38
+ || receipt.profile !== expected.profile
39
+ || (expected.ownerEpoch !== undefined && receipt.ownerEpoch !== expected.ownerEpoch)
40
+ || (expected.ownershipClaimFingerprint !== undefined
41
+ && receipt.ownershipClaimFingerprint !== expected.ownershipClaimFingerprint))) {
39
42
  throw new Error("storage migration receipt identity mismatch");
40
43
  }
41
44
  return Object.freeze(storageMigrationReplayStatuses(receipt).map((status) => Object.freeze({
@@ -45,6 +48,10 @@ export function storageMigrationStatusReplay(receipt, expected) {
45
48
  environment: receipt.environment,
46
49
  workspaceId: receipt.workspaceId,
47
50
  profile: receipt.profile,
51
+ ...(receipt.ownerEpoch === undefined ? {} : { ownerEpoch: receipt.ownerEpoch }),
52
+ ...(receipt.ownershipClaimFingerprint === undefined ? {} : {
53
+ ownershipClaimFingerprint: receipt.ownershipClaimFingerprint,
54
+ }),
48
55
  ...(status === "verified" ? { finalManifestDigest: receipt.manifestDigest } : {}),
49
56
  ...(status === "failed"
50
57
  ? {
@@ -98,6 +105,10 @@ export async function monitorStorageMigrationStatus(input, send, injected = {})
98
105
  environment: input.environment,
99
106
  workspaceId: input.workspaceId,
100
107
  profile: input.profile,
108
+ ...(input.ownerEpoch === undefined ? {} : { ownerEpoch: input.ownerEpoch }),
109
+ ...(input.ownershipClaimFingerprint === undefined ? {} : {
110
+ ownershipClaimFingerprint: input.ownershipClaimFingerprint,
111
+ }),
101
112
  errorCode: workerFailure.errorCode,
102
113
  errorMessage: workerFailure.errorMessage,
103
114
  ...(receipt?.sourceStopAttempted === false || receipt === null ? { reopenAdmission: true } : {}),
@@ -114,6 +125,10 @@ export async function monitorStorageMigrationStatus(input, send, injected = {})
114
125
  environment: input.environment,
115
126
  workspaceId: input.workspaceId,
116
127
  profile: input.profile,
128
+ ...(input.ownerEpoch === undefined ? {} : { ownerEpoch: input.ownerEpoch }),
129
+ ...(input.ownershipClaimFingerprint === undefined ? {} : {
130
+ ownershipClaimFingerprint: input.ownershipClaimFingerprint,
131
+ }),
117
132
  errorCode: "worker_heartbeat_stale_recovery_required",
118
133
  errorMessage: "storage migration worker heartbeat expired; explicit recovery is required",
119
134
  ...(receipt?.sourceStopAttempted === false || receipt === null ? { reopenAdmission: true } : {}),
@@ -367,6 +367,7 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
367
367
  targetWorkspaceRoot: operation.migration.targetWorkspaceRoot,
368
368
  backupsRoot: operation.migration.backupsRoot,
369
369
  backupArtifacts: operation.migration.backupArtifacts,
370
+ ...(operation.migration.ownership === undefined ? {} : { ownership: operation.migration.ownership }),
370
371
  acceptedWarnings: operation.migration.acceptedWarnings,
371
372
  onStatus: () => assertHeartbeatHealthy(),
372
373
  assertHealthy: assertHeartbeatHealthy,
@@ -118,6 +118,10 @@ const OperationSchema = z.object({
118
118
  name: z.string().regex(/^[a-z0-9][a-z0-9_.-]{0,127}$/u),
119
119
  path: AbsolutePathSchema,
120
120
  }).strict()).max(32),
121
+ ownership: z.object({
122
+ ownerEpoch: z.number().int().positive().safe(),
123
+ ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
124
+ }).strict().optional(),
121
125
  acceptedWarnings: z.array(StorageMigrationWarningCodeSchema)
122
126
  .max(StorageMigrationWarningCodeSchema.options.length),
123
127
  }).strict(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.77-beta.7",
3
+ "version": "0.6.77-beta.8",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",