Skip to content

Commit 09c1cae

Browse files
authored
feat(daemon): add managed allocation operation journal (#2284)
* feat(daemon): add managed allocation operation journal * fix: harden allocation journal recovery * refactor: share durable file publication seam * fix: preserve host-kit import locality * fix: keep directory sync helper private
1 parent fa06c8c commit 09c1cae

44 files changed

Lines changed: 4430 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/adr/0021-host-simlock-managed-device-allocation.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,12 @@ authorization and attribution around the same boundary. Plain local device selec
110110

111111
Before acquisition, agent-device durably records a non-authoritative allocation operation: the
112112
logical requester, idempotency key, immutable shape request, deadline, and Host attribution when
113-
applicable. After Simlock responds, it records the allocator handle/outcome and whether Host
114-
published or cleaned it. This journal exists only to recover the Host-to-Simlock handoff. It never
115-
mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it never
116-
decides whether a device is reusable.
113+
applicable. After Simlock responds, it records the allocator handle/outcome. Before invoking an
114+
external Host binding publisher, it durably records a pending publication; publication success is
115+
then recorded separately, and recovery conservatively cleans a pending or uncertain binding before
116+
releasing the allocator lease. This journal exists only to recover the Host-to-Simlock handoff. It
117+
never mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it
118+
never decides whether a device is reusable.
117119

118120
Each logical requester is a restart-stable allocation lane; concurrent leases use distinct lanes.
119121
Replaying the same attempt key returns the same durable outcome, including a refusal. Disconnect,

packages/capture-kit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export { decodeDurableDescriptor } from './durable-descriptor-codec.ts';
1616
export { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts';
1717
export { createScreenRecordingCompletion } from './screen-recording-completion.ts';
1818
export { assertScreenRecordingOptionsSupported } from './screen-recording-options.ts';
19+
export { freezeJsonObject, isBoundedJsonObject } from './durable-json.ts';
1920
export {
2021
cleanupManagedAppLogProcess,
2122
reattachCleanupOnlyAppLogProcess,

packages/host-kit/src/file.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
export {
22
isAtomicPublishTemporaryPath,
3+
publishDurableFileSync,
34
publishFileSync,
4-
withAtomicPublishTempPathSync,
5+
type DurableFilePublishMode,
56
} from './internal/atomic-file.ts';
67
export {
78
lstatIfPresent,

packages/host-kit/src/internal/atomic-file.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,52 @@ export function publishFileSync(options: {
3535
});
3636
}
3737

38+
export type DurableFilePublishMode = 'replace' | 'link-exclusive';
39+
40+
/** Publishes complete UTF-8 contents with a durable file and directory fence. */
41+
export function publishDurableFileSync(options: {
42+
destination: string;
43+
contents: string;
44+
mode?: number;
45+
publish?: DurableFilePublishMode;
46+
}): void {
47+
const directory = path.dirname(options.destination);
48+
withAtomicPublishTempPathSync(options.destination, (temporaryPath) => {
49+
let descriptor: number | undefined;
50+
let failed = false;
51+
let primaryError: unknown;
52+
try {
53+
assertSafeDestination(options.destination);
54+
descriptor = fs.openSync(temporaryPath, 'wx', options.mode ?? 0o600);
55+
fs.writeFileSync(descriptor, options.contents, 'utf8');
56+
fs.fsyncSync(descriptor);
57+
fs.closeSync(descriptor);
58+
descriptor = undefined;
59+
assertSafeDestination(options.destination);
60+
if (options.publish === 'link-exclusive') {
61+
fs.linkSync(temporaryPath, options.destination);
62+
} else {
63+
fs.renameSync(temporaryPath, options.destination);
64+
}
65+
syncDirectoryBestEffort(directory);
66+
} catch (error) {
67+
failed = true;
68+
primaryError = error;
69+
}
70+
if (descriptor !== undefined) {
71+
try {
72+
fs.closeSync(descriptor);
73+
} catch (error) {
74+
if (!failed) {
75+
failed = true;
76+
primaryError = error;
77+
}
78+
}
79+
}
80+
if (failed) throw primaryError;
81+
});
82+
}
83+
3884
/**
3985
* Gives a specialized durable publisher a canonical temp path and cleanup
4086
* ownership while it performs its own open/fsync/safety protocol.
@@ -63,6 +109,22 @@ export function withAtomicPublishTempPathSync<T>(
63109
}
64110
}
65111

112+
/** Syncs a containing directory when the host filesystem supports directory fsync. */
113+
function syncDirectoryBestEffort(directory: string): void {
114+
let descriptor: number | undefined;
115+
try {
116+
descriptor = fs.openSync(directory, 'r');
117+
fs.fsyncSync(descriptor);
118+
} catch {
119+
} finally {
120+
if (descriptor !== undefined) {
121+
try {
122+
fs.closeSync(descriptor);
123+
} catch {}
124+
}
125+
}
126+
}
127+
66128
/** Returns the canonical same-directory temp path used by atomic publishers. */
67129
function createAtomicPublishTempPath(destination: string): string {
68130
return path.join(
@@ -78,3 +140,24 @@ export function isAtomicPublishTemporaryPath(value: unknown, destination: string
78140
const name = path.basename(value);
79141
return name.startsWith(`.${path.basename(destination)}.`) && name.endsWith('.tmp');
80142
}
143+
144+
function assertSafeDestination(destination: string): void {
145+
const stats = lstatIfPresent(destination);
146+
if (stats?.isSymbolicLink()) {
147+
throw new Error(`Refusing to replace a durable file symbolic link: ${destination}`);
148+
}
149+
if (stats && !stats.isFile()) {
150+
throw new Error(
151+
`Refusing to replace a durable path that is not a regular file: ${destination}`,
152+
);
153+
}
154+
}
155+
156+
function lstatIfPresent(pathname: string): fs.Stats | undefined {
157+
try {
158+
return fs.lstatSync(pathname);
159+
} catch (error) {
160+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
161+
throw error;
162+
}
163+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { afterEach, expect, test, vi } from 'vitest';
6+
import { isAtomicPublishTemporaryPath, publishDurableFileSync } from './atomic-file.ts';
7+
8+
const roots: string[] = [];
9+
10+
afterEach(() => {
11+
vi.restoreAllMocks();
12+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
13+
});
14+
15+
test('fsyncs complete contents before publication and uses the requested mode', () => {
16+
const root = fixtureRoot('ordering');
17+
const destination = path.join(root, 'record.json');
18+
const events: string[] = [];
19+
const realFsync = fs.fsyncSync;
20+
const realRename = fs.renameSync;
21+
vi.spyOn(fs, 'fsyncSync').mockImplementation((descriptor) => {
22+
events.push('fsync');
23+
return realFsync(descriptor);
24+
});
25+
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
26+
events.push('publish');
27+
return realRename(source, target);
28+
});
29+
30+
publishDurableFileSync({ destination, contents: 'durable\n', mode: 0o640 });
31+
32+
expect(fs.readFileSync(destination, 'utf8')).toBe('durable\n');
33+
expect(fs.statSync(destination).mode & 0o777).toBe(0o640);
34+
expect(events.indexOf('fsync')).toBeGreaterThanOrEqual(0);
35+
expect(events.indexOf('fsync')).toBeLessThan(events.indexOf('publish'));
36+
expect(temporaryPaths(root, destination)).toEqual([]);
37+
});
38+
39+
test.each(['symbolic link', 'non-regular path'] as const)(
40+
'refuses a final %s and removes the temporary file',
41+
(kind) => {
42+
const root = fixtureRoot(kind);
43+
const destination = path.join(root, 'record.json');
44+
if (kind === 'symbolic link') {
45+
const outside = path.join(root, 'outside.json');
46+
fs.writeFileSync(outside, 'outside');
47+
fs.symlinkSync(outside, destination);
48+
} else {
49+
fs.mkdirSync(destination);
50+
}
51+
52+
expect(() => publishDurableFileSync({ destination, contents: 'replacement' })).toThrow(
53+
kind === 'symbolic link' ? /symbolic link/ : /not a regular file/,
54+
);
55+
expect(temporaryPaths(root, destination)).toEqual([]);
56+
},
57+
);
58+
59+
test('keeps an existing destination on link-exclusive publication failure', () => {
60+
const root = fixtureRoot('exclusive');
61+
const destination = path.join(root, 'record.json');
62+
fs.writeFileSync(destination, 'original');
63+
64+
expect(() =>
65+
publishDurableFileSync({
66+
destination,
67+
contents: 'replacement',
68+
publish: 'link-exclusive',
69+
}),
70+
).toThrow(/EEXIST/);
71+
expect(fs.readFileSync(destination, 'utf8')).toBe('original');
72+
expect(temporaryPaths(root, destination)).toEqual([]);
73+
});
74+
75+
test('preserves the publication error while cleaning the temporary file', () => {
76+
const root = fixtureRoot('publish-error');
77+
const destination = path.join(root, 'record.json');
78+
const primary = new Error('publication failed');
79+
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
80+
throw primary;
81+
});
82+
83+
assert.throws(
84+
() => publishDurableFileSync({ destination, contents: 'durable' }),
85+
(error: unknown) => error === primary,
86+
);
87+
expect(temporaryPaths(root, destination)).toEqual([]);
88+
});
89+
90+
test('preserves a file fsync error when descriptor cleanup also fails', () => {
91+
const root = fixtureRoot('close-error');
92+
const destination = path.join(root, 'record.json');
93+
const primary = new Error('file fsync failed');
94+
const secondary = new Error('descriptor close failed');
95+
const realClose = fs.closeSync;
96+
vi.spyOn(fs, 'fsyncSync').mockImplementation(() => {
97+
throw primary;
98+
});
99+
vi.spyOn(fs, 'closeSync').mockImplementation((descriptor) => {
100+
realClose(descriptor);
101+
throw secondary;
102+
});
103+
104+
assert.throws(
105+
() => publishDurableFileSync({ destination, contents: 'durable' }),
106+
(error: unknown) => error === primary,
107+
);
108+
expect(temporaryPaths(root, destination)).toEqual([]);
109+
});
110+
111+
function fixtureRoot(label: string): string {
112+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-durable-file-${label}-`));
113+
roots.push(root);
114+
return root;
115+
}
116+
117+
function temporaryPaths(root: string, destination: string): string[] {
118+
return fs
119+
.readdirSync(root)
120+
.map((name) => path.join(root, name))
121+
.filter((pathname) => isAtomicPublishTemporaryPath(pathname, destination));
122+
}

src/daemon/__tests__/atomic-publish-ownership.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@ test('simple same-directory publishers use the shared atomic publish owner', ()
2222
}
2323
});
2424

25-
test('durable capture publication keeps its specialized fsync and destination checks', () => {
26-
const source = fs.readFileSync(
25+
test('durable publishers share the host-kit durable publication owner', () => {
26+
const sourcePaths = [
2727
new URL('../durable-capture-resource-store.ts', import.meta.url),
28-
'utf8',
29-
);
30-
assert.match(source, /withAtomicPublishTempPathSync/);
31-
assert.match(source, /fs\.openSync\([^\n]+['"]wx['"]/);
32-
assert.match(source, /fs\.fsyncSync/);
33-
assert.match(source, /fs\.renameSync/);
34-
assert.match(source, /assertSafeDestination/);
28+
new URL('../managed-device-allocation/store-filesystem.ts', import.meta.url),
29+
];
30+
for (const sourcePath of sourcePaths) {
31+
const source = fs.readFileSync(sourcePath, 'utf8');
32+
assert.match(source, /publishDurableFileSync/);
33+
assert.doesNotMatch(
34+
source,
35+
/fs\.(?:openSync|writeFileSync|fsyncSync|renameSync|linkSync)\s*\(/,
36+
);
37+
assert.doesNotMatch(source, /assertSafeDestination/);
38+
}
3539
});

src/daemon/__tests__/config.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ test('resolveDaemonPaths keeps explicit state directories authoritative', () =>
1010
try {
1111
const paths = resolveDaemonPaths('~/custom-daemon', { env: { HOME: home } });
1212
assert.equal(paths.baseDir, path.join(home, 'custom-daemon'));
13+
assert.equal(paths.allocationsDir, path.join(home, 'custom-daemon', 'allocations'));
1314
} finally {
1415
fs.rmSync(home, { recursive: true, force: true });
1516
}

src/daemon/client/__tests__/boundary-fault-acceptance.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ function daemonPaths(baseDir: string): DaemonPaths {
132132
infoPath: path.join(baseDir, 'daemon.json'),
133133
lockPath: path.join(baseDir, 'daemon.lock'),
134134
logPath: path.join(baseDir, 'daemon.log'),
135+
allocationsDir: path.join(baseDir, 'allocations'),
135136
sessionsDir: path.join(baseDir, 'sessions'),
136137
};
137138
}

src/daemon/client/__tests__/boundary-fault-transport.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ function daemonPaths(): DaemonPaths {
251251
infoPath: path.join(baseDir, 'daemon.json'),
252252
lockPath: path.join(baseDir, 'daemon.lock'),
253253
logPath: path.join(baseDir, 'daemon.log'),
254+
allocationsDir: path.join(baseDir, 'allocations'),
254255
sessionsDir: path.join(baseDir, 'sessions'),
255256
};
256257
}

src/daemon/client/__tests__/daemon-client-timeout-route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function dummyStatePaths(): DaemonPaths {
5959
infoPath: path.join(baseDir, 'daemon.json'),
6060
lockPath: path.join(baseDir, 'daemon.lock'),
6161
logPath: path.join(baseDir, 'daemon.log'),
62+
allocationsDir: path.join(baseDir, 'allocations'),
6263
sessionsDir: path.join(baseDir, 'sessions'),
6364
};
6465
}

0 commit comments

Comments
 (0)