@nowcrew/daemon 0.6.68 → 0.6.69

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 (72) hide show
  1. package/dist/agent-ability/materializer.js +30 -1
  2. package/dist/agent-ability/resolver.js +27 -16
  3. package/dist/agent-ability/runtime.js +3 -0
  4. package/dist/agent-ability/types.js +6 -11
  5. package/dist/automatic-install-target.js +15 -10
  6. package/dist/background-job.js +50 -0
  7. package/dist/computer-cli.js +10 -1
  8. package/dist/computer-service.js +41 -1
  9. package/dist/config.js +44 -0
  10. package/dist/console-formatter.js +76 -1
  11. package/dist/console.js +23 -4
  12. package/dist/daemon-migration-controller.js +16 -18
  13. package/dist/execution-protocol.js +0 -1
  14. package/dist/execution-runner.js +60 -11
  15. package/dist/i18n.js +2 -0
  16. package/dist/local-execution-prompt.js +32 -13
  17. package/dist/local-executor.js +107 -55
  18. package/dist/local-memory/index-budget.js +74 -0
  19. package/dist/local-memory/pending.js +111 -0
  20. package/dist/local-memory/runtime.js +87 -0
  21. package/dist/local-memory/types.js +1 -0
  22. package/dist/local-memory/write.js +448 -0
  23. package/dist/machine-info.js +3 -4
  24. package/dist/main.js +83 -0
  25. package/dist/managed-service-lifecycle.js +47 -2
  26. package/dist/managed-service-startup.js +2 -0
  27. package/dist/profile-layout.js +10 -4
  28. package/dist/project-dependencies.js +139 -124
  29. package/dist/project-dependency-lock.js +87 -0
  30. package/dist/project-dependency-manifest.js +2 -12
  31. package/dist/project-registry-wiring.js +1 -4
  32. package/dist/project-skills/controller.js +2 -0
  33. package/dist/project-skills/runtime-launch.js +10 -7
  34. package/dist/prompt.js +19 -1
  35. package/dist/provider-env.js +3 -21
  36. package/dist/repository-identity.js +45 -0
  37. package/dist/runner.js +4 -2
  38. package/dist/runtimes/codex-app-server-runner.js +55 -23
  39. package/dist/runtimes/codex-command-safety.js +75 -0
  40. package/dist/runtimes/codex-contract.js +20 -5
  41. package/dist/runtimes/codex-deepseek-config.js +7 -17
  42. package/dist/runtimes/codex-turn-retry.js +69 -0
  43. package/dist/runtimes/codex.js +4 -3
  44. package/dist/serve.js +68 -3
  45. package/dist/storage-config.js +175 -0
  46. package/dist/storage-control.js +193 -0
  47. package/dist/storage-copy.js +628 -0
  48. package/dist/storage-credential-guard.js +21 -0
  49. package/dist/storage-doctor.js +228 -0
  50. package/dist/storage-identity.js +29 -0
  51. package/dist/storage-layout.js +143 -0
  52. package/dist/storage-manifest.js +855 -0
  53. package/dist/storage-migration-controller.js +420 -0
  54. package/dist/storage-migration-manifest.js +105 -0
  55. package/dist/storage-migration-policy.js +100 -0
  56. package/dist/storage-runtime-controller.js +339 -0
  57. package/dist/storage-runtime-readiness.js +184 -0
  58. package/dist/storage-runtime.js +39 -0
  59. package/dist/storage-safe-fs.js +302 -0
  60. package/dist/storage-serve-adapter.js +65 -0
  61. package/dist/storage-status-delivery.js +209 -0
  62. package/dist/storage-status-replay.js +125 -0
  63. package/dist/storage-target-confirmation.js +129 -0
  64. package/dist/storage-worker-command.js +37 -0
  65. package/dist/storage-worker-executor.js +430 -0
  66. package/dist/storage-worker-handoff.js +151 -0
  67. package/dist/storage-worker-operation.js +284 -0
  68. package/dist/storage-worker-status.js +259 -0
  69. package/dist/supervised-runtime.js +3 -1
  70. package/dist/workspace.js +34 -14
  71. package/package.json +1 -1
  72. package/dist/self-training-workspace.js +0 -15
