@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.
Files changed (43) hide show
  1. package/dist/agent-ability/runtime-context.js +7 -1
  2. package/dist/atomic-private-write.js +54 -1
  3. package/dist/automatic-install-target.js +40 -11
  4. package/dist/console.js +9 -0
  5. package/dist/control-plane-url.js +2 -2
  6. package/dist/daemon-migration-controller.js +198 -0
  7. package/dist/daemon-migration-wiring.js +22 -0
  8. package/dist/daemon-update-eligibility.js +1 -1
  9. package/dist/directory-projection-identity.js +32 -0
  10. package/dist/directory-projection.js +922 -0
  11. package/dist/execution-protocol.js +78 -11
  12. package/dist/execution-runner.js +50 -2
  13. package/dist/i18n.js +1 -0
  14. package/dist/local-execution-prompt.js +57 -0
  15. package/dist/local-executor.js +99 -40
  16. package/dist/machine-info.js +45 -9
  17. package/dist/main.js +0 -0
  18. package/dist/normalize.js +5 -0
  19. package/dist/profile-layout.js +41 -0
  20. package/dist/project-skills/controller.js +74 -14
  21. package/dist/project-skills/execution-adapter.js +11 -0
  22. package/dist/project-skills/initialized-reconciler.js +20 -0
  23. package/dist/project-skills/projection-set-switch.js +419 -0
  24. package/dist/project-skills/projection-state-domain.js +153 -0
  25. package/dist/project-skills/projection-state-store.js +841 -0
  26. package/dist/project-skills/projection-state-transaction.js +318 -0
  27. package/dist/project-skills/projection-state.js +3 -0
  28. package/dist/project-skills/reconciler.js +299 -68
  29. package/dist/project-skills/runtime-warning.js +6 -0
  30. package/dist/project-skills/scanner.js +30 -1
  31. package/dist/project-skills/types.js +9 -0
  32. package/dist/project-workspaces/resolver.js +179 -0
  33. package/dist/project-workspaces/types.js +1 -0
  34. package/dist/prompt.js +40 -0
  35. package/dist/runtimes/claude.js +235 -4
  36. package/dist/runtimes/codex-app-server-runner.js +100 -25
  37. package/dist/runtimes/codex-contract.js +123 -0
  38. package/dist/runtimes/codex.js +2 -0
  39. package/dist/serve.js +31 -17
  40. package/dist/session.js +3 -0
  41. package/dist/supervised-runtime.js +12 -4
  42. package/dist/workspace.js +14 -5
  43. package/package.json +10 -9
@@ -0,0 +1,41 @@
1
+ import { posix, win32 } from "node:path";
2
+ export const PROFILE_LAYOUT_VERSION = 1;
3
+ const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
4
+ const LOCAL_INSTANCE_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/u;
5
+ export function validateLocalInstanceName(value) {
6
+ const normalized = value.trim().toLowerCase();
7
+ if (!LOCAL_INSTANCE_NAME.test(normalized) || normalized.includes("..")) {
8
+ throw new Error("local instance name must use lowercase letters, digits, '_' or '-' and cannot contain '..'");
9
+ }
10
+ return normalized;
11
+ }
12
+ export function profileForLocalInstance(environment, localInstanceName) {
13
+ const profile = `nw-${environment}-${validateLocalInstanceName(localInstanceName)}`;
14
+ if (!PROFILE_NAME.test(profile))
15
+ throw new Error("derived daemon profile exceeds 48 characters");
16
+ return profile;
17
+ }
18
+ export function profileLayout(input) {
19
+ const localInstanceName = validateLocalInstanceName(input.localInstanceName);
20
+ const profile = profileForLocalInstance(input.environment, localInstanceName);
21
+ const paths = input.platform === "win32" ? win32 : posix;
22
+ const base = `${input.environment}-${localInstanceName}`;
23
+ return Object.freeze({
24
+ layoutVersion: PROFILE_LAYOUT_VERSION,
25
+ environment: input.environment,
26
+ localInstanceName,
27
+ profile,
28
+ daemonHome: paths.join(input.userHome, ".crew", `daemon-${base}`),
29
+ agentsRoot: paths.join(input.userHome, ".crew", `agents-${base}`),
30
+ npmPrefix: paths.join(input.userHome, ".crew", "daemon-runtimes", profile),
31
+ });
32
+ }
33
+ export function compareProfileLayout(current, expected) {
34
+ if (current === null || current.layoutVersion !== PROFILE_LAYOUT_VERSION)
35
+ return "unknown";
36
+ return current.profile === expected.profile
37
+ && current.daemonHome === expected.daemonHome
38
+ && current.agentsRoot === expected.agentsRoot
39
+ ? "standard"
40
+ : "migration_required";
41
+ }
@@ -1,10 +1,21 @@
1
1
  import { z } from "zod";
