@nowcrew/daemon 0.6.16 → 0.6.18
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/agent-ability/runtime-context.js +7 -1
- package/dist/atomic-private-write.js +54 -1
- package/dist/automatic-install-target.js +40 -11
- package/dist/console.js +9 -0
- package/dist/control-plane-url.js +2 -2
- package/dist/daemon-migration-controller.js +198 -0
- package/dist/daemon-migration-wiring.js +22 -0
- package/dist/daemon-update-eligibility.js +1 -1
- package/dist/directory-projection-identity.js +32 -0
- package/dist/directory-projection.js +922 -0
- package/dist/execution-protocol.js +78 -11
- package/dist/execution-runner.js +50 -2
- package/dist/i18n.js +1 -0
- package/dist/local-execution-prompt.js +57 -0
- package/dist/local-executor.js +99 -40
- package/dist/machine-info.js +45 -9
- package/dist/main.js +0 -0
- package/dist/normalize.js +5 -0
- package/dist/profile-layout.js +41 -0
- package/dist/project-skills/controller.js +74 -14
- package/dist/project-skills/execution-adapter.js +11 -0
- package/dist/project-skills/initialized-reconciler.js +20 -0
- package/dist/project-skills/projection-set-switch.js +419 -0
- package/dist/project-skills/projection-state-domain.js +153 -0
- package/dist/project-skills/projection-state-store.js +841 -0
- package/dist/project-skills/projection-state-transaction.js +318 -0
- package/dist/project-skills/projection-state.js +3 -0
- package/dist/project-skills/reconciler.js +299 -68
- package/dist/project-skills/runtime-warning.js +6 -0
- package/dist/project-skills/scanner.js +30 -1
- package/dist/project-skills/types.js +9 -0
- package/dist/project-workspaces/resolver.js +179 -0
- package/dist/project-workspaces/types.js +1 -0
- package/dist/prompt.js +40 -0
- package/dist/runtimes/claude.js +235 -4
- package/dist/runtimes/codex-app-server-runner.js +100 -25
- package/dist/runtimes/codex-contract.js +123 -0
- package/dist/runtimes/codex.js +2 -0
- package/dist/serve.js +31 -17
- package/dist/session.js +3 -0
- package/dist/supervised-runtime.js +12 -4
- package/dist/workspace.js +14 -5
- package/package.json +10 -9
|
@@ -135,5 +135,11 @@ export async function loadAgentAbilityRuntimeContext(agentRoot, trainingRoot, ab
|
|
|
135
135
|
`- ${join(trainingRoot, "workspace")}`,
|
|
136
136
|
"Eval assets are provenance only and are not injected into task context.",
|
|
137
137
|
].join("\n");
|
|
138
|
-
return {
|
|
138
|
+
return {
|
|
139
|
+
prompt,
|
|
140
|
+
instructionBytes,
|
|
141
|
+
memoryBytes,
|
|
142
|
+
skillCount: skills.length,
|
|
143
|
+
skillRoot: join(trainingRoot, "skills"),
|
|
144
|
+
};
|
|
139
145
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, open, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
export async function atomicPrivateWrite(path, content, beforeCommit) {
|
|
5
5
|
const directory = dirname(path);
|
|
@@ -16,3 +16,56 @@ export async function atomicPrivateWrite(path, content, beforeCommit) {
|
|
|
16
16
|
await rm(temporary, { force: true });
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
const durableFileSystem = (options) => ({
|
|
20
|
+
chmod: options.fs?.chmod ?? chmod,
|
|
21
|
+
open: options.fs?.open ?? open,
|
|
22
|
+
rename: options.fs?.rename ?? rename,
|
|
23
|
+
rm: options.fs?.rm ?? rm,
|
|
24
|
+
unlink: options.fs?.unlink ?? unlink,
|
|
25
|
+
});
|
|
26
|
+
const syncDirectory = async (directory, fs) => {
|
|
27
|
+
const handle = await fs.open(directory, "r");
|
|
28
|
+
try {
|
|
29
|
+
await handle.sync();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** Persists prior directory-entry mutations in an existing directory. */
|
|
36
|
+
export async function durableDirectorySync(directory, options = {}) {
|
|
37
|
+
await syncDirectory(directory, durableFileSystem(options));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Atomically replaces a private file below an existing parent and does not resolve until both the
|
|
41
|
+
* new file contents and parent-directory rename are durable. Callers may therefore use resolution
|
|
42
|
+
* as a write-ahead boundary before mutating other filesystem state. Windows callers must remain
|
|
43
|
+
* capability-gated until native directory-fsync behavior is validated.
|
|
44
|
+
*/
|
|
45
|
+
export async function durableAtomicPrivateWrite(path, content, options = {}) {
|
|
46
|
+
const fs = durableFileSystem(options);
|
|
47
|
+
const directory = options.parentDirectory ?? dirname(path);
|
|
48
|
+
await fs.chmod(directory, 0o700);
|
|
49
|
+
const temporary = `${path}.${(options.randomId ?? randomUUID)()}.tmp`;
|
|
50
|
+
let handle = null;
|
|
51
|
+
try {
|
|
52
|
+
handle = await fs.open(temporary, "wx", 0o600);
|
|
53
|
+
await handle.writeFile(content, "utf8");
|
|
54
|
+
await handle.sync();
|
|
55
|
+
await handle.close();
|
|
56
|
+
handle = null;
|
|
57
|
+
await fs.rename(temporary, path);
|
|
58
|
+
await syncDirectory(directory, fs);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (handle !== null)
|
|
62
|
+
await handle.close().catch(() => undefined);
|
|
63
|
+
await fs.rm(temporary, { force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Removes a durable journal record and persists the directory-entry deletion. */
|
|
67
|
+
export async function durablePrivateUnlink(path, options = {}) {
|
|
68
|
+
const fs = durableFileSystem(options);
|
|
69
|
+
await fs.unlink(path);
|
|
70
|
+
await syncDirectory(options.parentDirectory ?? dirname(path), fs);
|
|
71
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { posix, win32 } from "node:path";
|
|
2
|
+
import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
|
|
2
3
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
3
4
|
const KNOWN_TARGETS = {
|
|
4
5
|
"nowwork-in.nowcoder.com": {
|
|
@@ -46,23 +47,51 @@ function requireCustomProfile(hostname, value) {
|
|
|
46
47
|
}
|
|
47
48
|
return value;
|
|
48
49
|
}
|
|
50
|
+
function knownTargetForProfile(environment, requestedProfile, input, parsed) {
|
|
51
|
+
const paths = input.platform === "win32" ? win32 : posix;
|
|
52
|
+
const legacyProfile = environment;
|
|
53
|
+
if (requestedProfile === undefined || requestedProfile === legacyProfile) {
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
kind: "known",
|
|
56
|
+
profile: legacyProfile,
|
|
57
|
+
serverUrl: normalizedServerUrl(parsed),
|
|
58
|
+
daemonHome: paths.join(input.userHome, ".crew", environment === "in" ? "daemon" : "daemon-dev"),
|
|
59
|
+
agentsRoot: paths.join(input.userHome, ".crew", environment === "in" ? "agents" : "agents-dev"),
|
|
60
|
+
npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", legacyProfile),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (requestedProfile === "in" || requestedProfile === "dev") {
|
|
64
|
+
throw new Error(`Server hostname '${new URL(input.serverUrl).hostname}' requires profile '${legacyProfile}'`);
|
|
65
|
+
}
|
|
66
|
+
const prefix = `nw-${environment}-`;
|
|
67
|
+
if (!requestedProfile.startsWith(prefix)) {
|
|
68
|
+
throw new Error(`Server hostname '${new URL(input.serverUrl).hostname}' requires profile '${prefix}<local-name>'`);
|
|
69
|
+
}
|
|
70
|
+
const localInstanceName = validateLocalInstanceName(requestedProfile.slice(prefix.length));
|
|
71
|
+
const layout = profileLayout({
|
|
72
|
+
environment,
|
|
73
|
+
localInstanceName,
|
|
74
|
+
userHome: input.userHome,
|
|
75
|
+
platform: input.platform,
|
|
76
|
+
});
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
kind: "known",
|
|
79
|
+
environment,
|
|
80
|
+
localInstanceName,
|
|
81
|
+
profile: layout.profile,
|
|
82
|
+
serverUrl: normalizedServerUrl(parsed),
|
|
83
|
+
daemonHome: layout.daemonHome,
|
|
84
|
+
agentsRoot: layout.agentsRoot,
|
|
85
|
+
npmPrefix: layout.npmPrefix,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
49
88
|
export function resolveAutomaticInstallTarget(input) {
|
|
50
89
|
const parsed = parseServerUrl(input.serverUrl);
|
|
51
90
|
const hostname = parsed.hostname.toLowerCase();
|
|
52
91
|
const paths = input.platform === "win32" ? win32 : posix;
|
|
53
92
|
const known = KNOWN_TARGETS[hostname];
|
|
54
93
|
if (known !== undefined) {
|
|
55
|
-
|
|
56
|
-
throw new Error(`Server hostname '${hostname}' requires profile '${known.profile}'`);
|
|
57
|
-
}
|
|
58
|
-
return Object.freeze({
|
|
59
|
-
kind: "known",
|
|
60
|
-
profile: known.profile,
|
|
61
|
-
serverUrl: normalizedServerUrl(parsed),
|
|
62
|
-
daemonHome: paths.join(input.userHome, ".crew", known.daemonDirectory),
|
|
63
|
-
agentsRoot: paths.join(input.userHome, ".crew", known.agentsDirectory),
|
|
64
|
-
npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", known.profile),
|
|
65
|
-
});
|
|
94
|
+
return knownTargetForProfile(known.profile, input.requestedProfile, input, parsed);
|
|
66
95
|
}
|
|
67
96
|
const profile = requireCustomProfile(hostname, input.requestedProfile);
|
|
68
97
|
return Object.freeze({
|
package/dist/console.js
CHANGED
|
@@ -17,6 +17,7 @@ import { buildFilePreview, buildSnippetDiff } from "./console-payload.js";
|
|
|
17
17
|
import { parseUnifiedDiff } from "./unified-diff.js";
|
|
18
18
|
import { buildJsonResult } from "./json-result.js";
|
|
19
19
|
import { buildCollapsedResult } from "./console-collapse.js";
|
|
20
|
+
import { boundedCodexPlan, codexPlanText } from "./runtimes/codex-contract.js";
|
|
20
21
|
/** 单条工具返回正文上限(超出截断并标注),避免单条把终端/DB 撑爆。 */
|
|
21
22
|
export const TOOL_RESULT_CAP = 4000;
|
|
22
23
|
/** 工具输入摘要上限(标题行那一段)。 */
|
|
@@ -273,6 +274,14 @@ export function toConsoleLines(event) {
|
|
|
273
274
|
if (e.type === "thread.started") {
|
|
274
275
|
return [{ stream: "system", text: `● ${td("Codex session started")}` }];
|
|
275
276
|
}
|
|
277
|
+
if (e.type === "turn.plan.updated") {
|
|
278
|
+
const plan = boundedCodexPlan(e.plan);
|
|
279
|
+
return plan === null ? [] : [{
|
|
280
|
+
stream: "tool",
|
|
281
|
+
text: `⏺ ${td("Plan updated")} (${plan.length})`,
|
|
282
|
+
payload: { kind: "plan", plan: codexPlanText(plan) },
|
|
283
|
+
}];
|
|
284
|
+
}
|
|
276
285
|
if (e.type === "item.completed" && e.item) {
|
|
277
286
|
return codexItemChunks(e.item, td);
|
|
278
287
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { executionBackendCapability } from "./execution-backend.js";
|
|
2
2
|
import { daemonCapabilities, EXECUTION_PROTOCOL } from "./machine-info.js";
|
|
3
3
|
import { PROJECT_SKILLS_CAPABILITY } from "./project-skills/types.js";
|
|
4
|
-
export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe, projectSkillsAvailable = true) {
|
|
4
|
+
export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe, projectSkillsAvailable = true, capabilities = daemonCapabilities(runtimePlatform)) {
|
|
5
5
|
const query = new URLSearchParams({ key: machineToken });
|
|
6
6
|
if (executionBackendCapability(runtimePlatform, jobObjectProbe).supported) {
|
|
7
7
|
query.set("execution_min", String(EXECUTION_PROTOCOL.min));
|
|
8
8
|
query.set("execution_max", String(EXECUTION_PROTOCOL.max));
|
|
9
9
|
}
|
|
10
|
-
for (const capability of
|
|
10
|
+
for (const capability of capabilities) {
|
|
11
11
|
if (capability !== PROJECT_SKILLS_CAPABILITY || projectSkillsAvailable) {
|
|
12
12
|
query.append("capability", capability);
|
|
13
13
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { loadProfile } from "./computer-profile.js";
|
|
7
|
+
import { builtDaemonEntry } from "./computer-cli.js";
|
|
8
|
+
import { installDaemonAutomatically } from "./automatic-daemon-installer.js";
|
|
9
|
+
import { buildServiceSpec, captureExactService, serviceStatus, systemCommandRunner } from "./computer-service.js";
|
|
10
|
+
import { readManagedServiceRegistry } from "./managed-service-registry.js";
|
|
11
|
+
import { uninstallManagedService } from "./managed-service-lifecycle.js";
|
|
12
|
+
import { resolveAutomaticInstallTarget } from "./automatic-install-target.js";
|
|
13
|
+
const MigrationMessageSchema = z.object({
|
|
14
|
+
type: z.literal("daemon:migrate"),
|
|
15
|
+
migrationId: z.string().uuid(),
|
|
16
|
+
targetProfile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/),
|
|
17
|
+
targetDaemonHome: z.string().min(1),
|
|
18
|
+
targetAgentsRoot: z.string().min(1),
|
|
19
|
+
}).strict();
|
|
20
|
+
function migrationBackupRoot(input, id) {
|
|
21
|
+
return resolve(input.backupRoot ?? resolve(input.userHome ?? homedir(), ".crew", "migration-backups"), id);
|
|
22
|
+
}
|
|
23
|
+
async function assertEmptyOrMissing(path) {
|
|
24
|
+
try {
|
|
25
|
+
const entries = await readdir(path);
|
|
26
|
+
if (entries.length > 0)
|
|
27
|
+
throw new Error(`target path is not empty: ${path}`);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code !== "ENOENT")
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function hashedFiles(root, directory = root) {
|
|
35
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
36
|
+
const files = [];
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const path = resolve(directory, entry.name);
|
|
39
|
+
const stat = await lstat(path);
|
|
40
|
+
if (stat.isSymbolicLink())
|
|
41
|
+
throw new Error(`backup source contains a symbolic link: ${relative(root, path)}`);
|
|
42
|
+
if (stat.isDirectory())
|
|
43
|
+
files.push(...await hashedFiles(root, path));
|
|
44
|
+
else if (stat.isFile())
|
|
45
|
+
files.push({ path: relative(root, path), sha256: createHash("sha256").update(await readFile(path)).digest("hex") });
|
|
46
|
+
}
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
async function backupAndCopy(input, id, targetHome, targetAgents, service) {
|
|
50
|
+
const backup = migrationBackupRoot(input, id);
|
|
51
|
+
await mkdir(backup, { recursive: true, mode: 0o700 });
|
|
52
|
+
await cp(input.currentDaemonHome, resolve(backup, "daemon-home"), { recursive: true, force: false });
|
|
53
|
+
await cp(input.currentAgentsRoot, resolve(backup, "agents-root"), { recursive: true, force: false });
|
|
54
|
+
await cp(input.currentDaemonHome, targetHome, { recursive: true, force: false });
|
|
55
|
+
await cp(input.currentAgentsRoot, targetAgents, { recursive: true, force: false });
|
|
56
|
+
const files = [
|
|
57
|
+
...(await hashedFiles(resolve(backup, "daemon-home"))).map((file) => ({ ...file, path: `daemon-home/${file.path}` })),
|
|
58
|
+
...(await hashedFiles(resolve(backup, "agents-root"))).map((file) => ({ ...file, path: `agents-root/${file.path}` })),
|
|
59
|
+
];
|
|
60
|
+
await writeFile(resolve(backup, "manifest.json"), `${JSON.stringify({
|
|
61
|
+
version: 1,
|
|
62
|
+
migrationId: id,
|
|
63
|
+
profile: input.profileName,
|
|
64
|
+
daemonHome: input.currentDaemonHome,
|
|
65
|
+
agentsRoot: input.currentAgentsRoot,
|
|
66
|
+
service: {
|
|
67
|
+
id: service.spec.id,
|
|
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,
|
|
77
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
78
|
+
return backup;
|
|
79
|
+
}
|
|
80
|
+
async function cleanupCurrentServiceDefault(spec, input) {
|
|
81
|
+
const userHome = input.userHome ?? homedir();
|
|
82
|
+
const records = await readManagedServiceRegistry({ userHome });
|
|
83
|
+
const record = records.find((candidate) => candidate.profile === spec.profile && candidate.daemonHome === input.currentDaemonHome);
|
|
84
|
+
if (record !== undefined && (spec.platform === "darwin" || spec.platform === "linux")) {
|
|
85
|
+
await uninstallManagedService({ spec, record, runner: systemCommandRunner, registry: { userHome } });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Windows and legacy descriptors are handled by the normal CLI/service lifecycle.
|
|
89
|
+
const { uninstallService } = await import("./computer-service.js");
|
|
90
|
+
const status = await serviceStatus(spec, systemCommandRunner);
|
|
91
|
+
if (status.installed)
|
|
92
|
+
await uninstallService(spec, systemCommandRunner);
|
|
93
|
+
}
|
|
94
|
+
export function createDaemonMigrationController(input) {
|
|
95
|
+
const handled = new Set();
|
|
96
|
+
let running = null;
|
|
97
|
+
const sendFailure = (id, errorCode, error) => {
|
|
98
|
+
input.sendStatus({ type: "daemon:migration-status", migrationId: id, status: "failed", errorCode, errorMessage: error instanceof Error ? error.message : String(error) });
|
|
99
|
+
};
|
|
100
|
+
const execute = async (raw) => {
|
|
101
|
+
const parsed = MigrationMessageSchema.safeParse(raw);
|
|
102
|
+
if (!parsed.success)
|
|
103
|
+
return false;
|
|
104
|
+
const message = parsed.data;
|
|
105
|
+
if (handled.has(message.migrationId))
|
|
106
|
+
return true;
|
|
107
|
+
if (running !== null) {
|
|
108
|
+
sendFailure(message.migrationId, "target_conflict", new Error("another migration is already running"));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
running = (async () => {
|
|
112
|
+
input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "preflight" });
|
|
113
|
+
if (input.isBusy()) {
|
|
114
|
+
sendFailure(message.migrationId, "runtime_busy", new Error("active executions must finish before migration"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const platform = input.platform ?? process.platform;
|
|
118
|
+
const userHome = input.userHome ?? homedir();
|
|
119
|
+
let targetPath;
|
|
120
|
+
let targetAgents;
|
|
121
|
+
let currentSpec;
|
|
122
|
+
let currentService;
|
|
123
|
+
let managedRecord = null;
|
|
124
|
+
try {
|
|
125
|
+
const currentProfile = await (input.loadCurrentProfile ?? loadProfile)(input.profileName, input.currentDaemonHome);
|
|
126
|
+
if (currentProfile.machineToken !== input.machineToken)
|
|
127
|
+
throw new Error("active profile token does not match this daemon");
|
|
128
|
+
const target = resolveAutomaticInstallTarget({ serverUrl: input.serverUrl, requestedProfile: message.targetProfile, userHome, platform });
|
|
129
|
+
if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
|
|
130
|
+
|| resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
|
|
131
|
+
|| resolve(message.targetAgentsRoot) !== resolve(target.agentsRoot))
|
|
132
|
+
throw new Error("requested target does not match the standard profile layout");
|
|
133
|
+
targetPath = resolve(target.daemonHome);
|
|
134
|
+
targetAgents = resolve(target.agentsRoot);
|
|
135
|
+
await assertEmptyOrMissing(targetPath);
|
|
136
|
+
await assertEmptyOrMissing(targetAgents);
|
|
137
|
+
currentSpec = buildServiceSpec({ platform, profile: input.profileName, userHome, uid: process.getuid?.(), nodePath: process.execPath, entryPath: builtDaemonEntry(), profileHome: input.currentDaemonHome });
|
|
138
|
+
const exact = await (input.captureCurrentService ?? ((spec) => captureExactService(spec, systemCommandRunner)))(currentSpec);
|
|
139
|
+
if (!exact.installed || !exact.running || !exact.exact)
|
|
140
|
+
throw new Error("current managed service identity is not exact and running");
|
|
141
|
+
currentService = { installed: exact.installed, running: exact.running };
|
|
142
|
+
if (platform === "darwin" || platform === "linux") {
|
|
143
|
+
const records = await (input.readManagedRecords ?? (() => readManagedServiceRegistry({ userHome })))();
|
|
144
|
+
managedRecord = records.find((record) => record.serviceId === currentSpec.id && record.profile === input.profileName && record.daemonHome === input.currentDaemonHome) ?? null;
|
|
145
|
+
if (managedRecord === null)
|
|
146
|
+
throw new Error("current managed service registry record is missing");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
sendFailure(message.migrationId, "target_conflict", error);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "backing_up" });
|
|
154
|
+
let backupPath;
|
|
155
|
+
try {
|
|
156
|
+
backupPath = await backupAndCopy(input, message.migrationId, targetPath, targetAgents, { spec: currentSpec, snapshot: currentService, managedRecord });
|
|
157
|
+
input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "installing", backupPath });
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await Promise.allSettled([rm(targetPath, { recursive: true, force: true }), rm(targetAgents, { recursive: true, force: true })]);
|
|
161
|
+
sendFailure(message.migrationId, "backup_failed", error);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const install = input.install ?? installDaemonAutomatically;
|
|
165
|
+
try {
|
|
166
|
+
const result = await install({
|
|
167
|
+
serverUrl: input.serverUrl,
|
|
168
|
+
machineToken: input.machineToken,
|
|
169
|
+
requestedProfile: message.targetProfile,
|
|
170
|
+
});
|
|
171
|
+
if (result.profile !== message.targetProfile || result.serviceId.length === 0) {
|
|
172
|
+
throw new Error("target service identity did not match the requested profile");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
await Promise.allSettled([rm(targetPath, { recursive: true, force: true }), rm(targetAgents, { recursive: true, force: true })]);
|
|
177
|
+
sendFailure(message.migrationId, "install_failed", error);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
input.sendStatus({ type: "daemon:migration-status", migrationId: message.migrationId, status: "restarting" });
|
|
181
|
+
try {
|
|
182
|
+
await (input.cleanupCurrentService ?? ((spec) => cleanupCurrentServiceDefault(spec, input)))(currentSpec);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const targetSpec = buildServiceSpec({ platform, profile: message.targetProfile, userHome, uid: process.getuid?.(), nodePath: process.execPath, entryPath: builtDaemonEntry(), profileHome: targetPath });
|
|
186
|
+
await (input.cleanupTargetService ?? ((spec) => cleanupCurrentServiceDefault(spec, { ...input, profileName: message.targetProfile, currentDaemonHome: targetPath })))(targetSpec).catch(() => undefined);
|
|
187
|
+
sendFailure(message.migrationId, "service_cleanup_failed", error);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
})().finally(() => {
|
|
191
|
+
handled.add(message.migrationId);
|
|
192
|
+
running = null;
|
|
193
|
+
});
|
|
194
|
+
await running;
|
|
195
|
+
return true;
|
|
196
|
+
};
|
|
197
|
+
return { handle: execute, drain: async () => { await running; } };
|
|
198
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { daemonHome } from "./computer-profile.js";
|
|
2
|
+
import { createDaemonMigrationController } from "./daemon-migration-controller.js";
|
|
3
|
+
export function createServeMigrationController(input) {
|
|
4
|
+
if (input.profileName === undefined)
|
|
5
|
+
return null;
|
|
6
|
+
return createDaemonMigrationController({
|
|
7
|
+
serverUrl: input.config.serverUrl,
|
|
8
|
+
machineToken: input.config.machineToken,
|
|
9
|
+
profileName: input.profileName,
|
|
10
|
+
currentDaemonHome: daemonHome(),
|
|
11
|
+
currentAgentsRoot: input.config.agentsRoot,
|
|
12
|
+
isBusy: input.isBusy,
|
|
13
|
+
sendStatus: input.sendStatus,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function handleMigrationControlMessage(controller, decoded) {
|
|
17
|
+
if (controller === null || typeof decoded !== "object" || decoded === null
|
|
18
|
+
|| !("type" in decoded) || decoded.type !== "daemon:migrate")
|
|
19
|
+
return false;
|
|
20
|
+
await controller.handle(decoded);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
@@ -13,7 +13,7 @@ import { assertRegistryCoversDescriptors, listManagedServiceDescriptorPaths, man
|
|
|
13
13
|
export async function managedDaemonCapabilities(eligibility) {
|
|
14
14
|
try {
|
|
15
15
|
return (await eligibility()).eligible
|
|
16
|
-
? ["daemon_update_v1", "daemon_restart_v1"]
|
|
16
|
+
? ["daemon_update_v1", "daemon_restart_v1", "daemon_layout_migration_v1"]
|
|
17
17
|
: [];
|
|
18
18
|
}
|
|
19
19
|
catch {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
const errorCode = (error) => error.code;
|
|
3
|
+
/** Reads one directory identity without narrowing filesystem bigint values through JavaScript numbers. */
|
|
4
|
+
export const readExactDirectoryIdentity = async (path, statPath = lstat) => {
|
|
5
|
+
try {
|
|
6
|
+
const info = await statPath(path, { bigint: true });
|
|
7
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
8
|
+
throw new Error("directory_projection_artifact_unmanaged");
|
|
9
|
+
}
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
dev: String(info.dev),
|
|
12
|
+
ino: String(info.ino),
|
|
13
|
+
birthtimeNs: String(info.birthtimeNs),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (errorCode(error) === "ENOENT")
|
|
18
|
+
return null;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export const sameExactDirectoryIdentity = (left, right) => left.dev === right.dev
|
|
23
|
+
&& left.ino === right.ino
|
|
24
|
+
&& left.birthtimeNs === right.birthtimeNs;
|
|
25
|
+
export const validExactDirectoryIdentity = (value) => {
|
|
26
|
+
const candidate = value;
|
|
27
|
+
return candidate !== null
|
|
28
|
+
&& typeof candidate === "object"
|
|
29
|
+
&& typeof candidate.dev === "string"
|
|
30
|
+
&& typeof candidate.ino === "string"
|
|
31
|
+
&& typeof candidate.birthtimeNs === "string";
|
|
32
|
+
};
|