package/dist/serve.js CHANGED
@@ -51,6 +51,7 @@ import { handleRuntimeProbeFrame, probeRuntimeHealth } from "./runtime-health.js
51
51
  import { buildControlPlaneUrl } from "./control-plane-url.js";
52
52
  import { createServeMigrationController, handleMigrationControlMessage } from "./daemon-migration-wiring.js";
53
53
  import { createCodexHomeModeController } from "./codex-home-mode-controller.js";
54
+ import { attachStorageMigrationRuntime, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
54
55
  export { buildControlPlaneUrl } from "./control-plane-url.js";
55
56
  // normalize.ts 的活动种类 → activity 枚举
56
57
  const ACTIVITY_MAP = {
@@ -122,6 +123,7 @@ export function serve(config, opts = {}) {
122
123
  catch { /* reconnect/timeout reconciliation handles a lost status frame */ }
123
124
  },
124
125
  });
126
+ let storageMigrationAdmissionClosed = () => false;
125
127
  const migrationController = createServeMigrationController({
126
128
  config,
127
129
  ...(opts.profileName === undefined ? {} : { profileName: opts.profileName }),
@@ -142,6 +144,7 @@ export function serve(config, opts = {}) {
142
144
  const agentAbilityRuntime = createAgentAbilityRuntime(config.agentsRoot, {
143
145
  ...opts.agentAbility,
144
146
  coordinator: projectionCoordinator,
147
+ isAdmissionClosed: () => storageMigrationAdmissionClosed(),
145
148
  });
146
149
  let projectSkillsController;
147
150
  const projectSkillsReconciler = opts.projectSkills?.reconciler ?? createProjectSkillsReconciler({
@@ -161,6 +164,7 @@ export function serve(config, opts = {}) {
161
164
  }
162
165
  catch { /* 下一次 ready 或项目操作会重新发送完整快照 */ }
163
166
  },
167
+ isAdmissionClosed: () => storageMigrationAdmissionClosed(),
164
168
  reconcile: (handle, bindings) => projectSkillsReconciler.reconcile(handle, bindings),
165
169
  ensureSnapshot: (handle, snapshot, options) => projectSkillsReconciler.ensureSnapshot(handle, snapshot, options),
166
170
  });
@@ -175,13 +179,37 @@ export function serve(config, opts = {}) {
175
179
  });
176
180
  const machineHelloPublisher = createProjectSkillMachineHelloPublisher({
177
181
  status: projectSkillControllerReadiness.status,
178
- effective: withProjectSkillCapabilityStatus,
182
+ effective: (hello, status) => {
183
+ const effective = withProjectSkillCapabilityStatus(hello, status);
184
+ const configuredIdentity = opts.storageMigration?.identity;
185
+ const currentIdentity = typeof configuredIdentity === "function"
186
+ ? configuredIdentity()
187
+ : configuredIdentity;
188
+ return currentIdentity === undefined ? effective : { ...effective, ...currentIdentity };
189
+ },
179
190
  onSent: (hello) => log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · installed=[${hello.runtimes.join(",")}] · executable=[${hello.executionRuntimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`),
180
191
  });
181
192
  const initializedProjectSkills = initializedProjectSkillsReconciler(projectSkillControllerReadiness.ensure, projectSkillsReconciler);
193
+ const configuredCapabilities = opts.machineInfo?.capabilities ?? daemonCapabilities(process.platform);
194
+ const storageCapabilities = storageMigrationCapabilities(configuredCapabilities, opts.storageMigration);
195
+ const storageStatusDelivery = createServeStorageStatusDelivery(opts.storageMigration, {
196
+ onPersistenceError: (error) => dslog("daemon.storage_status_outbox_failed", "存储迁移状态 outbox 持久化失败", { level: "ERROR", error_message: error.message }),
197
+ transmit: (frame) => { try {
198
+ if (ws?.readyState === WebSocket.OPEN)
199
+ ws.send(JSON.stringify(frame));
200
+ }
201
+ catch { /* retry */ } },
202
+ });
203
+ const storageMigrationRuntime = attachStorageMigrationRuntime(opts.storageMigration, {
204
+ isBusy: () => executionRuns.size > 0 || legacyRuns.size > 0,
205
+ waitForExecutionFrames: () => executionFrameQueue,
206
+ flushLocalState: flushSlog,
207
+ sendStatus: storageStatusDelivery.send,
208
+ });
209
+ storageMigrationAdmissionClosed = storageMigrationRuntime.isAdmissionClosed;
182
210
  const prepareProjectSkillCapabilities = createProjectSkillConnectionCapabilityProvider({
183
211
  platform: process.platform,
184
- baseCapabilities: opts.machineInfo?.capabilities ?? daemonCapabilities(process.platform),
212
+ baseCapabilities: storageCapabilities,
185
213
  additionalCapabilities: () => managedDaemonCapabilities(updateEligibility),
186
214
  probeRuntimeRootStorePrimitive: projectSkillRuntimeRootStartup.probeNativePublicationPrimitiveReadiness,
187
215
  initializeController: projectSkillControllerReadiness.ensure,
@@ -310,6 +338,12 @@ export function serve(config, opts = {}) {
310
338
  // 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
311
339
  void drainSpool();
312
340
  // 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
341
+ storageStatusDelivery.replayPending();
342
+ const storageReplay = replayStorageMigrationStatus(opts.storageMigration, openedSocket, () => ws === openedSocket, (error) => {
343
+ dslog("daemon.storage_status_replay_failed", "存储迁移状态重放失败", {
344
+ level: "ERROR", error_message: error.message,
345
+ });
346
+ }, storageStatusDelivery.send);
313
347
  const { detectInstalled, detectExecutable = detectExecutionRuntimesWithSignal } = opts.machineInfo ?? {};
314
348
  const helloPromise = (opts.machineInfo?.collect ?? collectMachineHello)(config.agentsRoot, config.executionLimits, process.platform, {
315
349
  ...(detectInstalled ? { detectInstalled } : {}),
@@ -325,6 +359,7 @@ export function serve(config, opts = {}) {
325
359
  if (hello.executionProtocol === undefined)
326
360
  return [];
327
361
  const detected = await runtimeProbe.detect(hello.runtimes, detectExecutable);
362
+ await storageReplay;
328
363
  if (ws === openedSocket && openedSocket.readyState === WebSocket.OPEN
329
364
  && detected.some((runtime) => !hello.executionRuntimes.includes(runtime))) {
330
365
  detectedExecutionRuntimes = detected;
@@ -339,7 +374,8 @@ export function serve(config, opts = {}) {
339
374
  return [];
340
375
  });
341
376
  void helloPromise
342
- .then((hello) => {
377
+ .then(async (hello) => {
378
+ await storageReplay;
343
379
  if (ws !== openedSocket || openedSocket.readyState !== WebSocket.OPEN)
344
380
  return;
345
381
  detectedExecutionRuntimes = hello.executionRuntimes;
@@ -387,6 +423,8 @@ export function serve(config, opts = {}) {
387
423
  if (typeof decoded !== "object" || decoded === null)
388
424
  return;
389
425
  const rawType = "type" in decoded && typeof decoded.type === "string" ? decoded.type : "";
426
+ if (storageStatusDelivery.acknowledge(decoded))
427
+ return;
390
428
  if (rawType === "daemon:update" || rawType === "daemon:restart") {
391
429
  void updateController.handle(decoded).catch((error) => {
392
430
  dslog("daemon.control_failed", "daemon 控制操作处理失败", {
@@ -403,6 +441,11 @@ export function serve(config, opts = {}) {
403
441
  void codexHomeModeController.handle(decoded).catch((error) => dslog("daemon.codex_home_mode_failed", "Codex Home 模式切换失败", { level: "ERROR", error_message: error.message }));
404
442
  return;
405
443
  }
444
+ if (handleStorageMigrationControl(opts.storageMigration, rawType, decoded, storageStatusDelivery.send, (kind, error) => {
445
+ dslog(kind === "migration" ? "daemon.storage_migration_failed" : "daemon.storage_migration_confirmation_failed", kind === "migration" ? "存储迁移控制操作失败" : "存储迁移 hello 确认处理失败", { level: "ERROR", error_message: error.message });
446
+ })) {
447
+ return;
448
+ }
406
449
  if (rawType.startsWith("execution:")) {
407
450
  const parsedExecution = ServerToDaemonExecutionFrameSchema.safeParse(decoded);
408
451
  if (!parsedExecution.success) {
@@ -464,6 +507,14 @@ export function serve(config, opts = {}) {
464
507
  return;
465
508
  }
466
509
  const spec = frame;
510
+ if (storageMigrationRuntime.isAdmissionClosed()) {
511
+ safeExecutionSend(ExecutionRejectedSchema.parse({
512
+ type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
513
+ reason: "resource_limit", message: "Storage migration is quiescing this machine",
514
+ at: new Date().toISOString(),
515
+ }));
516
+ return;
517
+ }
467
518
  const workspaceRejection = projectWorkspaceCapabilityRejection(spec, capabilities.executionRunner, new Date().toISOString());
468
519
  if (workspaceRejection !== null)
469
520
  return void safeExecutionSend(workspaceRejection);
@@ -770,6 +821,17 @@ export function serve(config, opts = {}) {
770
821
  // ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
771
822
  const r = msg;
772
823
  setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
824
+ if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
825
+ try {
826
+ opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
827
+ machineHelloPublisher.republish();
828
+ }
829
+ catch (error) {
830
+ dslog("daemon.storage_identity_bind_failed", "存储迁移 Workspace 身份绑定失败", {
831
+ level: "ERROR", error_message: error.message,
832
+ });
833
+ }
834
+ }
773
835
  // 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
774
836
  serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
775
837
  ? r.serverCapabilities.filter((c) => typeof c === "string")
@@ -868,6 +930,8 @@ export function serve(config, opts = {}) {
868
930
  }
869
931
  if (msg.type !== "agent:start")
870
932
  return; // ready/error 等忽略
933
+ if (storageMigrationRuntime.isAdmissionClosed())
934
+ return;
871
935
  // 任务键:scheduled run 用 runId(每次运行独立 cwd/work-log;overlap 由 server 端 skip_if_running 管,
872
936
  // daemon 的 running 去重只兜底"同一 run 重复投递");普通唤醒仍是 线程锚点 ?? 频道。
873
937
  const scheduled = msg.reason === "scheduled_job" && msg.scheduledRun
@@ -1283,6 +1347,7 @@ export function serve(config, opts = {}) {
1283
1347
  concurrencyTelemetry.stop();
1284
1348
  runtimeProbe.stop();
1285
1349
  completionRetransmitter.stop();
1350
+ storageStatusDelivery.stop();
1286
1351
  const deadline = createShutdownDeadline(shutdownTimeoutMs);
1287
1352
  if (reconnectTimer !== null) {
1288
1353
  clearTimeout(reconnectTimer);
@@ -0,0 +1,175 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { posix, win32 } from "node:path";
3
+ import { z } from "zod";
4
+ import { atomicPrivateWrite } from "./atomic-private-write.js";
5
+ import { resolveEnvTag, resolveNowworkHome, storageLayout, } from "./storage-layout.js";
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
7
+ const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
8
+ const PORTABLE_ABSOLUTE_PATH = z.string().min(1).max(32768).refine((value) => posix.isAbsolute(value) || win32.isAbsolute(value), "must be an absolute path");
9
+ const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
10
+ const url = new URL(value);
11
+ return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
12
+ }, "serverUrl must be HTTP(S) without embedded credentials");
13
+ const InstallationRecordSchema = z.object({
14
+ version: z.literal(1),
15
+ installationId: z.string().uuid(),
16
+ profile: z.string().regex(PROFILE),
17
+ serverUrl: ServerUrlSchema,
18
+ environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
19
+ workspaceId: z.string().uuid(),
20
+ storageId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/u),
21
+ nowworkHome: PORTABLE_ABSOLUTE_PATH,
22
+ runtimeRoot: PORTABLE_ABSOLUTE_PATH,
23
+ credentialRef: z.string().regex(/^profile:[a-z0-9][a-z0-9_-]{0,47}$/u),
24
+ storageMigrationId: z.string().uuid().optional(),
25
+ createdAt: z.string().datetime({ offset: true }),
26
+ updatedAt: z.string().datetime({ offset: true }),
27
+ }).strict();
28
+ const InstallationRegistrySchema = z.object({
29
+ version: z.literal(1),
30
+ installations: z.array(InstallationRecordSchema).max(256),
31
+ }).strict();
32
+ export function findInstallationForProfile(records, profile) {
33
+ if (!PROFILE.test(profile))
34
+ throw new Error("profile name is invalid");
35
+ const matches = records.filter((record) => record.profile === profile);
36
+ if (matches.length > 1)
37
+ throw new Error(`Multiple installations use profile '${profile}'`);
38
+ return matches[0] ?? null;
39
+ }
40
+ const pathsFor = (platform) => platform === "win32" ? win32 : posix;
41
+ function absoluteBase(value, fallback, platform, name) {
42
+ const paths = pathsFor(platform);
43
+ const selected = value ?? fallback;
44
+ if (!paths.isAbsolute(selected) || selected.includes("\0") || selected.includes("\n") || selected.includes("\r")) {
45
+ throw new Error(`${name} must be an absolute single-line path`);
46
+ }
47
+ return paths.normalize(selected);
48
+ }
49
+ export function resolveLocalStorageRoots(input) {
50
+ const paths = pathsFor(input.platform);
51
+ const userHome = absoluteBase(input.userHome, input.userHome, input.platform, "user home");
52
+ if (input.platform === "win32") {
53
+ const local = absoluteBase(input.environment.LOCALAPPDATA, paths.join(userHome, "AppData", "Local"), input.platform, "LOCALAPPDATA");
54
+ const root = paths.join(local, "NowWork");
55
+ return Object.freeze({
56
+ configRoot: paths.join(root, "config"),
57
+ runtimeBase: paths.join(root, "runtime"),
58
+ cacheRoot: paths.join(root, "cache"),
59
+ stateRoot: paths.join(root, "state"),
60
+ backupsRoot: paths.join(root, "state", "backups"),
61
+ runtimePackagesRoot: paths.join(root, "cache", "runtime-packages"),
62
+ });
63
+ }
64
+ if (input.platform === "darwin") {
65
+ const applicationSupport = paths.join(userHome, "Library", "Application Support", "NowWork");
66
+ const cacheRoot = paths.join(userHome, "Library", "Caches", "NowWork");
67
+ return Object.freeze({
68
+ configRoot: paths.join(applicationSupport, "config"),
69
+ runtimeBase: paths.join(cacheRoot, "runtime"),
70
+ cacheRoot,
71
+ stateRoot: paths.join(applicationSupport, "state"),
72
+ backupsRoot: paths.join(applicationSupport, "state", "backups"),
73
+ runtimePackagesRoot: paths.join(cacheRoot, "runtime-packages"),
74
+ });
75
+ }
76
+ const configHome = absoluteBase(input.environment.XDG_CONFIG_HOME, paths.join(userHome, ".config"), input.platform, "XDG_CONFIG_HOME");
77
+ const cacheHome = absoluteBase(input.environment.XDG_CACHE_HOME, paths.join(userHome, ".cache"), input.platform, "XDG_CACHE_HOME");
78
+ const stateHome = absoluteBase(input.environment.XDG_STATE_HOME, paths.join(userHome, ".local", "state"), input.platform, "XDG_STATE_HOME");
79
+ const runtimeHome = absoluteBase(input.environment.XDG_RUNTIME_DIR, paths.join(stateHome, "nowwork", "runtime"), input.platform, "XDG_RUNTIME_DIR");
80
+ const stateRoot = paths.join(stateHome, "nowwork");
81
+ const cacheRoot = paths.join(cacheHome, "nowwork");
82
+ return Object.freeze({
83
+ configRoot: paths.join(configHome, "nowwork"),
84
+ runtimeBase: input.environment.XDG_RUNTIME_DIR === undefined ? runtimeHome : paths.join(runtimeHome, "nowwork"),
85
+ cacheRoot,
86
+ stateRoot,
87
+ backupsRoot: paths.join(stateRoot, "backups"),
88
+ runtimePackagesRoot: paths.join(cacheRoot, "runtime-packages"),
89
+ });
90
+ }
91
+ export function buildStorageConfig(input) {
92
+ if (!PROFILE.test(input.profile))
93
+ throw new Error("profile name is invalid");
94
+ if (!UUID.test(input.workspaceId))
95
+ throw new Error("workspace id must be a UUID");
96
+ const paths = pathsFor(input.platform);
97
+ const local = resolveLocalStorageRoots(input);
98
+ const home = resolveNowworkHome({
99
+ ...(input.nowworkHome === undefined ? {} : { explicit: input.nowworkHome }),
100
+ ...(input.environment.NOWWORK_HOME === undefined ? {} : { environment: input.environment.NOWWORK_HOME }),
101
+ userHome: input.userHome,
102
+ platform: input.platform,
103
+ });
104
+ const credentialRef = `profile:${input.profile}`;
105
+ if (home.mode === "legacy") {
106
+ return Object.freeze({
107
+ mode: "legacy",
108
+ profile: input.profile,
109
+ workspaceId: input.workspaceId.toLowerCase(),
110
+ credentialRef,
111
+ daemonHome: input.legacyDaemonHome ?? paths.join(input.userHome, ".crew", "daemon"),
112
+ agentsRoot: input.legacyAgentsRoot ?? paths.join(input.userHome, ".crew", "agents"),
113
+ layout: null,
114
+ local,
115
+ diagnostics: Object.freeze(["legacy_storage_detected"]),
116
+ });
117
+ }
118
+ const environment = resolveEnvTag({
119
+ ...(input.envTag === undefined ? {} : { explicit: input.envTag }),
120
+ ...(input.environment.NOWWORK_ENV_TAG === undefined ? {} : { environment: input.environment.NOWWORK_ENV_TAG }),
121
+ }).environment;
122
+ const layout = storageLayout({
123
+ nowworkHome: home.root,
124
+ environment,
125
+ workspaceId: input.workspaceId,
126
+ runtimeBase: local.runtimeBase,
127
+ platform: input.platform,
128
+ });
129
+ return Object.freeze({
130
+ mode: "standard",
131
+ profile: input.profile,
132
+ workspaceId: input.workspaceId.toLowerCase(),
133
+ credentialRef,
134
+ daemonHome: local.configRoot,
135
+ agentsRoot: layout.agentItemsRoot,
136
+ layout,
137
+ local,
138
+ diagnostics: Object.freeze([]),
139
+ });
140
+ }
141
+ export function installationsPath(configRoot) {
142
+ const paths = win32.isAbsolute(configRoot) && !posix.isAbsolute(configRoot) ? win32 : posix;
143
+ return paths.join(configRoot, "installations.json");
144
+ }
145
+ export async function readInstallations(input) {
146
+ const path = installationsPath(input.configRoot);
147
+ try {
148
+ const parsed = InstallationRegistrySchema.parse(JSON.parse(await readFile(path, "utf8")));
149
+ return Object.freeze(parsed.installations.map((record) => Object.freeze(record)));
150
+ }
151
+ catch (error) {
152
+ if (error.code === "ENOENT")
153
+ return Object.freeze([]);
154
+ throw new Error(`Invalid installation registry: ${path}`, { cause: error });
155
+ }
156
+ }
157
+ export async function writeInstallations(records, input) {
158
+ let parsed;
159
+ try {
160
+ parsed = InstallationRegistrySchema.parse({ version: 1, installations: records });
161
+ const installationIds = new Set();
162
+ const profiles = new Set();
163
+ for (const record of parsed.installations) {
164
+ if (installationIds.has(record.installationId) || profiles.has(record.profile)) {
165
+ throw new Error("duplicate installation identity");
166
+ }
167
+ installationIds.add(record.installationId);
168
+ profiles.add(record.profile);
169
+ }
170
+ }
171
+ catch (error) {
172
+ throw new Error("Invalid installation metadata", { cause: error });
173
+ }
174
+ await atomicPrivateWrite(installationsPath(input.configRoot), `${JSON.stringify(parsed, null, 2)}\n`);
175
+ }
@@ -0,0 +1,193 @@
1
+ import { z } from "zod";
2
+ export const STORAGE_MIGRATION_CAPABILITY = "storage_migration_v1";
3
+ export const STORAGE_MIGRATION_STATUS_CAPABILITY = "storage_migration_status_v1";
4
+ const IdentitySchema = z.object({
5
+ migrationId: z.string().uuid(),
6
+ environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
7
+ workspaceId: z.string().uuid(),
8
+ });
9
+ const DoctorRequestSchema = IdentitySchema.extend({ type: z.literal("daemon:storage-doctor") }).strict();
10
+ const MigrateRequestSchema = IdentitySchema.extend({
11
+ type: z.literal("daemon:storage-migrate"),
12
+ manifestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
13
+ }).strict();
14
+ const StatusSchema = z.enum([
15
+ "preflight_running", "preflight_passed", "backing_up", "initial_sync", "quiescing", "final_sync", "verified",
16
+ "service_switched", "hello_confirmed", "completed", "failed", "rollback_pending", "rolled_back",
17
+ "rollback_failed",
18
+ ]);
19
+ const TOKEN = /(?:\bsk_(?:machine|user|agent)_[A-Za-z0-9_-]{4,}\b|\bbearer\s+[^\s]+|(?:password|secret|token)\s*[:=]\s*[^\s]+)/giu;
20
+ const safeError = (error) => (error instanceof Error ? error.message : String(error))
21
+ .replaceAll(TOKEN, "<redacted>").slice(0, 500) || "storage migration failed";
22
+ const stableErrorCode = (candidate, fallback) => typeof candidate === "string" && /^[a-z0-9._-]{1,128}$/u.test(candidate) ? candidate : fallback;
23
+ export function createStorageControlController(input) {
24
+ const environment = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u).parse(input.environment);
25
+ const workspaceId = z.string().uuid().parse(input.workspaceId);
26
+ const profile = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u).parse(input.profile);
27
+ const sourceStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.sourceStorageFingerprint);
28
+ const targetStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.targetStorageFingerprint);
29
+ const preflights = new Map();
30
+ const preflightRuns = new Map();
31
+ const appliedMigrationIds = new Set();
32
+ let runningMigrationId = null;
33
+ const preflightTimeoutMs = z.number().int().positive().max(24 * 60 * 60 * 1000)
34
+ .parse(input.preflightTimeoutMs ?? 30 * 60 * 1000);
35
+ const frame = (identity, status, extra = {}) => ({
36
+ type: "daemon:storage-migration-status",
37
+ migrationId: identity.migrationId,
38
+ status,
39
+ environment,
40
+ workspaceId,
41
+ profile,
42
+ ...extra,
43
+ });
44
+ const failure = (identity, errorCode, error, reopenAdmission = false, extra = {}) => {
45
+ input.sendStatus(frame(identity, "failed", {
46
+ errorCode,
47
+ ...extra,
48
+ ...(error === undefined ? {} : { errorMessage: safeError(error) }),
49
+ ...(reopenAdmission || error?.reopenAdmission === true
50
+ ? { reopenAdmission: true }
51
+ : {}),
52
+ }));
53
+ };
54
+ const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId;
55
+ const preflightKey = (identity) => `${identity.migrationId}\0${identity.environment}\0${identity.workspaceId}`;
56
+ const runPreflight = (identity, failureFallback = "doctor_failed") => {
57
+ const key = preflightKey(identity);
58
+ const existing = preflightRuns.get(key);
59
+ if (existing !== undefined)
60
+ return existing;
61
+ const controller = new AbortController();
62
+ let scannedEntries = 0;
63
+ let timedOut = false;
64
+ input.sendStatus(frame(identity, "preflight_running", {
65
+ progress: { state: "running", scannedEntries: 0 },
66
+ }));
67
+ const doctorPromise = Promise.resolve().then(() => input.runDoctor(identity.migrationId, {
68
+ signal: controller.signal,
69
+ onProgress: (progress) => {
70
+ if (timedOut)
71
+ return;
72
+ scannedEntries = progress.scannedEntries;
73
+ input.sendStatus(frame(identity, "preflight_running", {
74
+ progress: { state: "running", scannedEntries },
75
+ }));
76
+ },
77
+ }));
78
+ let timeoutHandle;
79
+ const timeout = new Promise((_, reject) => {
80
+ timeoutHandle = setTimeout(() => {
81
+ timedOut = true;
82
+ controller.abort();
83
+ reject(Object.assign(new Error("storage doctor preflight timed out"), {
84
+ code: "preflight_stale",
85
+ scannedEntries,
86
+ }));
87
+ }, preflightTimeoutMs);
88
+ timeoutHandle.unref?.();
89
+ });
90
+ void doctorPromise.catch(() => undefined);
91
+ let outcome;
92
+ outcome = Promise.race([doctorPromise, timeout]).then((doctor) => {
93
+ if (doctor.manifest.migrationId !== identity.migrationId
94
+ || doctor.environment !== environment || doctor.workspaceId !== workspaceId
95
+ || doctor.profile !== profile) {
96
+ failure(identity, "storage_identity_mismatch", undefined, true);
97
+ return null;
98
+ }
99
+ if (!doctor.applyAllowed) {
100
+ failure(identity, doctor.blockers[0]?.code ?? "doctor_blocked", undefined, true);
101
+ return null;
102
+ }
103
+ preflights.set(identity.migrationId, doctor);
104
+ input.sendStatus(frame(identity, "preflight_passed", {
105
+ sourceStorageFingerprint,
106
+ targetStorageFingerprint,
107
+ manifestDigest: doctor.manifestDigest,
108
+ }));
109
+ return doctor;
110
+ }).catch((error) => {
111
+ const code = error.code;
112
+ if (code === "preflight_stale") {
113
+ failure(identity, "preflight_stale", undefined, true, {
114
+ retryable: true,
115
+ progress: { state: "stale", scannedEntries: Number(error.scannedEntries) || scannedEntries },
116
+ });
117
+ }
118
+ else if (code === "preflight_aborted") {
119
+ failure(identity, "preflight_aborted", error, true, {
120
+ retryable: true,
121
+ progress: { state: "stale", scannedEntries },
122
+ });
123
+ }
124
+ else {
125
+ failure(identity, stableErrorCode(code, failureFallback), error, true);
126
+ }
127
+ return null;
128
+ }).finally(() => {
129
+ if (timeoutHandle !== undefined)
130
+ clearTimeout(timeoutHandle);
131
+ if (preflightRuns.get(key) === outcome)
132
+ preflightRuns.delete(key);
133
+ });
134
+ preflightRuns.set(key, outcome);
135
+ return outcome;
136
+ };
137
+ const handle = async (raw) => {
138
+ const type = typeof raw === "object" && raw !== null && "type" in raw ? raw.type : null;
139
+ if (type !== "daemon:storage-doctor" && type !== "daemon:storage-migrate")
140
+ return false;
141
+ const schema = type === "daemon:storage-doctor" ? DoctorRequestSchema : MigrateRequestSchema;
142
+ const parsed = schema.safeParse(raw);
143
+ if (!parsed.success)
144
+ return true;
145
+ const identity = parsed.data;
146
+ if (!matchesIdentity(identity)) {
147
+ failure(identity, "storage_identity_mismatch", undefined, true);
148
+ return true;
149
+ }
150
+ if (type === "daemon:storage-doctor") {
151
+ await runPreflight(identity);
152
+ return true;
153
+ }
154
+ if (runningMigrationId !== null) {
155
+ if (runningMigrationId !== identity.migrationId)
156
+ failure(identity, "migration_active");
157
+ return true;
158
+ }
159
+ const migrate = MigrateRequestSchema.parse(identity);
160
+ if (appliedMigrationIds.has(identity.migrationId))
161
+ return true;
162
+ let doctor = preflights.get(identity.migrationId);
163
+ if (doctor === undefined) {
164
+ const rebuilt = await runPreflight(identity, "preflight_rebuild_failed");
165
+ if (rebuilt === null)
166
+ return true;
167
+ doctor = rebuilt;
168
+ }
169
+ if (!doctor.applyAllowed || doctor.manifestDigest !== migrate.manifestDigest
170
+ || doctor.manifest.migrationId !== identity.migrationId
171
+ || doctor.environment !== environment || doctor.workspaceId !== workspaceId || doctor.profile !== profile) {
172
+ failure(identity, "preflight_digest_mismatch", undefined, true);
173
+ return true;
174
+ }
175
+ preflights.delete(identity.migrationId);
176
+ runningMigrationId = identity.migrationId;
177
+ try {
178
+ await input.runMigration(doctor, (status) => {
179
+ if (StatusSchema.safeParse(status).success)
180
+ input.sendStatus(frame(identity, status));
181
+ });
182
+ appliedMigrationIds.add(identity.migrationId);
183
+ }
184
+ catch (error) {
185
+ failure(identity, stableErrorCode(error.code, "storage_migration_failed"), error);
186
+ }
187
+ finally {
188
+ runningMigrationId = null;
189
+ }
190
+ return true;
191
+ };
192
+ return Object.freeze({ handle });
193
+ }