2
- import { scanProjects } from "./scanner.js";
2
+ import { projectSkillResolutionRecords, scanProjects } from "./scanner.js";
3
3
  import { ProjectRegistryError } from "./registry.js";
4
- import { isProjectId, isProjectSkillName, MAX_AGENT_PROJECT_SKILL_BINDINGS, } from "./types.js";
4
+ import { isProjectId, isProjectSkillName, compareProjectSkillRefs, MAX_AGENT_PROJECT_SKILL_BINDINGS, } from "./types.js";
5
+ import { ProjectProjectionError, } from "./reconciler.js";
5
6
  import { AgentHandleSchema } from "../execution-protocol.js";
6
7
  import { createPromiseTail } from "../promise-tail.js";
7
8
  const ProjectIdSchema = z.string().refine(isProjectId);
9
+ const AgentSkillsSyncSchema = z.object({
10
+ type: z.literal("agent:skills:sync"),
11
+ reqId: z.string().min(1).max(128),
12
+ handle: AgentHandleSchema,
13
+ bindings: z.array(z.object({
14
+ projectId: ProjectIdSchema,
15
+ skillName: z.string().refine(isProjectSkillName),
16
+ }).strict()).max(MAX_AGENT_PROJECT_SKILL_BINDINGS),
17
+ generation: z.number().int().min(0).max(2_147_483_647).optional(),
18
+ }).strict();
8
19
  const ProjectCommandSchema = z.discriminatedUnion("type", [
9
20
  z.object({
10
21
  type: z.literal("project:add"),
@@ -22,16 +33,21 @@ const ProjectCommandSchema = z.discriminatedUnion("type", [
22
33
  reqId: z.string().min(1).max(128),
23
34
  projectId: ProjectIdSchema,
24
35
  }).strict(),
25
- z.object({
26
- type: z.literal("agent:skills:sync"),
27
- reqId: z.string().min(1).max(128),
28
- handle: AgentHandleSchema,
29
- bindings: z.array(z.object({
30
- projectId: ProjectIdSchema,
31
- skillName: z.string().refine(isProjectSkillName),
32
- }).strict()).max(MAX_AGENT_PROJECT_SKILL_BINDINGS),
33
- }).strict(),
34
- ]);
36
+ AgentSkillsSyncSchema,
37
+ ]).superRefine((command, ctx) => {
38
+ if (command.type !== "agent:skills:sync" || command.generation === undefined)
39
+ return;
40
+ for (let index = 1; index < command.bindings.length; index += 1) {
41
+ if (compareProjectSkillRefs(command.bindings[index - 1], command.bindings[index]) >= 0) {
42
+ ctx.addIssue({
43
+ code: z.ZodIssueCode.custom,
44
+ message: "project_skill_bindings_not_canonical",
45
+ path: ["bindings", index],
46
+ });
47
+ return;
48
+ }
49
+ }
50
+ });
35
51
  const DEFAULT_PROJECT_SCAN_TIMEOUT_MS = 10_000;
