@nowcrew/daemon 0.6.50 → 0.6.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/automatic-install-target.js +15 -10
- package/dist/daemon-migration-controller.js +16 -18
- package/dist/machine-info.js +3 -2
- package/dist/main.js +77 -0
- package/dist/profile-layout.js +10 -4
- package/dist/serve.js +64 -3
- package/dist/storage-config.js +175 -0
- package/dist/storage-control.js +131 -0
- package/dist/storage-copy.js +444 -0
- package/dist/storage-credential-guard.js +21 -0
- package/dist/storage-doctor.js +175 -0
- package/dist/storage-identity.js +29 -0
- package/dist/storage-layout.js +143 -0
- package/dist/storage-manifest.js +440 -0
- package/dist/storage-migration-controller.js +417 -0
- package/dist/storage-migration-manifest.js +105 -0
- package/dist/storage-migration-policy.js +100 -0
- package/dist/storage-runtime-controller.js +310 -0
- package/dist/storage-runtime-readiness.js +175 -0
- package/dist/storage-runtime.js +39 -0
- package/dist/storage-safe-fs.js +293 -0
- package/dist/storage-serve-adapter.js +65 -0
- package/dist/storage-status-delivery.js +201 -0
- package/dist/storage-status-replay.js +125 -0
- package/dist/storage-target-confirmation.js +129 -0
- package/dist/storage-worker-command.js +27 -0
- package/dist/storage-worker-executor.js +380 -0
- package/dist/storage-worker-handoff.js +116 -0
- package/dist/storage-worker-operation.js +277 -0
- package/dist/storage-worker-status.js +105 -0
- package/package.json +1 -1
|
@@ -3,12 +3,12 @@ import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
|
|
|
3
3
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
4
4
|
const KNOWN_TARGETS = {
|
|
5
5
|
"nowwork-in.nowcoder.com": {
|
|
6
|
-
|
|
6
|
+
environment: "production",
|
|
7
7
|
daemonDirectory: "daemon",
|
|
8
8
|
agentsDirectory: "agents",
|
|
9
9
|
},
|
|
10
10
|
"nowwork-dev.nowcoder.com": {
|
|
11
|
-
|
|
11
|
+
environment: "dev",
|
|
12
12
|
daemonDirectory: "daemon-dev",
|
|
13
13
|
agentsDirectory: "agents-dev",
|
|
14
14
|
},
|
|
@@ -42,32 +42,37 @@ function requireCustomProfile(hostname, value) {
|
|
|
42
42
|
if (!PROFILE_NAME.test(value)) {
|
|
43
43
|
throw new Error("Invalid custom profile; expected a lowercase name of 1-48 letters, digits, '_' or '-'");
|
|
44
44
|
}
|
|
45
|
-
if (value === "in" || value === "dev") {
|
|
45
|
+
if (value === "in" || value === "dev" || value === "production") {
|
|
46
46
|
throw new Error(`Profile '${value}' is reserved for a known NowWork hostname`);
|
|
47
47
|
}
|
|
48
48
|
return value;
|
|
49
49
|
}
|
|
50
50
|
function knownTargetForProfile(environment, requestedProfile, input, parsed) {
|
|
51
51
|
const paths = input.platform === "win32" ? win32 : posix;
|
|
52
|
-
|
|
52
|
+
// Keep the no-flag target readable by old daemons; any workspace-scoped profile below
|
|
53
|
+
// uses the canonical environment token (production, never in).
|
|
54
|
+
const legacyProfile = environment === "production" ? "in" : environment;
|
|
53
55
|
if (requestedProfile === undefined || requestedProfile === legacyProfile) {
|
|
54
56
|
return Object.freeze({
|
|
55
57
|
kind: "known",
|
|
56
58
|
profile: legacyProfile,
|
|
57
59
|
serverUrl: normalizedServerUrl(parsed),
|
|
58
|
-
daemonHome: paths.join(input.userHome, ".crew", environment === "
|
|
59
|
-
agentsRoot: paths.join(input.userHome, ".crew", environment === "
|
|
60
|
+
daemonHome: paths.join(input.userHome, ".crew", environment === "production" ? "daemon" : "daemon-dev"),
|
|
61
|
+
agentsRoot: paths.join(input.userHome, ".crew", environment === "production" ? "agents" : "agents-dev"),
|
|
60
62
|
npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
|
|
61
63
|
});
|
|
62
64
|
}
|
|
63
65
|
if (requestedProfile === "in" || requestedProfile === "dev") {
|
|
64
|
-
throw new Error(`Server hostname '${new URL(input.serverUrl).hostname}' requires profile '${
|
|
66
|
+
throw new Error(`Server hostname '${new URL(input.serverUrl).hostname}' requires profile '${environment}'`);
|
|
65
67
|
}
|
|
66
68
|
const prefix = `nw-${environment}-`;
|
|
67
|
-
|
|
69
|
+
const legacyPrefix = environment === "production" ? "nw-in-" : prefix;
|
|
70
|
+
if (!requestedProfile.startsWith(prefix) && !requestedProfile.startsWith(legacyPrefix)) {
|
|
68
71
|
throw new Error(`Server hostname '${new URL(input.serverUrl).hostname}' requires profile '${prefix}<local-name>'`);
|
|
69
72
|
}
|
|
70
|
-
const localInstanceName = validateLocalInstanceName(requestedProfile.
|
|
73
|
+
const localInstanceName = validateLocalInstanceName(requestedProfile.startsWith(prefix)
|
|
74
|
+
? requestedProfile.slice(prefix.length)
|
|
75
|
+
: requestedProfile.slice(legacyPrefix.length));
|
|
71
76
|
const layout = profileLayout({
|
|
72
77
|
environment,
|
|
73
78
|
localInstanceName,
|
|
@@ -91,7 +96,7 @@ export function resolveAutomaticInstallTarget(input) {
|
|
|
91
96
|
const paths = input.platform === "win32" ? win32 : posix;
|
|
92
97
|
const known = KNOWN_TARGETS[hostname];
|
|
93
98
|
if (known !== undefined) {
|
|
94
|
-
return knownTargetForProfile(known.
|
|
99
|
+
return knownTargetForProfile(known.environment, input.requestedProfile, input, parsed);
|
|
95
100
|
}
|
|
96
101
|
const profile = requireCustomProfile(hostname, input.requestedProfile);
|
|
97
102
|
return Object.freeze({
|
|
@@ -10,6 +10,7 @@ import { buildServiceSpec, captureExactService, serviceStatus, systemCommandRunn
|
|
|
10
10
|
import { readManagedServiceRegistry } from "./managed-service-registry.js";
|
|
11
11
|
import { uninstallManagedService } from "./managed-service-lifecycle.js";
|
|
12
12
|
import { resolveAutomaticInstallTarget } from "./automatic-install-target.js";
|
|
13
|
+
import { buildStorageMigrationMappings, createSafeServiceManifest, CredentialMaterialError, } from "./storage-migration-manifest.js";
|
|
13
14
|
const MigrationMessageSchema = z.object({
|
|
14
15
|
type: z.literal("daemon:migrate"),
|
|
15
16
|
migrationId: z.string().uuid(),
|
|
@@ -47,33 +48,30 @@ async function hashedFiles(root, directory = root) {
|
|
|
47
48
|
return files;
|
|
48
49
|
}
|
|
49
50
|
async function backupAndCopy(input, id, targetHome, targetAgents, service) {
|
|
51
|
+
// Validate and project service identity before creating any backup bytes. A descriptor containing
|
|
52
|
+
// a token must stop the migration before it can be copied into the local backup root.
|
|
53
|
+
const safeService = createSafeServiceManifest({
|
|
54
|
+
spec: service.spec,
|
|
55
|
+
snapshot: service.snapshot,
|
|
56
|
+
managedRecord: service.managedRecord,
|
|
57
|
+
});
|
|
50
58
|
const backup = migrationBackupRoot(input, id);
|
|
51
59
|
await mkdir(backup, { recursive: true, mode: 0o700 });
|
|
52
60
|
await cp(input.currentDaemonHome, resolve(backup, "daemon-home"), { recursive: true, force: false });
|
|
53
61
|
await cp(input.currentAgentsRoot, resolve(backup, "agents-root"), { recursive: true, force: false });
|
|
54
62
|
await cp(input.currentDaemonHome, targetHome, { recursive: true, force: false });
|
|
55
63
|
await cp(input.currentAgentsRoot, targetAgents, { recursive: true, force: false });
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
];
|
|
64
|
+
const daemonFiles = await hashedFiles(resolve(backup, "daemon-home"));
|
|
65
|
+
const agentFiles = await hashedFiles(resolve(backup, "agents-root"));
|
|
66
|
+
const mappings = buildStorageMigrationMappings({ daemonFiles, agentFiles });
|
|
60
67
|
await writeFile(resolve(backup, "manifest.json"), `${JSON.stringify({
|
|
61
|
-
version:
|
|
68
|
+
version: 2,
|
|
62
69
|
migrationId: id,
|
|
63
70
|
profile: input.profileName,
|
|
64
71
|
daemonHome: input.currentDaemonHome,
|
|
65
72
|
agentsRoot: input.currentAgentsRoot,
|
|
66
|
-
service:
|
|
67
|
-
|
|
68
|
-
platform: service.spec.platform,
|
|
69
|
-
descriptorPath: service.spec.descriptorPath,
|
|
70
|
-
descriptor: service.spec.descriptor,
|
|
71
|
-
daemonCommand: service.spec.daemonCommand,
|
|
72
|
-
installed: service.snapshot.installed,
|
|
73
|
-
running: service.snapshot.running,
|
|
74
|
-
managedRecord: service.managedRecord,
|
|
75
|
-
},
|
|
76
|
-
files,
|
|
73
|
+
service: safeService,
|
|
74
|
+
mappings,
|
|
77
75
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
78
76
|
return backup;
|
|
79
77
|
}
|
|
@@ -147,7 +145,7 @@ export function createDaemonMigrationController(input) {
|
|
|
147
145
|
}
|
|
148
146
|
}
|
|
149
147
|
catch (error) {
|
|
150
|
-
sendFailure(message.migrationId, "target_conflict", error);
|
|
148
|
+
sendFailure(message.migrationId, error instanceof CredentialMaterialError ? "credential_material" : "target_conflict", error);
|
|
151
149
|
return;
|
|
152
150
|
}
|
|
153
151
|
input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "backing_up" });
|
|
@@ -158,7 +156,7 @@ export function createDaemonMigrationController(input) {
|
|
|
158
156
|
}
|
|
159
157
|
catch (error) {
|
|
160
158
|
await Promise.allSettled([rm(targetPath, { recursive: true, force: true }), rm(targetAgents, { recursive: true, force: true })]);
|
|
161
|
-
sendFailure(message.migrationId, "backup_failed", error);
|
|
159
|
+
sendFailure(message.migrationId, error instanceof CredentialMaterialError ? "credential_material" : "backup_failed", error);
|
|
162
160
|
return;
|
|
163
161
|
}
|
|
164
162
|
const install = input.install ?? installDaemonAutomatically;
|
package/dist/machine-info.js
CHANGED
|
@@ -191,10 +191,11 @@ export async function collectMachineHello(agentsRoot, executionLimits, runtimePl
|
|
|
191
191
|
profileName: dependencies.profileName,
|
|
192
192
|
daemonHome: dependencies.daemonHome ?? daemonHome(),
|
|
193
193
|
agentsRoot,
|
|
194
|
-
...(dependencies.profileName.match(/^nw-(?:in|dev)-([a-z0-9][a-z0-9_-]{0,39})$/u)?.[1] === undefined
|
|
194
|
+
...(dependencies.profileName.match(/^nw-(?:production|in|dev)-([a-z0-9][a-z0-9_-]{0,39})$/u)?.[1] === undefined
|
|
195
195
|
? {}
|
|
196
|
-
: { localInstanceName: dependencies.profileName.match(/^nw-(?:in|dev)-([a-z0-9][a-z0-9_-]{0,39})$/u)[1] }),
|
|
196
|
+
: { localInstanceName: dependencies.profileName.match(/^nw-(?:production|in|dev)-([a-z0-9][a-z0-9_-]{0,39})$/u)[1] }),
|
|
197
197
|
}),
|
|
198
|
+
...(dependencies.storageIdentity ?? {}),
|
|
198
199
|
...(backend.supported ? {
|
|
199
200
|
executionProtocol: EXECUTION_PROTOCOL,
|
|
200
201
|
executionLimits: Object.freeze({
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
10
11
|
import { loadConfig, ConfigError } from "./config.js";
|
|
11
12
|
import { detectDaemonLang, formatDaemonText, translateDaemon } from "./i18n.js";
|
|
12
13
|
import { cliVersion, daemonVersion } from "./machine-info.js";
|
|
@@ -21,7 +22,18 @@ import { formatDaemonStartupError } from "./daemon-startup-error.js";
|
|
|
21
22
|
import { assertManagedServiceStartup } from "./managed-service-startup.js";
|
|
22
23
|
import { daemonGlobalInstallation } from "./daemon-installation.js";
|
|
23
24
|
import { acquireDaemonInstallationLease, runWithDaemonInstallationLease, } from "./daemon-installation-lease.js";
|
|
25
|
+
import { loadStorageRuntime } from "./storage-runtime.js";
|
|
26
|
+
import { readStorageMigrationReceipt } from "./storage-migration-controller.js";
|
|
27
|
+
import { storageMigrationStatusReplay } from "./storage-status-replay.js";
|
|
28
|
+
import { runStorageWorkerCommand } from "./storage-worker-command.js";
|
|
29
|
+
import { probeStorageMigrationRuntime } from "./storage-runtime-readiness.js";
|
|
30
|
+
import { createStorageTargetConfirmationHandler } from "./storage-target-confirmation.js";
|
|
24
31
|
async function main() {
|
|
32
|
+
const workerResult = await runStorageWorkerCommand(process.argv.slice(2));
|
|
33
|
+
if (workerResult !== null) {
|
|
34
|
+
process.exitCode = workerResult;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
25
37
|
const computerResult = await runComputerCommand(process.argv.slice(2));
|
|
26
38
|
if (computerResult !== null) {
|
|
27
39
|
process.exitCode = computerResult;
|
|
@@ -57,8 +69,10 @@ async function main() {
|
|
|
57
69
|
// 不把 machine token 放进 argv / plist / systemd unit / scheduled task。
|
|
58
70
|
if (values["daemon-home"])
|
|
59
71
|
process.env.CREW_DAEMON_HOME = values["daemon-home"];
|
|
72
|
+
let activeDaemonHome;
|
|
60
73
|
if (values.profile) {
|
|
61
74
|
const home = daemonHome();
|
|
75
|
+
activeDaemonHome = home;
|
|
62
76
|
const profile = await loadProfile(values.profile, home);
|
|
63
77
|
if (cmd === "serve") {
|
|
64
78
|
await assertProfileAgentsRootUnique(profile, home, homedir());
|
|
@@ -100,9 +114,72 @@ async function main() {
|
|
|
100
114
|
const installationLease = installation === null
|
|
101
115
|
? null
|
|
102
116
|
: await acquireDaemonInstallationLease(installation.npmPrefix);
|
|
117
|
+
const storageRuntime = values.profile === undefined
|
|
118
|
+
? null
|
|
119
|
+
: await loadStorageRuntime({
|
|
120
|
+
profile: values.profile,
|
|
121
|
+
serverUrl: config.serverUrl,
|
|
122
|
+
userHome: homedir(),
|
|
123
|
+
platform: process.platform,
|
|
124
|
+
environment: process.env,
|
|
125
|
+
});
|
|
126
|
+
const migrationRuntime = values.profile === undefined || activeDaemonHome === undefined || storageRuntime !== null
|
|
127
|
+
? { ready: false, reason: "not_legacy_source" }
|
|
128
|
+
: await probeStorageMigrationRuntime({
|
|
129
|
+
profile: values.profile,
|
|
130
|
+
serverUrl: config.serverUrl,
|
|
131
|
+
currentDaemonHome: activeDaemonHome,
|
|
132
|
+
currentAgentsRoot: config.agentsRoot,
|
|
133
|
+
userHome: homedir(),
|
|
134
|
+
platform: process.platform,
|
|
135
|
+
...(process.getuid === undefined ? {} : { uid: process.getuid() }),
|
|
136
|
+
environment: process.env,
|
|
137
|
+
});
|
|
138
|
+
const targetConfirmation = storageRuntime?.identity.storageMigrationId === undefined
|
|
139
|
+
? null
|
|
140
|
+
: createStorageTargetConfirmationHandler({
|
|
141
|
+
backupsRoot: storageRuntime.local.backupsRoot,
|
|
142
|
+
identity: storageRuntime.identity,
|
|
143
|
+
profile: storageRuntime.installation.profile,
|
|
144
|
+
completionTimeoutMs: 180_000,
|
|
145
|
+
});
|
|
146
|
+
const storageMigration = storageRuntime !== null
|
|
147
|
+
? {
|
|
148
|
+
ready: false,
|
|
149
|
+
statusOutboxPath: join(storageRuntime.local.stateRoot, "storage-status-outbox.json"),
|
|
150
|
+
identity: storageRuntime.identity,
|
|
151
|
+
...(targetConfirmation === null ? {} : { confirm: targetConfirmation.handle }),
|
|
152
|
+
...(storageRuntime.identity.storageMigrationId === undefined ? {} : {
|
|
153
|
+
replayStatus: async (send) => {
|
|
154
|
+
const migrationId = storageRuntime.identity.storageMigrationId;
|
|
155
|
+
const receipt = await readStorageMigrationReceipt(storageRuntime.local.backupsRoot, migrationId);
|
|
156
|
+
if (receipt === null)
|
|
157
|
+
throw new Error(`storage migration receipt not found: ${migrationId}`);
|
|
158
|
+
for (const frame of storageMigrationStatusReplay(receipt, {
|
|
159
|
+
migrationId,
|
|
160
|
+
environment: storageRuntime.identity.storageEnvironment,
|
|
161
|
+
workspaceId: storageRuntime.identity.storageWorkspaceId,
|
|
162
|
+
profile: storageRuntime.installation.profile,
|
|
163
|
+
}))
|
|
164
|
+
send(frame);
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
}
|
|
168
|
+
: migrationRuntime.ready
|
|
169
|
+
? {
|
|
170
|
+
ready: true,
|
|
171
|
+
statusOutboxPath: migrationRuntime.runtime.statusOutboxPath,
|
|
172
|
+
identity: migrationRuntime.runtime.identity,
|
|
173
|
+
controller: migrationRuntime.runtime.controller,
|
|
174
|
+
attach: migrationRuntime.runtime.attach,
|
|
175
|
+
bindServerIdentity: migrationRuntime.runtime.bindServerIdentity,
|
|
176
|
+
replayStatus: migrationRuntime.runtime.replayStatus,
|
|
177
|
+
}
|
|
178
|
+
: undefined;
|
|
103
179
|
await runWithDaemonInstallationLease(installationLease, async () => {
|
|
104
180
|
const service = serve(config, {
|
|
105
181
|
...(values.profile === undefined ? {} : { profileName: values.profile }),
|
|
182
|
+
...(storageMigration === undefined ? {} : { storageMigration }),
|
|
106
183
|
});
|
|
107
184
|
await runServeLifecycle(service);
|
|
108
185
|
});
|
package/dist/profile-layout.js
CHANGED
|
@@ -2,6 +2,10 @@ import { posix, win32 } from "node:path";
|
|
|
2
2
|
export const PROFILE_LAYOUT_VERSION = 1;
|
|
3
3
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
|
|
4
4
|
const LOCAL_INSTANCE_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/u;
|
|
5
|
+
/** Accept the legacy `in` tag at the boundary, but never persist it in a new layout. */
|
|
6
|
+
export function normalizeProfileEnvironment(value) {
|
|
7
|
+
return value === "in" ? "production" : value;
|
|
8
|
+
}
|
|
5
9
|
export function validateLocalInstanceName(value) {
|
|
6
10
|
const normalized = value.trim().toLowerCase();
|
|
7
11
|
if (!LOCAL_INSTANCE_NAME.test(normalized) || normalized.includes("..")) {
|
|
@@ -10,19 +14,21 @@ export function validateLocalInstanceName(value) {
|
|
|
10
14
|
return normalized;
|
|
11
15
|
}
|
|
12
16
|
export function profileForLocalInstance(environment, localInstanceName) {
|
|
13
|
-
const
|
|
17
|
+
const canonicalEnvironment = normalizeProfileEnvironment(environment);
|
|
18
|
+
const profile = `nw-${canonicalEnvironment}-${validateLocalInstanceName(localInstanceName)}`;
|
|
14
19
|
if (!PROFILE_NAME.test(profile))
|
|
15
20
|
throw new Error("derived daemon profile exceeds 48 characters");
|
|
16
21
|
return profile;
|
|
17
22
|
}
|
|
18
23
|
export function profileLayout(input) {
|
|
24
|
+
const environment = normalizeProfileEnvironment(input.environment);
|
|
19
25
|
const localInstanceName = validateLocalInstanceName(input.localInstanceName);
|
|
20
|
-
const profile = profileForLocalInstance(
|
|
26
|
+
const profile = profileForLocalInstance(environment, localInstanceName);
|
|
21
27
|
const paths = input.platform === "win32" ? win32 : posix;
|
|
22
|
-
const base = `${
|
|
28
|
+
const base = `${environment}-${localInstanceName}`;
|
|
23
29
|
return Object.freeze({
|
|
24
30
|
layoutVersion: PROFILE_LAYOUT_VERSION,
|
|
25
|
-
environment
|
|
31
|
+
environment,
|
|
26
32
|
localInstanceName,
|
|
27
33
|
profile,
|
|
28
34
|
daemonHome: paths.join(input.userHome, ".crew", `daemon-${base}`),
|
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 = {
|
|
@@ -175,13 +176,36 @@ export function serve(config, opts = {}) {
|
|
|
175
176
|
});
|
|
176
177
|
const machineHelloPublisher = createProjectSkillMachineHelloPublisher({
|
|
177
178
|
status: projectSkillControllerReadiness.status,
|
|
178
|
-
effective:
|
|
179
|
+
effective: (hello, status) => {
|
|
180
|
+
const effective = withProjectSkillCapabilityStatus(hello, status);
|
|
181
|
+
const configuredIdentity = opts.storageMigration?.identity;
|
|
182
|
+
const currentIdentity = typeof configuredIdentity === "function"
|
|
183
|
+
? configuredIdentity()
|
|
184
|
+
: configuredIdentity;
|
|
185
|
+
return currentIdentity === undefined ? effective : { ...effective, ...currentIdentity };
|
|
186
|
+
},
|
|
179
187
|
onSent: (hello) => log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · installed=[${hello.runtimes.join(",")}] · executable=[${hello.executionRuntimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`),
|
|
180
188
|
});
|
|
181
189
|
const initializedProjectSkills = initializedProjectSkillsReconciler(projectSkillControllerReadiness.ensure, projectSkillsReconciler);
|
|
190
|
+
const configuredCapabilities = opts.machineInfo?.capabilities ?? daemonCapabilities(process.platform);
|
|
191
|
+
const storageCapabilities = storageMigrationCapabilities(configuredCapabilities, opts.storageMigration);
|
|
192
|
+
const storageStatusDelivery = createServeStorageStatusDelivery(opts.storageMigration, {
|
|
193
|
+
onPersistenceError: (error) => dslog("daemon.storage_status_outbox_failed", "存储迁移状态 outbox 持久化失败", { level: "ERROR", error_message: error.message }),
|
|
194
|
+
transmit: (frame) => { try {
|
|
195
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
196
|
+
ws.send(JSON.stringify(frame));
|
|
197
|
+
}
|
|
198
|
+
catch { /* retry */ } },
|
|
199
|
+
});
|
|
200
|
+
const storageMigrationRuntime = attachStorageMigrationRuntime(opts.storageMigration, {
|
|
201
|
+
isBusy: () => executionRuns.size > 0 || legacyRuns.size > 0,
|
|
202
|
+
waitForExecutionFrames: () => executionFrameQueue,
|
|
203
|
+
flushLocalState: flushSlog,
|
|
204
|
+
sendStatus: storageStatusDelivery.send,
|
|
205
|
+
});
|
|
182
206
|
const prepareProjectSkillCapabilities = createProjectSkillConnectionCapabilityProvider({
|
|
183
207
|
platform: process.platform,
|
|
184
|
-
baseCapabilities:
|
|
208
|
+
baseCapabilities: storageCapabilities,
|
|
185
209
|
additionalCapabilities: () => managedDaemonCapabilities(updateEligibility),
|
|
186
210
|
probeRuntimeRootStorePrimitive: projectSkillRuntimeRootStartup.probeNativePublicationPrimitiveReadiness,
|
|
187
211
|
initializeController: projectSkillControllerReadiness.ensure,
|
|
@@ -310,6 +334,12 @@ export function serve(config, opts = {}) {
|
|
|
310
334
|
// 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
|
|
311
335
|
void drainSpool();
|
|
312
336
|
// 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
|
|
337
|
+
storageStatusDelivery.replayPending();
|
|
338
|
+
const storageReplay = replayStorageMigrationStatus(opts.storageMigration, openedSocket, () => ws === openedSocket, (error) => {
|
|
339
|
+
dslog("daemon.storage_status_replay_failed", "存储迁移状态重放失败", {
|
|
340
|
+
level: "ERROR", error_message: error.message,
|
|
341
|
+
});
|
|
342
|
+
}, storageStatusDelivery.send);
|
|
313
343
|
const { detectInstalled, detectExecutable = detectExecutionRuntimesWithSignal } = opts.machineInfo ?? {};
|
|
314
344
|
const helloPromise = (opts.machineInfo?.collect ?? collectMachineHello)(config.agentsRoot, config.executionLimits, process.platform, {
|
|
315
345
|
...(detectInstalled ? { detectInstalled } : {}),
|
|
@@ -325,6 +355,7 @@ export function serve(config, opts = {}) {
|
|
|
325
355
|
if (hello.executionProtocol === undefined)
|
|
326
356
|
return [];
|
|
327
357
|
const detected = await runtimeProbe.detect(hello.runtimes, detectExecutable);
|
|
358
|
+
await storageReplay;
|
|
328
359
|
if (ws === openedSocket && openedSocket.readyState === WebSocket.OPEN
|
|
329
360
|
&& detected.some((runtime) => !hello.executionRuntimes.includes(runtime))) {
|
|
330
361
|
detectedExecutionRuntimes = detected;
|
|
@@ -339,7 +370,8 @@ export function serve(config, opts = {}) {
|
|
|
339
370
|
return [];
|
|
340
371
|
});
|
|
341
372
|
void helloPromise
|
|
342
|
-
.then((hello) => {
|
|
373
|
+
.then(async (hello) => {
|
|
374
|
+
await storageReplay;
|
|
343
375
|
if (ws !== openedSocket || openedSocket.readyState !== WebSocket.OPEN)
|
|
344
376
|
return;
|
|
345
377
|
detectedExecutionRuntimes = hello.executionRuntimes;
|
|
@@ -387,6 +419,8 @@ export function serve(config, opts = {}) {
|
|
|
387
419
|
if (typeof decoded !== "object" || decoded === null)
|
|
388
420
|
return;
|
|
389
421
|
const rawType = "type" in decoded && typeof decoded.type === "string" ? decoded.type : "";
|
|
422
|
+
if (storageStatusDelivery.acknowledge(decoded))
|
|
423
|
+
return;
|
|
390
424
|
if (rawType === "daemon:update" || rawType === "daemon:restart") {
|
|
391
425
|
void updateController.handle(decoded).catch((error) => {
|
|
392
426
|
dslog("daemon.control_failed", "daemon 控制操作处理失败", {
|
|
@@ -403,6 +437,11 @@ export function serve(config, opts = {}) {
|
|
|
403
437
|
void codexHomeModeController.handle(decoded).catch((error) => dslog("daemon.codex_home_mode_failed", "Codex Home 模式切换失败", { level: "ERROR", error_message: error.message }));
|
|
404
438
|
return;
|
|
405
439
|
}
|
|
440
|
+
if (handleStorageMigrationControl(opts.storageMigration, rawType, decoded, storageStatusDelivery.send, (kind, error) => {
|
|
441
|
+
dslog(kind === "migration" ? "daemon.storage_migration_failed" : "daemon.storage_migration_confirmation_failed", kind === "migration" ? "存储迁移控制操作失败" : "存储迁移 hello 确认处理失败", { level: "ERROR", error_message: error.message });
|
|
442
|
+
})) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
406
445
|
if (rawType.startsWith("execution:")) {
|
|
407
446
|
const parsedExecution = ServerToDaemonExecutionFrameSchema.safeParse(decoded);
|
|
408
447
|
if (!parsedExecution.success) {
|
|
@@ -464,6 +503,14 @@ export function serve(config, opts = {}) {
|
|
|
464
503
|
return;
|
|
465
504
|
}
|
|
466
505
|
const spec = frame;
|
|
506
|
+
if (storageMigrationRuntime.isAdmissionClosed()) {
|
|
507
|
+
safeExecutionSend(ExecutionRejectedSchema.parse({
|
|
508
|
+
type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
|
|
509
|
+
reason: "resource_limit", message: "Storage migration is quiescing this machine",
|
|
510
|
+
at: new Date().toISOString(),
|
|
511
|
+
}));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
467
514
|
const workspaceRejection = projectWorkspaceCapabilityRejection(spec, capabilities.executionRunner, new Date().toISOString());
|
|
468
515
|
if (workspaceRejection !== null)
|
|
469
516
|
return void safeExecutionSend(workspaceRejection);
|
|
@@ -770,6 +817,17 @@ export function serve(config, opts = {}) {
|
|
|
770
817
|
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
771
818
|
const r = msg;
|
|
772
819
|
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
820
|
+
if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
|
|
821
|
+
try {
|
|
822
|
+
opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
|
|
823
|
+
machineHelloPublisher.republish();
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
dslog("daemon.storage_identity_bind_failed", "存储迁移 Workspace 身份绑定失败", {
|
|
827
|
+
level: "ERROR", error_message: error.message,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
773
831
|
// 旧 server 无此字段 → 空集合(daemon 保持全部旧行为)。
|
|
774
832
|
serverCapabilities = new Set(Array.isArray(r.serverCapabilities)
|
|
775
833
|
? r.serverCapabilities.filter((c) => typeof c === "string")
|
|
@@ -868,6 +926,8 @@ export function serve(config, opts = {}) {
|
|
|
868
926
|
}
|
|
869
927
|
if (msg.type !== "agent:start")
|
|
870
928
|
return; // ready/error 等忽略
|
|
929
|
+
if (storageMigrationRuntime.isAdmissionClosed())
|
|
930
|
+
return;
|
|
871
931
|
// 任务键:scheduled run 用 runId(每次运行独立 cwd/work-log;overlap 由 server 端 skip_if_running 管,
|
|
872
932
|
// daemon 的 running 去重只兜底"同一 run 重复投递");普通唤醒仍是 线程锚点 ?? 频道。
|
|
873
933
|
const scheduled = msg.reason === "scheduled_job" && msg.scheduledRun
|
|
@@ -1283,6 +1343,7 @@ export function serve(config, opts = {}) {
|
|
|
1283
1343
|
concurrencyTelemetry.stop();
|
|
1284
1344
|
runtimeProbe.stop();
|
|
1285
1345
|
completionRetransmitter.stop();
|
|
1346
|
+
storageStatusDelivery.stop();
|
|
1286
1347
|
const deadline = createShutdownDeadline(shutdownTimeoutMs);
|
|
1287
1348
|
if (reconnectTimer !== null) {
|
|
1288
1349
|
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
|
+
}
|