@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.
- package/dist/agent-ability/materializer.js +30 -1
- package/dist/agent-ability/resolver.js +27 -16
- package/dist/agent-ability/runtime.js +3 -0
- package/dist/agent-ability/types.js +6 -11
- package/dist/automatic-install-target.js +15 -10
- package/dist/background-job.js +50 -0
- package/dist/computer-cli.js +10 -1
- package/dist/computer-service.js +41 -1
- package/dist/config.js +44 -0
- package/dist/console-formatter.js +76 -1
- package/dist/console.js +23 -4
- package/dist/daemon-migration-controller.js +16 -18
- package/dist/execution-protocol.js +0 -1
- package/dist/execution-runner.js +60 -11
- package/dist/i18n.js +2 -0
- package/dist/local-execution-prompt.js +32 -13
- package/dist/local-executor.js +107 -55
- package/dist/local-memory/index-budget.js +74 -0
- package/dist/local-memory/pending.js +111 -0
- package/dist/local-memory/runtime.js +87 -0
- package/dist/local-memory/types.js +1 -0
- package/dist/local-memory/write.js +448 -0
- package/dist/machine-info.js +3 -4
- package/dist/main.js +83 -0
- package/dist/managed-service-lifecycle.js +47 -2
- package/dist/managed-service-startup.js +2 -0
- package/dist/profile-layout.js +10 -4
- package/dist/project-dependencies.js +139 -124
- package/dist/project-dependency-lock.js +87 -0
- package/dist/project-dependency-manifest.js +2 -12
- package/dist/project-registry-wiring.js +1 -4
- package/dist/project-skills/controller.js +2 -0
- package/dist/project-skills/runtime-launch.js +10 -7
- package/dist/prompt.js +19 -1
- package/dist/provider-env.js +3 -21
- package/dist/repository-identity.js +45 -0
- package/dist/runner.js +4 -2
- package/dist/runtimes/codex-app-server-runner.js +55 -23
- package/dist/runtimes/codex-command-safety.js +75 -0
- package/dist/runtimes/codex-contract.js +20 -5
- package/dist/runtimes/codex-deepseek-config.js +7 -17
- package/dist/runtimes/codex-turn-retry.js +69 -0
- package/dist/runtimes/codex.js +4 -3
- package/dist/serve.js +68 -3
- package/dist/storage-config.js +175 -0
- package/dist/storage-control.js +193 -0
- package/dist/storage-copy.js +628 -0
- package/dist/storage-credential-guard.js +21 -0
- package/dist/storage-doctor.js +228 -0
- package/dist/storage-identity.js +29 -0
- package/dist/storage-layout.js +143 -0
- package/dist/storage-manifest.js +855 -0
- package/dist/storage-migration-controller.js +420 -0
- package/dist/storage-migration-manifest.js +105 -0
- package/dist/storage-migration-policy.js +100 -0
- package/dist/storage-runtime-controller.js +339 -0
- package/dist/storage-runtime-readiness.js +184 -0
- package/dist/storage-runtime.js +39 -0
- package/dist/storage-safe-fs.js +302 -0
- package/dist/storage-serve-adapter.js +65 -0
- package/dist/storage-status-delivery.js +209 -0
- package/dist/storage-status-replay.js +125 -0
- package/dist/storage-target-confirmation.js +129 -0
- package/dist/storage-worker-command.js +37 -0
- package/dist/storage-worker-executor.js +430 -0
- package/dist/storage-worker-handoff.js +151 -0
- package/dist/storage-worker-operation.js +284 -0
- package/dist/storage-worker-status.js +259 -0
- package/dist/supervised-runtime.js +3 -1
- package/dist/workspace.js +34 -14
- package/package.json +1 -1
- package/dist/self-training-workspace.js +0 -15
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { buildServiceSpec, captureExactService, serviceAction, systemCommandRunner, } from "./computer-service.js";
|
|
4
|
+
import { listProfiles, loadProfile, removeProfile, saveProfile, } from "./computer-profile.js";
|
|
5
|
+
import { assertManagedServiceIdentity, installManagedService, uninstallManagedService, uninstallUnregisteredManagedService, } from "./managed-service-lifecycle.js";
|
|
6
|
+
import { readManagedServiceRegistry, normalizeManagedServiceRecord, serviceDescriptorSha256, } from "./managed-service-registry.js";
|
|
7
|
+
import { readInstallations, writeInstallations, } from "./storage-config.js";
|
|
8
|
+
import { runStorageDoctor } from "./storage-doctor.js";
|
|
9
|
+
import { readStorageMigrationReceipt, recoverStorageMigration, runStorageMigration, StorageMigrationFailure, } from "./storage-migration-controller.js";
|
|
10
|
+
import { materializeStorageManifest, publishStagedStorage, reconcileStorageManifest, verifyStagedStorage, } from "./storage-copy.js";
|
|
11
|
+
import { readStorageWorkerHandoff } from "./storage-worker-handoff.js";
|
|
12
|
+
import { readStorageWorkerOperation } from "./storage-worker-operation.js";
|
|
13
|
+
import { storageIdFingerprint } from "./storage-identity.js";
|
|
14
|
+
import { waitForStorageMigrationAcknowledgement } from "./storage-target-confirmation.js";
|
|
15
|
+
const defaultServiceDependencies = () => ({
|
|
16
|
+
assertManagedServiceIdentity,
|
|
17
|
+
serviceAction,
|
|
18
|
+
uninstallManagedService,
|
|
19
|
+
uninstallUnregisteredManagedService,
|
|
20
|
+
installManagedService,
|
|
21
|
+
captureExactService,
|
|
22
|
+
readManagedServices: readManagedServiceRegistry,
|
|
23
|
+
listProfiles,
|
|
24
|
+
loadProfile,
|
|
25
|
+
saveProfile,
|
|
26
|
+
removeProfile,
|
|
27
|
+
readInstallations,
|
|
28
|
+
writeInstallations,
|
|
29
|
+
});
|
|
30
|
+
function serviceSpec(operation, record, profileHome) {
|
|
31
|
+
return buildServiceSpec({
|
|
32
|
+
platform: operation.service.platform,
|
|
33
|
+
profile: operation.profile,
|
|
34
|
+
userHome: realpathSync.native(operation.service.userHome),
|
|
35
|
+
uid: operation.service.uid,
|
|
36
|
+
nodePath: record.nodePath,
|
|
37
|
+
entryPath: record.entryPath,
|
|
38
|
+
profileHome,
|
|
39
|
+
...(operation.service.serviceEnvironment === undefined
|
|
40
|
+
? {}
|
|
41
|
+
: { environment: operation.service.serviceEnvironment }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function assertRecordMatchesSpec(record, spec) {
|
|
45
|
+
const normalizedDescriptorPath = spec.descriptorPath === null
|
|
46
|
+
? null
|
|
47
|
+
: normalizeManagedServiceRecord({ ...record, descriptorPath: spec.descriptorPath }).descriptorPath;
|
|
48
|
+
const mismatches = [
|
|
49
|
+
spec.descriptor === null ? "descriptor" : null,
|
|
50
|
+
spec.descriptorPath === null ? "descriptorPath" : null,
|
|
51
|
+
record.platform !== spec.platform ? "platform" : null,
|
|
52
|
+
record.serviceId !== spec.id ? "serviceId" : null,
|
|
53
|
+
record.profile !== spec.profile ? "profile" : null,
|
|
54
|
+
record.descriptorPath !== normalizedDescriptorPath ? "descriptorPath" : null,
|
|
55
|
+
spec.descriptor !== null && record.descriptorSha256 !== serviceDescriptorSha256(spec.descriptor)
|
|
56
|
+
? "descriptorSha256"
|
|
57
|
+
: null,
|
|
58
|
+
].filter((value) => value !== null);
|
|
59
|
+
if (mismatches.length > 0) {
|
|
60
|
+
throw new Error(`Managed service record does not match operation service '${record.serviceId}': ${mismatches.join(", ")}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function sameRecord(left, right) {
|
|
64
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
65
|
+
}
|
|
66
|
+
function sameInstallation(left, right) {
|
|
67
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
68
|
+
}
|
|
69
|
+
function sameProfile(left, right) {
|
|
70
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
71
|
+
}
|
|
72
|
+
function canonicalServerUrl(value) {
|
|
73
|
+
return value.replace(/\/+$/u, "");
|
|
74
|
+
}
|
|
75
|
+
export function createStorageWorkerServiceLifecycle(operation, injected = {}) {
|
|
76
|
+
const dependencies = { ...defaultServiceDependencies(), ...injected };
|
|
77
|
+
const registry = { userHome: operation.service.userHome };
|
|
78
|
+
const sourceRecord = operation.service.sourceRecord;
|
|
79
|
+
const targetRecord = operation.service.targetRecord;
|
|
80
|
+
const sourceSpec = serviceSpec(operation, sourceRecord, operation.migration.sourceRoots["daemon-home"]);
|
|
81
|
+
const targetSpec = serviceSpec(operation, targetRecord, operation.installation.configRoot);
|
|
82
|
+
assertRecordMatchesSpec(sourceRecord, sourceSpec);
|
|
83
|
+
assertRecordMatchesSpec(targetRecord, targetSpec);
|
|
84
|
+
let sourceProfile = null;
|
|
85
|
+
let savedTargetProfile = null;
|
|
86
|
+
let baselineInstallations = null;
|
|
87
|
+
let targetProfileCreated = false;
|
|
88
|
+
let targetInstallationWritten = false;
|
|
89
|
+
const assertSourceIdentity = () => dependencies.assertManagedServiceIdentity({
|
|
90
|
+
spec: sourceSpec,
|
|
91
|
+
record: sourceRecord,
|
|
92
|
+
registry,
|
|
93
|
+
});
|
|
94
|
+
const readTargetInstallations = () => dependencies.readInstallations({
|
|
95
|
+
configRoot: operation.installation.configRoot,
|
|
96
|
+
});
|
|
97
|
+
const assertTargetMetadataUnchanged = async () => {
|
|
98
|
+
const profiles = await dependencies.listProfiles(operation.installation.configRoot);
|
|
99
|
+
if (profiles.includes(operation.profile))
|
|
100
|
+
throw new Error(`Target profile '${operation.profile}' already exists`);
|
|
101
|
+
const current = await readTargetInstallations();
|
|
102
|
+
if (baselineInstallations !== null && JSON.stringify(current) !== JSON.stringify(baselineInstallations)) {
|
|
103
|
+
throw new Error("Target installation registry changed after worker preflight");
|
|
104
|
+
}
|
|
105
|
+
if (current.some((record) => record.profile === operation.profile
|
|
106
|
+
|| record.installationId === operation.installation.target.installationId)) {
|
|
107
|
+
throw new Error("Target installation identity already exists");
|
|
108
|
+
}
|
|
109
|
+
return current;
|
|
110
|
+
};
|
|
111
|
+
const preflight = async () => {
|
|
112
|
+
await assertSourceIdentity();
|
|
113
|
+
const source = await dependencies.captureExactService(sourceSpec, systemCommandRunner);
|
|
114
|
+
if (!source.installed || !source.exact || source.running !== operation.service.sourceWasRunning) {
|
|
115
|
+
throw new Error("Source service state does not match worker operation");
|
|
116
|
+
}
|
|
117
|
+
const target = await dependencies.captureExactService(targetSpec, systemCommandRunner);
|
|
118
|
+
if (target.installed)
|
|
119
|
+
throw new Error(`Target service '${targetSpec.id}' already exists`);
|
|
120
|
+
sourceProfile = await dependencies.loadProfile(operation.profile, sourceRecord.daemonHome);
|
|
121
|
+
if (sourceProfile.name !== operation.profile
|
|
122
|
+
|| canonicalServerUrl(sourceProfile.serverUrl) !== canonicalServerUrl(operation.installation.target.serverUrl)) {
|
|
123
|
+
throw new Error("Source profile does not match target installation identity");
|
|
124
|
+
}
|
|
125
|
+
baselineInstallations = await assertTargetMetadataUnchanged();
|
|
126
|
+
};
|
|
127
|
+
const stopSourceService = async () => {
|
|
128
|
+
await dependencies.serviceAction(sourceSpec, "stop", systemCommandRunner);
|
|
129
|
+
const stopped = await dependencies.captureExactService(sourceSpec, systemCommandRunner);
|
|
130
|
+
if (!stopped.installed || !stopped.exact || stopped.running) {
|
|
131
|
+
throw new Error(`Source service '${sourceSpec.id}' did not stop exactly`);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const installTargetService = async () => {
|
|
135
|
+
if (sourceProfile === null || baselineInstallations === null)
|
|
136
|
+
throw new Error("Worker service preflight is required");
|
|
137
|
+
const installations = await assertTargetMetadataUnchanged();
|
|
138
|
+
await assertSourceIdentity();
|
|
139
|
+
await dependencies.uninstallManagedService({
|
|
140
|
+
spec: sourceSpec,
|
|
141
|
+
record: sourceRecord,
|
|
142
|
+
runner: systemCommandRunner,
|
|
143
|
+
registry,
|
|
144
|
+
});
|
|
145
|
+
const { version: _version, ...profile } = sourceProfile;
|
|
146
|
+
savedTargetProfile = await dependencies.saveProfile({
|
|
147
|
+
...profile,
|
|
148
|
+
agentsRoot: targetRecord.agentsRoot,
|
|
149
|
+
}, operation.installation.configRoot);
|
|
150
|
+
targetProfileCreated = true;
|
|
151
|
+
await dependencies.writeInstallations([...installations, operation.installation.target].sort((left, right) => left.profile.localeCompare(right.profile)), { configRoot: operation.installation.configRoot });
|
|
152
|
+
targetInstallationWritten = true;
|
|
153
|
+
await dependencies.installManagedService({
|
|
154
|
+
spec: targetSpec,
|
|
155
|
+
record: targetRecord,
|
|
156
|
+
runner: systemCommandRunner,
|
|
157
|
+
registry,
|
|
158
|
+
});
|
|
159
|
+
const target = await dependencies.captureExactService(targetSpec, systemCommandRunner);
|
|
160
|
+
if (!target.installed || !target.exact || target.running) {
|
|
161
|
+
throw new Error(`Target service '${targetSpec.id}' was not installed exactly in stopped state`);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const startTargetService = async () => {
|
|
165
|
+
await dependencies.serviceAction(targetSpec, "start", systemCommandRunner);
|
|
166
|
+
const target = await dependencies.captureExactService(targetSpec, systemCommandRunner);
|
|
167
|
+
if (!target.installed || !target.exact || !target.running) {
|
|
168
|
+
throw new Error(`Target service '${targetSpec.id}' did not start exactly`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const removeTargetService = async () => {
|
|
172
|
+
const records = await dependencies.readManagedServices(registry);
|
|
173
|
+
const registered = records.find((record) => record.serviceId === targetRecord.serviceId
|
|
174
|
+
|| (record.profile === targetRecord.profile && record.daemonHome === targetRecord.daemonHome));
|
|
175
|
+
if (registered !== undefined && !sameRecord(registered, targetRecord)) {
|
|
176
|
+
throw new Error(`Target service '${targetSpec.id}' identity changed before rollback`);
|
|
177
|
+
}
|
|
178
|
+
if (registered !== undefined) {
|
|
179
|
+
await dependencies.uninstallManagedService({
|
|
180
|
+
spec: targetSpec,
|
|
181
|
+
record: targetRecord,
|
|
182
|
+
runner: systemCommandRunner,
|
|
183
|
+
registry,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const target = await dependencies.captureExactService(targetSpec, systemCommandRunner);
|
|
188
|
+
if (target.installed && !target.exact)
|
|
189
|
+
throw new Error(`Target service '${targetSpec.id}' changed before rollback`);
|
|
190
|
+
if (target.installed) {
|
|
191
|
+
await dependencies.uninstallUnregisteredManagedService({
|
|
192
|
+
spec: targetSpec,
|
|
193
|
+
daemonHome: targetRecord.daemonHome,
|
|
194
|
+
npmPrefix: targetRecord.npmPrefix,
|
|
195
|
+
runner: systemCommandRunner,
|
|
196
|
+
registry,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const installations = await readTargetInstallations();
|
|
201
|
+
const targetInstallation = installations.find((record) => record.profile === operation.profile
|
|
202
|
+
|| record.installationId === operation.installation.target.installationId);
|
|
203
|
+
if (targetInstallation !== undefined) {
|
|
204
|
+
if (!sameInstallation(targetInstallation, operation.installation.target)) {
|
|
205
|
+
throw new Error("Target installation identity changed before rollback");
|
|
206
|
+
}
|
|
207
|
+
await dependencies.writeInstallations(installations.filter((record) => !sameInstallation(record, operation.installation.target)), { configRoot: operation.installation.configRoot });
|
|
208
|
+
targetInstallationWritten = false;
|
|
209
|
+
}
|
|
210
|
+
const profiles = await dependencies.listProfiles(operation.installation.configRoot);
|
|
211
|
+
if (profiles.includes(operation.profile)) {
|
|
212
|
+
const current = await dependencies.loadProfile(operation.profile, operation.installation.configRoot);
|
|
213
|
+
const original = sourceProfile
|
|
214
|
+
?? await dependencies.loadProfile(operation.profile, sourceRecord.daemonHome);
|
|
215
|
+
const { version: _version, ...profile } = original;
|
|
216
|
+
const expected = savedTargetProfile ?? { version: 1, ...profile, agentsRoot: targetRecord.agentsRoot };
|
|
217
|
+
if (!sameProfile(current, expected)) {
|
|
218
|
+
throw new Error("Target profile identity changed before rollback");
|
|
219
|
+
}
|
|
220
|
+
await dependencies.removeProfile(operation.profile, operation.installation.configRoot);
|
|
221
|
+
targetProfileCreated = false;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const restoreSourceService = async () => {
|
|
225
|
+
const current = await dependencies.captureExactService(sourceSpec, systemCommandRunner);
|
|
226
|
+
if (current.installed && !current.exact) {
|
|
227
|
+
throw new Error(`Source service '${sourceSpec.id}' changed before rollback`);
|
|
228
|
+
}
|
|
229
|
+
if (!current.installed) {
|
|
230
|
+
await dependencies.installManagedService({
|
|
231
|
+
spec: sourceSpec,
|
|
232
|
+
record: sourceRecord,
|
|
233
|
+
runner: systemCommandRunner,
|
|
234
|
+
registry,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const afterInstall = current.installed
|
|
238
|
+
? current
|
|
239
|
+
: await dependencies.captureExactService(sourceSpec, systemCommandRunner);
|
|
240
|
+
if (operation.service.sourceWasRunning && !afterInstall.running) {
|
|
241
|
+
await dependencies.serviceAction(sourceSpec, "start", systemCommandRunner);
|
|
242
|
+
}
|
|
243
|
+
else if (!operation.service.sourceWasRunning && afterInstall.running) {
|
|
244
|
+
await dependencies.serviceAction(sourceSpec, "stop", systemCommandRunner);
|
|
245
|
+
}
|
|
246
|
+
const restored = await dependencies.captureExactService(sourceSpec, systemCommandRunner);
|
|
247
|
+
if (!restored.installed || !restored.exact || restored.running !== operation.service.sourceWasRunning) {
|
|
248
|
+
throw new Error(`Source service '${sourceSpec.id}' was not restored exactly`);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
return Object.freeze({
|
|
252
|
+
preflight,
|
|
253
|
+
assertServiceIdentity: assertSourceIdentity,
|
|
254
|
+
isBusy: () => false,
|
|
255
|
+
quiesceExecutions: async () => undefined,
|
|
256
|
+
waitForIdle: async () => undefined,
|
|
257
|
+
flushLocalState: async () => undefined,
|
|
258
|
+
stopSourceService,
|
|
259
|
+
installTargetService,
|
|
260
|
+
startTargetService,
|
|
261
|
+
removeTargetService,
|
|
262
|
+
restoreSourceService,
|
|
263
|
+
confirmHello: (receipt) => waitForStorageMigrationAcknowledgement({
|
|
264
|
+
backupsRoot: operation.migration.backupsRoot,
|
|
265
|
+
migrationId: operation.migrationId,
|
|
266
|
+
environment: operation.environment,
|
|
267
|
+
workspaceId: operation.workspaceId,
|
|
268
|
+
profile: operation.profile,
|
|
269
|
+
manifestDigest: receipt.manifestDigest,
|
|
270
|
+
targetStorageFingerprint: storageIdFingerprint(operation.installation.target.storageId),
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function validateFreshDoctor(operation, doctor) {
|
|
275
|
+
if (!doctor.applyAllowed
|
|
276
|
+
|| doctor.manifestDigest !== operation.expectedManifestDigest
|
|
277
|
+
|| doctor.manifest.migrationId !== operation.migrationId
|
|
278
|
+
|| doctor.environment !== operation.environment
|
|
279
|
+
|| doctor.workspaceId !== operation.workspaceId
|
|
280
|
+
|| doctor.profile !== operation.profile
|
|
281
|
+
|| doctor.manifest.targetWorkspaceRoot !== operation.migration.targetWorkspaceRoot) {
|
|
282
|
+
throw new StorageMigrationFailure("worker_doctor_mismatch", "fresh worker doctor digest or identity does not match the approved operation");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function workerDoctorInput(operation, generatedAt = operation.doctorInput.generatedAt) {
|
|
286
|
+
const { freeSpaceMultiplier, manifestStoreRoot, ...input } = operation.doctorInput;
|
|
287
|
+
return {
|
|
288
|
+
...input,
|
|
289
|
+
generatedAt,
|
|
290
|
+
...(manifestStoreRoot === undefined ? {} : { manifestStoreRoot }),
|
|
291
|
+
...(freeSpaceMultiplier === undefined ? {} : { freeSpaceMultiplier }),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function workerSourceRoots(operation) {
|
|
295
|
+
return Object.fromEntries(Object.entries(operation.migration.sourceRoots)
|
|
296
|
+
.filter((entry) => entry[1] !== undefined));
|
|
297
|
+
}
|
|
298
|
+
export async function executeStorageMigrationWorker(handoffPath, injected = {}) {
|
|
299
|
+
const dependencies = {
|
|
300
|
+
runDoctor: (input) => runStorageDoctor(input),
|
|
301
|
+
runMigration: runStorageMigration,
|
|
302
|
+
recoverMigration: recoverStorageMigration,
|
|
303
|
+
readReceipt: readStorageMigrationReceipt,
|
|
304
|
+
createServiceLifecycle: (operation) => createStorageWorkerServiceLifecycle(operation),
|
|
305
|
+
now: () => new Date(),
|
|
306
|
+
...injected,
|
|
307
|
+
};
|
|
308
|
+
const handoff = await readStorageWorkerHandoff(handoffPath);
|
|
309
|
+
const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
|
|
310
|
+
const assertHeartbeatHealthy = dependencies.assertHeartbeatHealthy ?? (() => undefined);
|
|
311
|
+
if (handoff.migrationId !== operation.migrationId || handoff.profile !== operation.profile) {
|
|
312
|
+
throw new StorageMigrationFailure("worker_handoff_mismatch", "worker handoff does not match operation identity");
|
|
313
|
+
}
|
|
314
|
+
const lifecycle = dependencies.createServiceLifecycle(operation);
|
|
315
|
+
const existing = await dependencies.readReceipt(operation.migration.backupsRoot, operation.migrationId);
|
|
316
|
+
if (existing !== null) {
|
|
317
|
+
if (existing.environment !== operation.environment || existing.workspaceId !== operation.workspaceId
|
|
318
|
+
|| existing.profile !== operation.profile) {
|
|
319
|
+
throw new StorageMigrationFailure("worker_receipt_mismatch", "worker receipt does not match operation identity");
|
|
320
|
+
}
|
|
321
|
+
if (existing.status === "completed" || existing.status === "rolled_back")
|
|
322
|
+
return existing;
|
|
323
|
+
return dependencies.recoverMigration({
|
|
324
|
+
backupsRoot: operation.migration.backupsRoot,
|
|
325
|
+
migrationId: operation.migrationId,
|
|
326
|
+
}, { ...lifecycle, now: dependencies.now });
|
|
327
|
+
}
|
|
328
|
+
assertHeartbeatHealthy();
|
|
329
|
+
await lifecycle.preflight();
|
|
330
|
+
assertHeartbeatHealthy();
|
|
331
|
+
const doctor = await dependencies.runDoctor(workerDoctorInput(operation));
|
|
332
|
+
assertHeartbeatHealthy();
|
|
333
|
+
validateFreshDoctor(operation, doctor);
|
|
334
|
+
const guardedMaterialize = async (input, hooks = {}) => materializeStorageManifest(input, {
|
|
335
|
+
...hooks,
|
|
336
|
+
afterSourceLstat: async (path) => {
|
|
337
|
+
await hooks.afterSourceLstat?.(path);
|
|
338
|
+
assertHeartbeatHealthy();
|
|
339
|
+
},
|
|
340
|
+
afterTargetParentOpen: async (path) => {
|
|
341
|
+
await hooks.afterTargetParentOpen?.(path);
|
|
342
|
+
assertHeartbeatHealthy();
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const guardedVerify = async (input, hooks = {}) => verifyStagedStorage(input, {
|
|
346
|
+
...hooks,
|
|
347
|
+
afterTargetLstat: async (path) => {
|
|
348
|
+
await hooks.afterTargetLstat?.(path);
|
|
349
|
+
assertHeartbeatHealthy();
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
const guardedReconcile = async (input) => {
|
|
353
|
+
assertHeartbeatHealthy();
|
|
354
|
+
const result = await reconcileStorageManifest(input);
|
|
355
|
+
assertHeartbeatHealthy();
|
|
356
|
+
return result;
|
|
357
|
+
};
|
|
358
|
+
const guardedPublish = async (input, deps = {}) => {
|
|
359
|
+
assertHeartbeatHealthy();
|
|
360
|
+
await publishStagedStorage(input, deps);
|
|
361
|
+
assertHeartbeatHealthy();
|
|
362
|
+
};
|
|
363
|
+
const receipt = await dependencies.runMigration({
|
|
364
|
+
doctor,
|
|
365
|
+
sourceRoots: workerSourceRoots(operation),
|
|
366
|
+
stagingWorkspaceRoot: operation.migration.stagingWorkspaceRoot,
|
|
367
|
+
targetWorkspaceRoot: operation.migration.targetWorkspaceRoot,
|
|
368
|
+
backupsRoot: operation.migration.backupsRoot,
|
|
369
|
+
backupArtifacts: operation.migration.backupArtifacts,
|
|
370
|
+
acceptedWarnings: operation.migration.acceptedWarnings,
|
|
371
|
+
onStatus: () => assertHeartbeatHealthy(),
|
|
372
|
+
assertHealthy: assertHeartbeatHealthy,
|
|
373
|
+
}, {
|
|
374
|
+
...lifecycle,
|
|
375
|
+
now: dependencies.now,
|
|
376
|
+
materialize: guardedMaterialize,
|
|
377
|
+
reconcile: guardedReconcile,
|
|
378
|
+
verify: guardedVerify,
|
|
379
|
+
publish: guardedPublish,
|
|
380
|
+
refreshDoctor: async () => {
|
|
381
|
+
const refreshed = await dependencies.runDoctor(workerDoctorInput(operation, dependencies.now().toISOString()));
|
|
382
|
+
if (refreshed.manifest.migrationId !== operation.migrationId
|
|
383
|
+
|| refreshed.environment !== operation.environment
|
|
384
|
+
|| refreshed.workspaceId !== operation.workspaceId
|
|
385
|
+
|| refreshed.profile !== operation.profile) {
|
|
386
|
+
throw new StorageMigrationFailure("final_doctor_failed", "final worker doctor identity changed");
|
|
387
|
+
}
|
|
388
|
+
return refreshed;
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
assertHeartbeatHealthy();
|
|
392
|
+
return receipt;
|
|
393
|
+
}
|
|
394
|
+
export async function recoverStorageMigrationWorker(handoffPath, injected = {}) {
|
|
395
|
+
const dependencies = {
|
|
396
|
+
runDoctor: (input) => runStorageDoctor(input),
|
|
397
|
+
runMigration: runStorageMigration,
|
|
398
|
+
recoverMigration: recoverStorageMigration,
|
|
399
|
+
readReceipt: readStorageMigrationReceipt,
|
|
400
|
+
createServiceLifecycle: (operation) => createStorageWorkerServiceLifecycle(operation),
|
|
401
|
+
now: () => new Date(),
|
|
402
|
+
...injected,
|
|
403
|
+
};
|
|
404
|
+
const handoff = await readStorageWorkerHandoff(handoffPath);
|
|
405
|
+
const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
|
|
406
|
+
const assertHeartbeatHealthy = dependencies.assertHeartbeatHealthy ?? (() => undefined);
|
|
407
|
+
assertHeartbeatHealthy();
|
|
408
|
+
if (handoff.migrationId !== operation.migrationId || handoff.profile !== operation.profile) {
|
|
409
|
+
throw new StorageMigrationFailure("worker_handoff_mismatch", "worker handoff does not match operation identity");
|
|
410
|
+
}
|
|
411
|
+
const receipt = await dependencies.readReceipt(operation.migration.backupsRoot, operation.migrationId);
|
|
412
|
+
if (receipt === null) {
|
|
413
|
+
throw new StorageMigrationFailure("migration_receipt_missing", "explicit rollback requires an existing migration receipt");
|
|
414
|
+
}
|
|
415
|
+
if (receipt.environment !== operation.environment || receipt.workspaceId !== operation.workspaceId
|
|
416
|
+
|| receipt.profile !== operation.profile) {
|
|
417
|
+
throw new StorageMigrationFailure("worker_receipt_mismatch", "worker receipt does not match operation identity");
|
|
418
|
+
}
|
|
419
|
+
if (receipt.status === "completed" || receipt.status === "rolled_back")
|
|
420
|
+
return receipt;
|
|
421
|
+
const recovered = await dependencies.recoverMigration({
|
|
422
|
+
backupsRoot: operation.migration.backupsRoot,
|
|
423
|
+
migrationId: operation.migrationId,
|
|
424
|
+
}, { ...dependencies.createServiceLifecycle(operation), now: dependencies.now });
|
|
425
|
+
assertHeartbeatHealthy();
|
|
426
|
+
return recovered;
|
|
427
|
+
}
|
|
428
|
+
export function storageWorkerOperationDigest(operation) {
|
|
429
|
+
return createHash("sha256").update(`${JSON.stringify(operation, null, 2)}\n`).digest("hex");
|
|
430
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join, posix, resolve, win32 } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { atomicPrivateWrite } from "./atomic-private-write.js";
|
|
7
|
+
import { readStorageWorkerOperation } from "./storage-worker-operation.js";
|
|
8
|
+
const AbsolutePathSchema = z.string().min(1).max(32768)
|
|
9
|
+
.refine((value) => !/[\0\r\n]/u.test(value), "path must be a single line")
|
|
10
|
+
.refine((value) => posix.isAbsolute(value) || win32.isAbsolute(value), "path must be absolute");
|
|
11
|
+
const HandoffSchema = z.object({
|
|
12
|
+
version: z.literal(1),
|
|
13
|
+
migrationId: z.string().uuid(),
|
|
14
|
+
profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
|
|
15
|
+
operationPath: AbsolutePathSchema,
|
|
16
|
+
operationSha256: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
17
|
+
}).strict();
|
|
18
|
+
export async function writeStorageWorkerHandoff(raw, input) {
|
|
19
|
+
let handoff;
|
|
20
|
+
try {
|
|
21
|
+
handoff = HandoffSchema.parse(raw);
|
|
22
|
+
AbsolutePathSchema.parse(input.stateRoot);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new Error("Invalid storage worker handoff", { cause: error });
|
|
26
|
+
}
|
|
27
|
+
const path = join(input.stateRoot, "migrations", handoff.migrationId, "handoff.json");
|
|
28
|
+
await atomicPrivateWrite(path, `${JSON.stringify(handoff, null, 2)}\n`);
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
export async function readStorageWorkerHandoff(path) {
|
|
32
|
+
try {
|
|
33
|
+
AbsolutePathSchema.parse(path);
|
|
34
|
+
return HandoffSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new Error("Invalid storage worker handoff", { cause: error });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const execFileAsync = promisify(execFile);
|
|
41
|
+
const systemSupervisorRun = async (command, args) => {
|
|
42
|
+
try {
|
|
43
|
+
const result = await execFileAsync(command, [...args], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
timeout: 15_000,
|
|
46
|
+
maxBuffer: 64 * 1024,
|
|
47
|
+
});
|
|
48
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const failure = error;
|
|
52
|
+
return {
|
|
53
|
+
exitCode: typeof failure.code === "number" ? failure.code : 127,
|
|
54
|
+
stdout: failure.stdout ?? "",
|
|
55
|
+
stderr: failure.stderr ?? failure.message,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
function workerUnitName(migrationId) {
|
|
60
|
+
return `nowwork-storage-migration-${migrationId}.service`;
|
|
61
|
+
}
|
|
62
|
+
function workerUnitPath(userHome, migrationId) {
|
|
63
|
+
return resolve(userHome, ".config", "systemd", "user", workerUnitName(migrationId));
|
|
64
|
+
}
|
|
65
|
+
function workerLaunchdLabel(migrationId) {
|
|
66
|
+
return `com.nowcrew.storage-migration.${migrationId}`;
|
|
67
|
+
}
|
|
68
|
+
function workerLaunchdPath(userHome, migrationId) {
|
|
69
|
+
return resolve(userHome, "Library", "LaunchAgents", `${workerLaunchdLabel(migrationId)}.plist`);
|
|
70
|
+
}
|
|
71
|
+
function xml(value) {
|
|
72
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
73
|
+
.replaceAll('"', """).replaceAll("'", "'");
|
|
74
|
+
}
|
|
75
|
+
function systemdArg(value) {
|
|
76
|
+
if (/[\0\r\n]/u.test(value))
|
|
77
|
+
throw new Error("storage worker service argument must be a single line");
|
|
78
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
79
|
+
}
|
|
80
|
+
async function checkedSupervisor(run, command, args) {
|
|
81
|
+
const result = await run(command, args);
|
|
82
|
+
if (result.exitCode !== 0)
|
|
83
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`);
|
|
84
|
+
}
|
|
85
|
+
export async function launchStorageMigrationWorker(input, runSupervisor = systemSupervisorRun) {
|
|
86
|
+
AbsolutePathSchema.parse(input.entryPath);
|
|
87
|
+
AbsolutePathSchema.parse(input.handoffPath);
|
|
88
|
+
AbsolutePathSchema.parse(input.userHome);
|
|
89
|
+
const migrationId = z.string().uuid().parse(input.migrationId).toLowerCase();
|
|
90
|
+
const worker = [process.execPath, input.entryPath, "storage-migration-worker", "--job", input.handoffPath];
|
|
91
|
+
if (input.platform === "darwin") {
|
|
92
|
+
if (input.uid === undefined || !Number.isSafeInteger(input.uid) || input.uid < 0) {
|
|
93
|
+
throw new Error("Persistent storage migration launchd worker requires a numeric uid");
|
|
94
|
+
}
|
|
95
|
+
const label = workerLaunchdLabel(migrationId);
|
|
96
|
+
const path = workerLaunchdPath(input.userHome, migrationId);
|
|
97
|
+
const args = worker.map((arg) => ` <string>${xml(arg)}</string>`).join("\n");
|
|
98
|
+
const descriptor = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(label)}</string>\n <key>ProgramArguments</key>\n <array>\n${args}\n </array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key><false/>\n </dict>\n <key>ProcessType</key><string>Background</string>\n</dict>\n</plist>\n`;
|
|
99
|
+
await atomicPrivateWrite(path, descriptor);
|
|
100
|
+
try {
|
|
101
|
+
await checkedSupervisor(runSupervisor, "launchctl", ["bootstrap", `gui/${input.uid}`, path]);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
await runSupervisor("launchctl", ["bootout", `gui/${input.uid}/${label}`]).catch(() => undefined);
|
|
105
|
+
await rm(path, { force: true });
|
|
106
|
+
throw new Error("Storage migration persistent service launch failed", { cause: error });
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const unit = workerUnitName(migrationId);
|
|
111
|
+
const path = workerUnitPath(input.userHome, migrationId);
|
|
112
|
+
const descriptor = `[Unit]\nDescription=NowWork storage migration ${migrationId}\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=exec\nExecStart=${worker.map(systemdArg).join(" ")}\nRestart=on-abnormal\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n`;
|
|
113
|
+
await atomicPrivateWrite(path, descriptor);
|
|
114
|
+
try {
|
|
115
|
+
await checkedSupervisor(runSupervisor, "systemctl", ["--user", "daemon-reload"]);
|
|
116
|
+
await checkedSupervisor(runSupervisor, "systemctl", ["--user", "enable", "--now", unit]);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await runSupervisor("systemctl", ["--user", "disable", "--now", unit]).catch(() => undefined);
|
|
120
|
+
await rm(path, { force: true });
|
|
121
|
+
await runSupervisor("systemctl", ["--user", "daemon-reload"]).catch(() => undefined);
|
|
122
|
+
throw new Error("Storage migration persistent service launch failed", { cause: error });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function completeStorageMigrationWorkerService(input, runSupervisor = systemSupervisorRun) {
|
|
126
|
+
const migrationId = z.string().uuid().parse(input.migrationId).toLowerCase();
|
|
127
|
+
AbsolutePathSchema.parse(input.userHome);
|
|
128
|
+
if (input.platform === "darwin") {
|
|
129
|
+
if (input.uid === undefined || !Number.isSafeInteger(input.uid) || input.uid < 0) {
|
|
130
|
+
throw new Error("Persistent storage migration launchd worker requires a numeric uid");
|
|
131
|
+
}
|
|
132
|
+
const label = workerLaunchdLabel(migrationId);
|
|
133
|
+
await rm(workerLaunchdPath(input.userHome, migrationId), { force: true });
|
|
134
|
+
await runSupervisor("launchctl", ["bootout", `gui/${input.uid}/${label}`]);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const unit = workerUnitName(migrationId);
|
|
138
|
+
await checkedSupervisor(runSupervisor, "systemctl", ["--user", "disable", unit]);
|
|
139
|
+
await rm(workerUnitPath(input.userHome, migrationId), { force: true });
|
|
140
|
+
await checkedSupervisor(runSupervisor, "systemctl", ["--user", "daemon-reload"]);
|
|
141
|
+
}
|
|
142
|
+
export async function completeStorageMigrationWorkerServiceForHandoff(path) {
|
|
143
|
+
const handoff = await readStorageWorkerHandoff(path);
|
|
144
|
+
const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
|
|
145
|
+
await completeStorageMigrationWorkerService({
|
|
146
|
+
migrationId: operation.migrationId,
|
|
147
|
+
platform: operation.service.platform,
|
|
148
|
+
userHome: operation.service.userHome,
|
|
149
|
+
...(operation.service.uid === undefined ? {} : { uid: operation.service.uid }),
|
|
150
|
+
});
|
|
151
|
+
}
|