36
52
  class ProjectScanTimeoutError extends Error {
37
53
  code = "project_scan_timeout";
@@ -63,6 +79,7 @@ export function createProjectSkillsController(deps) {
63
79
  let initializationState = "idle";
64
80
  const scan = deps.scan ?? scanProjects;
65
81
  const scanTimeoutMs = deps.scanTimeoutMs ?? DEFAULT_PROJECT_SCAN_TIMEOUT_MS;
82
+ const v2Snapshots = new Map();
66
83
  const scanCurrent = async (deferredProjectId) => withinScanDeadline(scan(await deps.registry.list(), undefined, [
67
84
  ...scanned
68
85
  .map((project) => project.inventory.projectId)
@@ -80,6 +97,34 @@ export function createProjectSkillsController(deps) {
80
97
  }));
81
98
  };
82
99
  const enqueue = (operation) => operationTail.enqueue(operation);
100
+ const applyV2Snapshot = async (handle, snapshot) => {
101
+ if (deps.ensureSnapshot === undefined)
102
+ throw new ProjectProjectionError("skill_projection_failed");
103
+ await deps.ensureSnapshot(handle, snapshot);
104
+ const previous = v2Snapshots.get(handle);
105
+ if (previous === undefined || snapshot.generation >= previous.generation) {
106
+ v2Snapshots.set(handle, Object.freeze({
107
+ bindings: Object.freeze(snapshot.bindings.map((binding) => Object.freeze({ ...binding }))),
108
+ generation: snapshot.generation,
109
+ }));
110
+ }
111
+ return Object.freeze(projectSkillResolutionRecords(snapshot.bindings, scanned)
112
+ .map((record) => Object.freeze({
113
+ projectId: record.projectId,
114
+ skillName: record.skillName,
115
+ status: record.mode === "resolved" ? "linked" : "unavailable",
116
+ })));
117
+ };
118
+ const reapplyV2Snapshots = async () => {
119
+ if (deps.ensureSnapshot === undefined) {
120
+ if (v2Snapshots.size > 0)
121
+ throw new ProjectProjectionError("skill_projection_failed");
122
+ return;
123
+ }
124
+ for (const [handle, snapshot] of [...v2Snapshots.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
125
+ await deps.ensureSnapshot(handle, snapshot);
126
+ }
127
+ };
83
128
  const initialize = () => {
84
129
  if (initializationState === "ready" && initialization !== null)
85
130
  return initialization;
@@ -115,11 +160,23 @@ export function createProjectSkillsController(deps) {
115
160
  const command = parsed.data;
116
161
  try {
117
162
  if (command.type === "agent:skills:sync") {
163
+ if (command.generation !== undefined) {
164
+ const snapshot = Object.freeze({
165
+ bindings: Object.freeze(command.bindings.map((binding) => Object.freeze({ ...binding }))),
166
+ generation: command.generation,
167
+ });
168
+ return {
169
+ ok: true,
170
+ data: { bindings: await applyV2Snapshot(command.handle, snapshot) },
171
+ };
172
+ }
118
173
  if (deps.reconcile === undefined)
119
174
  return { ok: false, error: "skill_projection_failed" };
175
+ const bindings = await deps.reconcile(command.handle, command.bindings);
176
+ v2Snapshots.delete(command.handle);
120
177
  return {
121
178
  ok: true,
122
- data: { bindings: await deps.reconcile(command.handle, command.bindings) },
179
+ data: { bindings },
123
180
  };
124
181
  }
125
182
  if (command.type === "project:add") {
@@ -146,6 +203,7 @@ export function createProjectSkillsController(deps) {
146
203
  if (command.type !== "project:add") {
147
204
  await refresh(command.type === "project:rescan" ? command.projectId : undefined);
148
205
  }
206
+ await reapplyV2Snapshots();
149
207
  await publishCurrent();
150
208
  return { ok: true, data: { projectId: command.projectId } };
151
209
  }
@@ -158,7 +216,9 @@ export function createProjectSkillsController(deps) {
158
216
  ? "skill_name_conflict"
159
217
  : error.code === "skill_projection_failed"
160
218
  ? "skill_projection_failed"
161
- : "project_operation_failed",
219
+ : error.code === "skill_projection_snapshot_corrupt"
220
+ ? "skill_projection_snapshot_corrupt"
221
+ : "project_operation_failed",
162
222
  };
163
223
  }
164
224
  });
@@ -0,0 +1,11 @@
1
+ import { ProjectProjectionError } from "./reconciler.js";
2
+ /** Converts the protocol's flat Agent fields without adding daemon-local paths to the wire shape. */
3
+ export function projectSkillExecutionProjection(bindings, generation) {
4
+ return generation === undefined
5
+ ? bindings
6
+ : Object.freeze({
7
+ bindings,
8
+ generation,
9
+ });
10
+ }
11
+ export const projectSkillProjectionErrorCode = (error) => error instanceof ProjectProjectionError ? error.code : null;
@@ -0,0 +1,20 @@
1
+ import { ProjectProjectionError, } from "./reconciler.js";
2
+ /** Gates every projection entrypoint on the daemon's bounded registry/scanner initialization. */
3
+ export function initializedProjectSkillsReconciler(ensureInitialized, reconciler) {
4
+ const ready = async () => {
5
+ if (await ensureInitialized())
6
+ return reconciler;
7
+ throw new ProjectProjectionError("skill_projection_failed");
8
+ };
9
+ return {
10
+ async reconcile(handle, bindings) {
11
+ return (await ready()).reconcile(handle, bindings);
12
+ },
13
+ async ensureSnapshot(handle, snapshot) {
14
+ return (await ready()).ensureSnapshot(handle, snapshot);
15
+ },
16
+ async prepareAndLaunch(agentsRoot, handle, projection, launch, onWarning) {
17
+ return (await ready()).prepareAndLaunch(agentsRoot, handle, projection, launch, onWarning);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,419 @@
1
+ import { lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { durableAtomicPrivateWrite, durableDirectorySync, durablePrivateUnlink, } from "../atomic-private-write.js";
4
+ import { commitPreparedProjectSkillManifestUpdate, parseProjectSkillManifestUpdateRecord, preparedProjectSkillManifestUpdateFromRecord, projectSkillManifestUpdateRecord, publishPreparedProjectSkillManifestUpdate, rollbackPreparedProjectSkillManifestUpdate, } from "./projection-state.js";
5
+ export const PROJECTION_ROOT_MARKER_NAME = ".nowcrew-project-skills-root.json";
6
+ const JOURNAL_NAME = "project-skills-switch.json";
7
+ const MAX_JOURNAL_BYTES = 768 * 1024;
8
+ const MAX_ROOT_MARKER_BYTES = 16 * 1024;
9
+ const NONCE = /^[A-Za-z0-9-]{1,128}$/u;
10
+ const PHASES = new Set([
11
+ "prepared",
12
+ "codex:backup_moved",
13
+ "codex:activated",
14
+ "claude:backup_moved",
15
+ "claude:activated",
16
+ "committed",
17
+ "rolling_back",
18
+ "rollback_failed",
19
+ ]);
20
+ const errorCode = (error) => error.code;
21
+ export const projectionPathExists = async (path, statPath = lstat) => {
22
+ try {
23
+ await statPath(path);
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ if (errorCode(error) === "ENOENT")
28
+ return false;
29
+ throw error;
30
+ }
31
+ };
32
+ const exists = projectionPathExists;
33
+ const sameIdentity = (left, right) => left.dev === right.dev
34
+ && left.ino === right.ino
35
+ && left.birthtimeNs === right.birthtimeNs;
36
+ const directoryIdentity = async (path) => {
37
+ try {
38
+ const info = await lstat(path, { bigint: true });
39
+ if (!info.isDirectory() || info.isSymbolicLink())
40
+ throw new Error("projection_switch_path_unmanaged");
41
+ return Object.freeze({
42
+ dev: String(info.dev),
43
+ ino: String(info.ino),
44
+ birthtimeNs: String(info.birthtimeNs),
45
+ });
46
+ }
47
+ catch (error) {
48
+ if (errorCode(error) === "ENOENT")
49
+ return null;
50
+ throw error;
51
+ }
52
+ };
53
+ export const projectionSwitchJournalPath = (agentRoot) => join(agentRoot, ".crew", JOURNAL_NAME);
54
+ export const projectionTargets = (agentRoot, nonce) => Object.freeze([
55
+ Object.freeze({
56
+ runtime: "codex",
57
+ target: join(agentRoot, ".agents", "skills"),
58
+ staging: join(agentRoot, ".agents", `.skills-next-${nonce}`),
59
+ backup: join(agentRoot, ".agents", `.skills-previous-${nonce}`),
60
+ discard: join(agentRoot, ".agents", `.skills-discard-${nonce}`),
61
+ }),
62
+ Object.freeze({
63
+ runtime: "claude",
64
+ target: join(agentRoot, ".crew", "claude-skills", ".claude", "skills"),
65
+ staging: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-next-${nonce}`),
66
+ backup: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-previous-${nonce}`),
67
+ discard: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-discard-${nonce}`),
68
+ }),
69
+ ]);
70
+ const rootMarker = (target, nonce) => Object.freeze({
71
+ managedBy: "nowcrew-project-skills-root",
72
+ version: 1,
73
+ nonce,
74
+ runtime: target.runtime,
75
+ target: target.target,
76
+ });
77
+ const readRootMarker = async (root) => {
78
+ const markerPath = join(root, PROJECTION_ROOT_MARKER_NAME);
79
+ try {
80
+ const info = await lstat(markerPath);
81
+ if (!info.isFile() || info.isSymbolicLink() || Number(info.size) > MAX_ROOT_MARKER_BYTES)
82
+ return null;
83
+ const candidate = JSON.parse(await readFile(markerPath, "utf8"));
84
+ return candidate.managedBy === "nowcrew-project-skills-root"
85
+ && candidate.version === 1
86
+ && NONCE.test(candidate.nonce ?? "")
87
+ && (candidate.runtime === "codex" || candidate.runtime === "claude")
88
+ && typeof candidate.target === "string"
89
+ ? candidate
90
+ : null;
91
+ }
92
+ catch (error) {
93
+ if (errorCode(error) === "ENOENT" || error instanceof SyntaxError)
94
+ return null;
95
+ throw error;
96
+ }
97
+ };
98
+ const markerMatches = (marker, target, nonce) => marker !== null
99
+ && marker.runtime === target.runtime
100
+ && marker.target === target.target
101
+ && (nonce === undefined || marker.nonce === nonce);
102
+ const assertOwnedRoot = async (root, target, nonce) => {
103
+ const info = await directoryIdentity(root);
104
+ if (info === null || !markerMatches(await readRootMarker(root), target, nonce)) {
105
+ throw new Error("projection_switch_path_unmanaged");
106
+ }
107
+ };
108
+ export const markProjectionStaging = async (target, nonce) => {
109
+ await writeFile(join(target.staging, PROJECTION_ROOT_MARKER_NAME), JSON.stringify(rootMarker(target, nonce)), {
110
+ encoding: "utf8",
111
+ mode: 0o600,
112
+ flag: "wx",
113
+ });
114
+ };
115
+ export const isManagedProjectionRoot = async (root, target) => markerMatches(await readRootMarker(root), target);
116
+ export const cleanupProjectionStaging = async (target, nonce, syncDirectory = durableDirectorySync) => {
117
+ if (!await exists(target.staging))
118
+ return;
119
+ await assertOwnedRoot(target.staging, target, nonce);
120
+ await rm(target.staging, { recursive: true, force: true });
121
+ await syncDirectory(dirname(target.target));
122
+ };
123
+ const exactJournalTargets = (agentRoot, nonce, candidates) => {
124
+ const expected = projectionTargets(agentRoot, nonce);
125
+ return candidates.length === expected.length && expected.every((target, index) => {
126
+ const candidate = candidates[index];
127
+ return candidate?.runtime === target.runtime
128
+ && candidate.target === target.target
129
+ && candidate.staging === target.staging
130
+ && candidate.backup === target.backup
131
+ && candidate.discard === target.discard
132
+ && typeof candidate.hadPrevious === "boolean"
133
+ && (candidate.previousIdentity === null
134
+ || (typeof candidate.previousIdentity?.dev === "string"
135
+ && typeof candidate.previousIdentity.ino === "string"
136
+ && typeof candidate.previousIdentity.birthtimeNs === "string"))
137
+ && typeof candidate.activatedIdentity?.dev === "string"
138
+ && typeof candidate.activatedIdentity.ino === "string"
139
+ && typeof candidate.activatedIdentity.birthtimeNs === "string";
140
+ });
141
+ };
142
+ const parseJournal = (raw, agentRoot) => {
143
+ const candidate = JSON.parse(raw);
144
+ if (candidate.managedBy !== "nowcrew-project-skills-switch"
145
+ || candidate.version !== 1
146
+ || !NONCE.test(candidate.nonce ?? "")
147
+ || !PHASES.has(candidate.phase)
148
+ || !Array.isArray(candidate.targets)
149
+ || !exactJournalTargets(agentRoot, candidate.nonce, candidate.targets)
150
+ || Object.keys(candidate).some((key) => ![
151
+ "managedBy", "version", "nonce", "phase", "targets", "manifestUpdate",
152
+ ].includes(key))) {
153
+ throw new Error("projection_switch_journal_invalid");
154
+ }
155
+ const manifestUpdate = candidate.manifestUpdate === undefined
156
+ ? undefined
157
+ : parseProjectSkillManifestUpdateRecord(candidate.manifestUpdate);
158
+ return Object.freeze({
159
+ managedBy: "nowcrew-project-skills-switch",
160
+ version: 1,
161
+ nonce: candidate.nonce,
162
+ phase: candidate.phase,
163
+ targets: candidate.targets,
164
+ ...(manifestUpdate === undefined ? {} : { manifestUpdate }),
165
+ });
166
+ };
167
+ const readJournal = async (agentRoot) => {
168
+ const path = projectionSwitchJournalPath(agentRoot);
169
+ let info;
170
+ try {
171
+ info = await lstat(path);
172
+ }
173
+ catch (error) {
174
+ if (errorCode(error) === "ENOENT")
175
+ return null;
176
+ throw error;
177
+ }
178
+ if (!info.isFile() || info.isSymbolicLink() || Number(info.size) > MAX_JOURNAL_BYTES) {
179
+ throw new Error("projection_switch_journal_invalid");
180
+ }
181
+ return parseJournal(await readFile(path, "utf8"), agentRoot);
182
+ };
183
+ const writeJournal = async (agentRoot, journal) => durableAtomicPrivateWrite(projectionSwitchJournalPath(agentRoot), `${JSON.stringify(journal)}\n`);
184
+ const withPhase = (journal, phase) => Object.freeze({ ...journal, phase });
185
+ const unlinkExactJournal = async (agentRoot, nonce) => {
186
+ const current = await readJournal(agentRoot);
187
+ if (current === null)
188
+ return;
189
+ if (current.nonce !== nonce)
190
+ throw new Error("projection_switch_journal_invalid");
191
+ await durablePrivateUnlink(projectionSwitchJournalPath(agentRoot));
192
+ };
193
+ const assertPrevious = async (path, expected) => {
194
+ const actual = await directoryIdentity(path);
195
+ if (actual === null || !sameIdentity(actual, expected))
196
+ throw new Error("projection_switch_backup_unmanaged");
197
+ };
198
+ const assertActivated = async (path, target, nonce) => {
199
+ await assertOwnedRoot(path, target, nonce);
200
+ await assertPrevious(path, target.activatedIdentity);
201
+ };
202
+ const cleanupDiscard = async (target, journal, syncDirectory) => {
203
+ const discardIdentity = await directoryIdentity(target.discard);
204
+ if (discardIdentity === null)
205
+ return;
206
+ if (target.previousIdentity !== null && sameIdentity(discardIdentity, target.previousIdentity)) {
207
+ await rm(target.discard, { recursive: true, force: true });
208
+ await syncDirectory(dirname(target.target));
209
+ return;
210
+ }
211
+ await assertActivated(target.discard, target, journal.nonce);
212
+ await rm(target.discard, { recursive: true, force: true });
213
+ await syncDirectory(dirname(target.target));
214
+ };
215
+ const finalizeCommitted = async (agentRoot, journal, syncDirectory, manifestOperations, unlinkJournal = unlinkExactJournal) => {
216
+ for (const target of journal.targets)
217
+ await assertActivated(target.target, target, journal.nonce);
218
+ const manifestUpdate = journal.manifestUpdate === undefined
219
+ ? null
220
+ : preparedProjectSkillManifestUpdateFromRecord(agentRoot, journal.manifestUpdate);
221
+ if (manifestUpdate !== null)
222
+ await manifestOperations.publish(manifestUpdate);
223
+ for (const target of journal.targets) {
224
+ if (target.hadPrevious && await exists(target.backup)) {
225
+ if (target.previousIdentity === null)
226
+ throw new Error("projection_switch_backup_unmanaged");
227
+ await assertPrevious(target.backup, target.previousIdentity);
228
+ if (await exists(target.discard))
229
+ throw new Error("projection_switch_path_unmanaged");
230
+ await rename(target.backup, target.discard);
231
+ await syncDirectory(dirname(target.target));
232
+ await assertPrevious(target.discard, target.previousIdentity);
233
+ }
234
+ if (await exists(target.discard)) {
235
+ if (target.previousIdentity === null)
236
+ throw new Error("projection_switch_backup_unmanaged");
237
+ await assertPrevious(target.discard, target.previousIdentity);
238
+ await rm(target.discard, { recursive: true, force: true });
239
+ await syncDirectory(dirname(target.target));
240
+ }
241
+ await cleanupProjectionStaging(target, journal.nonce, syncDirectory);
242
+ }
243
+ for (const target of journal.targets)
244
+ await syncDirectory(dirname(target.target));
245
+ if (manifestUpdate !== null)
246
+ await manifestOperations.commit(manifestUpdate);
247
+ await unlinkJournal(agentRoot, journal.nonce);
248
+ };
249
+ const rollback = async (agentRoot, journal, syncDirectory, manifestOperations) => {
250
+ let currentJournal = withPhase(journal, "rolling_back");
251
+ await writeJournal(agentRoot, currentJournal);
252
+ try {
253
+ for (const target of [...currentJournal.targets].reverse()) {
254
+ const currentIdentity = await directoryIdentity(target.target);
255
+ const currentIsPrevious = currentIdentity !== null
256
+ && target.previousIdentity !== null
257
+ && sameIdentity(currentIdentity, target.previousIdentity);
258
+ if (!currentIsPrevious && currentIdentity !== null) {
259
+ await assertActivated(target.target, target, currentJournal.nonce);
260
+ if (await exists(target.discard))
261
+ throw new Error("projection_switch_path_unmanaged");
262
+ await rename(target.target, target.discard);
263
+ await syncDirectory(dirname(target.target));
264
+ await assertActivated(target.discard, target, currentJournal.nonce);
265
+ }
266
+ const afterActivationRemoved = await directoryIdentity(target.target);
267
+ if (target.hadPrevious) {
268
+ if (target.previousIdentity === null)
269
+ throw new Error("projection_switch_backup_unmanaged");
270
+ if (afterActivationRemoved === null) {
271
+ await assertPrevious(target.backup, target.previousIdentity);
272
+ await rename(target.backup, target.target);
273
+ await syncDirectory(dirname(target.target));
274
+ await assertPrevious(target.target, target.previousIdentity);
275
+ }
276
+ else if (!sameIdentity(afterActivationRemoved, target.previousIdentity)) {
277
+ throw new Error("projection_switch_path_unmanaged");
278
+ }
279
+ }
280
+ else if (afterActivationRemoved !== null) {
281
+ throw new Error("projection_switch_path_unmanaged");
282
+ }
283
+ await cleanupDiscard(target, currentJournal, syncDirectory);
284
+ await cleanupProjectionStaging(target, currentJournal.nonce, syncDirectory);
285
+ }
286
+ if (currentJournal.manifestUpdate !== undefined) {
287
+ await manifestOperations.rollback(preparedProjectSkillManifestUpdateFromRecord(agentRoot, currentJournal.manifestUpdate));
288
+ }
289
+ for (const target of currentJournal.targets)
290
+ await syncDirectory(dirname(target.target));
291
+ await unlinkExactJournal(agentRoot, currentJournal.nonce);
292
+ }
293
+ catch (error) {
294
+ currentJournal = withPhase(currentJournal, "rollback_failed");
295
+ await writeJournal(agentRoot, currentJournal);
296
+ throw error;
297
+ }
298
+ };
299
+ export const recoverProjectionSwitch = async (agentRoot, syncDirectory = durableDirectorySync, manifestOperations = {}) => {
300
+ const operations = {
301
+ publish: manifestOperations.publish ?? publishPreparedProjectSkillManifestUpdate,
302
+ commit: manifestOperations.commit ?? commitPreparedProjectSkillManifestUpdate,
303
+ rollback: manifestOperations.rollback ?? rollbackPreparedProjectSkillManifestUpdate,
304
+ };
305
+ const journal = await readJournal(agentRoot);
306
+ if (journal === null)
307
+ return;
308
+ if (journal.phase === "committed") {
309
+ await finalizeCommitted(agentRoot, journal, syncDirectory, operations);
310
+ return;
311
+ }
312
+ await rollback(agentRoot, journal, syncDirectory, operations);
313
+ };
314
+ /**
315
+ * Switches the Codex/Claude roots as one recoverable projection set while the Agent coordinator lock is held.
316
+ * This is not a mythical two-directory filesystem transaction: each root is individually published by rename,
317
+ * so a mixed pair may exist inside the locked switch interval. Every bounded, path-exact journal phase fsyncs its
318
+ * temporary contents before rename and its parent directory afterward. Each target-parent mutation is also fsynced:
319
+ * activations before the durable commit phase, and restoration/cleanup plus a final parent barrier before journal
320
+ * deletion. A v2 state publication is bound into the same journal: recovery rolls every pre-commit phase back to
321
+ * the recorded prior roots and manifest, while a durable committed phase can only finish the next pair's cleanup.
322
+ * No launch using this coordinator observes a successfully returned mixed set. Activated roots require both their
323
+ * nonce marker and their recorded filesystem identity before rollback cleanup.
324
+ */
325
+ export const switchProjectionSet = async (agentRoot, targets, nonce, fault, syncDirectory = durableDirectorySync, publication) => {
326
+ const manifestOperations = {
327
+ publish: publication?.operations?.publish ?? publishPreparedProjectSkillManifestUpdate,
328
+ commit: publication?.operations?.commit ?? commitPreparedProjectSkillManifestUpdate,
329
+ rollback: publication?.operations?.rollback ?? rollbackPreparedProjectSkillManifestUpdate,
330
+ };
331
+ const journalTargets = [];
332
+ try {
333
+ if (!NONCE.test(nonce) || targets.length !== 2)
334
+ throw new Error("projection_switch_journal_invalid");
335
+ const expected = projectionTargets(agentRoot, nonce);
336
+ if (targets.some((target, index) => JSON.stringify(target) !== JSON.stringify(expected[index]))) {
337
+ throw new Error("projection_switch_journal_invalid");
338
+ }
339
+ for (const target of targets) {
340
+ await assertOwnedRoot(target.staging, target, nonce);
341
+ if (await exists(target.backup) || await exists(target.discard)) {
342
+ throw new Error("projection_switch_path_unmanaged");
343
+ }
344
+ const previousIdentity = await directoryIdentity(target.target);
345
+ const activatedIdentity = await directoryIdentity(target.staging);
346
+ if (activatedIdentity === null)
347
+ throw new Error("projection_switch_path_unmanaged");
348
+ journalTargets.push(Object.freeze({
349
+ ...target,
350
+ hadPrevious: previousIdentity !== null,
351
+ previousIdentity,
352
+ activatedIdentity,
353
+ }));
354
+ }
355
+ }
356
+ catch (error) {
357
+ if (publication !== undefined)
358
+ await manifestOperations.rollback(publication.update);
359
+ throw error;
360
+ }
361
+ let journal = Object.freeze({
362
+ managedBy: "nowcrew-project-skills-switch",
363
+ version: 1,
364
+ nonce,
365
+ phase: "prepared",
366
+ targets: Object.freeze(journalTargets),
367
+ ...(publication === undefined
368
+ ? {}
369
+ : { manifestUpdate: projectSkillManifestUpdateRecord(publication.update) }),
370
+ });
371
+ try {
372
+ await writeJournal(agentRoot, journal);
373
+ }
374
+ catch (error) {
375
+ if (publication !== undefined)
376
+ await manifestOperations.rollback(publication.update);
377
+ throw error;
378
+ }
379
+ let committedDurable = false;
380
+ try {
381
+ await fault?.(journal.phase);
382
+ for (const target of journal.targets) {
383
+ if (target.hadPrevious) {
384
+ await rename(target.target, target.backup);
385
+ await syncDirectory(dirname(target.target));
386
+ await assertPrevious(target.backup, target.previousIdentity);
387
+ }
388
+ journal = withPhase(journal, `${target.runtime}:backup_moved`);
389
+ await writeJournal(agentRoot, journal);
390
+ await fault?.(journal.phase);
391
+ await rename(target.staging, target.target);
392
+ await syncDirectory(dirname(target.target));
393
+ await assertActivated(target.target, target, nonce);
394
+ journal = withPhase(journal, `${target.runtime}:activated`);
395
+ await writeJournal(agentRoot, journal);
396
+ await fault?.(journal.phase);
397
+ }
398
+ if (publication !== undefined) {
399
+ await manifestOperations.publish(publication.update);
400
+ await publication.verify();
401
+ await publication.afterPublishedBeforeCommit?.();
402
+ }
403
+ const committedJournal = withPhase(journal, "committed");
404
+ await writeJournal(agentRoot, committedJournal);
405
+ journal = committedJournal;
406
+ committedDurable = true;
407
+ await fault?.(journal.phase);
408
+ await finalizeCommitted(agentRoot, journal, syncDirectory, manifestOperations, publication?.unlinkJournal);
409
+ }
410
+ catch (error) {
411
+ if (committedDurable)
412
+ throw error;
413
+ const persisted = await readJournal(agentRoot);
414
+ if (persisted?.phase === "committed")
415
+ throw error;
416
+ await rollback(agentRoot, persisted ?? journal, syncDirectory, manifestOperations);
417
+ throw error;
418
+ }
419
+ };