@playcraft/cli 0.1.57-beta → 0.1.59-beta
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/commands/workspace.js +43 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +278 -20
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -4
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +13 -3
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +74 -29
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +80 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +124 -8
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +55 -3
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
4
5
|
import { lstat } from 'node:fs/promises';
|
|
5
6
|
import { StringDecoder } from 'node:string_decoder';
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
@@ -8,13 +9,23 @@ import { appendTerminalOutput, createTerminalOutputLog, decodeTerminalBytes, run
|
|
|
8
9
|
import { resolveWorkspaceFile } from '../workspaces/files.js';
|
|
9
10
|
import { sessionWorkspaceRoot } from '../workspaces/registry.js';
|
|
10
11
|
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
12
|
+
import { stopBackgroundWriter } from '../processes/managed-writes.js';
|
|
11
13
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
14
|
+
import { cancelPendingDirectoryAccess, holdDirectoryAccess } from './directory-access.js';
|
|
12
15
|
const require = createRequire(import.meta.url);
|
|
16
|
+
/** A shell nobody views, silent this long and running nothing besides itself, is closed. */
|
|
17
|
+
const IDLE_RECLAIM_MS = 30 * 60_000;
|
|
18
|
+
const IDLE_SWEEP_MS = 120_000;
|
|
13
19
|
const commands = new WeakMap();
|
|
20
|
+
const sessionProbes = new WeakMap();
|
|
14
21
|
const hosts = new WeakMap();
|
|
15
22
|
export function setTerminalCommand(state, command) {
|
|
16
23
|
commands.set(state, command);
|
|
17
24
|
}
|
|
25
|
+
/** Replaces the /proc session scan, for platforms without /proc. */
|
|
26
|
+
export function setTerminalSessionProbe(state, probe) {
|
|
27
|
+
sessionProbes.set(state, probe);
|
|
28
|
+
}
|
|
18
29
|
export function terminalHost(state) {
|
|
19
30
|
let host = hosts.get(state);
|
|
20
31
|
if (!host) {
|
|
@@ -23,6 +34,20 @@ export function terminalHost(state) {
|
|
|
23
34
|
}
|
|
24
35
|
return host;
|
|
25
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The supervisor advances the runtime epoch only after the launcher confirms that every process it
|
|
39
|
+
* started, terminals included, has exited. A terminal from an earlier epoch is gone whatever its record says.
|
|
40
|
+
*/
|
|
41
|
+
export async function retireStoppedTerminals(journal, runtimeEpoch, previousEpoch) {
|
|
42
|
+
if (previousEpoch === undefined || runtimeEpoch <= previousEpoch)
|
|
43
|
+
return;
|
|
44
|
+
const destroyedAt = new Date().toISOString();
|
|
45
|
+
for (const terminal of journal.listTerminals()) {
|
|
46
|
+
if (terminal.status === 'destroyed' || (terminal.runtimeEpoch ?? 0) >= runtimeEpoch)
|
|
47
|
+
continue;
|
|
48
|
+
await journal.putTerminal({ ...terminal, status: 'destroyed', destroyedAt });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
26
51
|
export function disposeTerminalHost(state) {
|
|
27
52
|
hosts.get(state)?.dispose();
|
|
28
53
|
hosts.delete(state);
|
|
@@ -37,11 +62,16 @@ export class TerminalHost {
|
|
|
37
62
|
closing = new Set();
|
|
38
63
|
stops = new Map();
|
|
39
64
|
confirmed = new Set();
|
|
65
|
+
directoryReleases = new Map();
|
|
40
66
|
sockets = new WebSocketServer({ noServer: true });
|
|
67
|
+
idleSweep;
|
|
41
68
|
constructor(state) {
|
|
42
69
|
this.state = state;
|
|
70
|
+
this.idleSweep = setInterval(() => void this.reclaimIdle(), IDLE_SWEEP_MS);
|
|
71
|
+
this.idleSweep.unref?.();
|
|
43
72
|
// An absent in-memory session cannot prove a restored terminal's old process group stopped.
|
|
44
|
-
this.restored = new Set((state.journal?.listTerminals?.() ?? [])
|
|
73
|
+
this.restored = new Set((state.journal?.listTerminals?.() ?? [])
|
|
74
|
+
.filter(terminal => terminal.status !== 'destroyed').map(terminal => terminal.terminalId));
|
|
45
75
|
}
|
|
46
76
|
async handleUpgrade(request, socket, head) {
|
|
47
77
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
@@ -110,7 +140,14 @@ export class TerminalHost {
|
|
|
110
140
|
return pending;
|
|
111
141
|
this.closing.add(terminalId);
|
|
112
142
|
const stopped = (async () => {
|
|
113
|
-
await this.
|
|
143
|
+
await cancelPendingDirectoryAccess(this.state, `terminal:${terminalId}`);
|
|
144
|
+
try {
|
|
145
|
+
await this.spawning.get(terminalId);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (this.attempted.has(terminalId) && !this.confirmed.has(terminalId))
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
114
151
|
const session = this.sessions.get(terminalId);
|
|
115
152
|
if (session)
|
|
116
153
|
await session.stop();
|
|
@@ -123,6 +160,7 @@ export class TerminalHost {
|
|
|
123
160
|
...terminal, status: 'destroyed', destroyedAt: new Date().toISOString(),
|
|
124
161
|
});
|
|
125
162
|
this.confirmed.add(terminalId);
|
|
163
|
+
this.releaseDirectory(terminalId);
|
|
126
164
|
this.revoke(terminalId, 'terminal_destroyed');
|
|
127
165
|
this.closing.delete(terminalId);
|
|
128
166
|
return destroyed;
|
|
@@ -139,20 +177,58 @@ export class TerminalHost {
|
|
|
139
177
|
for (const listener of session.listeners)
|
|
140
178
|
listener(frame);
|
|
141
179
|
session.listeners.clear();
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
180
|
+
void session.stop().then(() => this.exited(terminalId, session, null)).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
/** Closes shells with no browser, no input or output for the idle window, and nothing else running. */
|
|
183
|
+
async reclaimIdle(now = Date.now()) {
|
|
184
|
+
const reclaimed = [];
|
|
185
|
+
for (const [terminalId, session] of [...this.sessions]) {
|
|
186
|
+
if (session.exited || session.listeners.size > 0 || this.closing.has(terminalId)
|
|
187
|
+
|| now - session.lastActivity < IDLE_RECLAIM_MS)
|
|
188
|
+
continue;
|
|
189
|
+
// A background job keeps the shell; a session that cannot be inspected is kept too.
|
|
190
|
+
if (!session.pid || (sessionProbes.get(this.state) ?? runsOtherProcesses)(session.pid) !== false)
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
await this.destroy(terminalId);
|
|
194
|
+
reclaimed.push(terminalId);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// The next sweep tries again.
|
|
198
|
+
}
|
|
147
199
|
}
|
|
200
|
+
return reclaimed;
|
|
148
201
|
}
|
|
149
202
|
dispose() {
|
|
203
|
+
clearInterval(this.idleSweep);
|
|
150
204
|
this.disposed = true;
|
|
151
205
|
for (const terminalId of [...this.sessions.keys()])
|
|
152
206
|
this.revoke(terminalId, 'runtime_destroyed');
|
|
153
207
|
this.sockets.close();
|
|
154
208
|
}
|
|
155
209
|
async spawn(terminal, cwd) {
|
|
210
|
+
if (this.closing.has(terminal.terminalId) || this.disposed || this.state.journal.destroyed() ||
|
|
211
|
+
this.state.journal.getTerminal(terminal.terminalId)?.status === 'destroyed')
|
|
212
|
+
throw new Error('pty_unavailable');
|
|
213
|
+
const release = await holdDirectoryAccess(this.state, terminal, `terminal:${terminal.terminalId}`, 'shared');
|
|
214
|
+
this.directoryReleases.set(terminal.terminalId, release);
|
|
215
|
+
try {
|
|
216
|
+
if (this.closing.has(terminal.terminalId) || this.state.journal.destroyed() ||
|
|
217
|
+
this.state.journal.getTerminal(terminal.terminalId)?.status === 'destroyed')
|
|
218
|
+
throw new Error('pty_unavailable');
|
|
219
|
+
return await this.spawnProcess(terminal, cwd);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (!this.attempted.has(terminal.terminalId) || this.confirmed.has(terminal.terminalId))
|
|
223
|
+
this.releaseDirectory(terminal.terminalId);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
releaseDirectory(terminalId) {
|
|
228
|
+
this.directoryReleases.get(terminalId)?.();
|
|
229
|
+
this.directoryReleases.delete(terminalId);
|
|
230
|
+
}
|
|
231
|
+
async spawnProcess(terminal, cwd) {
|
|
156
232
|
if (this.disposed)
|
|
157
233
|
throw new Error('pty_unavailable');
|
|
158
234
|
if (this.state.config.workspaceMode === 'multi' || process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
@@ -167,9 +243,11 @@ export class TerminalHost {
|
|
|
167
243
|
runtimeEpoch: this.state.runtimeEpoch, cwd, argv: [], env: {}, terminal: { cols: 80, rows: 24 } });
|
|
168
244
|
if (this.disposed) {
|
|
169
245
|
await child.stop();
|
|
246
|
+
this.confirmed.add(terminal.terminalId);
|
|
170
247
|
throw new Error('pty_unavailable');
|
|
171
248
|
}
|
|
172
249
|
const session = { terminalId: terminal.terminalId, streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
|
|
250
|
+
pid: child.pid ?? 0, lastActivity: Date.now(),
|
|
173
251
|
stop: () => child.stop(),
|
|
174
252
|
pty: { write: (data) => { child.stdin?.write(data); }, resize: child.resize,
|
|
175
253
|
kill: () => { void child.stop().catch(() => undefined); } } };
|
|
@@ -204,6 +282,8 @@ export class TerminalHost {
|
|
|
204
282
|
cwd,
|
|
205
283
|
env: shellEnv(command.file, cwd),
|
|
206
284
|
}),
|
|
285
|
+
pid: 0,
|
|
286
|
+
lastActivity: Date.now(),
|
|
207
287
|
stop: async () => {
|
|
208
288
|
if (!didExit)
|
|
209
289
|
session.pty.kill();
|
|
@@ -212,6 +292,8 @@ export class TerminalHost {
|
|
|
212
292
|
await Promise.race([knownExit, new Promise((_resolve, reject) => {
|
|
213
293
|
timer = setTimeout(() => reject(new Error('terminal_stop_unconfirmed')), 3000);
|
|
214
294
|
})]);
|
|
295
|
+
if (!await stopBackgroundWriter(session.pty.pid))
|
|
296
|
+
throw new Error('terminal_stop_unconfirmed');
|
|
215
297
|
}
|
|
216
298
|
finally {
|
|
217
299
|
clearTimeout(timer);
|
|
@@ -219,8 +301,12 @@ export class TerminalHost {
|
|
|
219
301
|
},
|
|
220
302
|
};
|
|
221
303
|
const local = session.pty;
|
|
304
|
+
session.pid = local.pid;
|
|
222
305
|
local.onData((data) => publish(session, data));
|
|
223
|
-
local.onExit(({ exitCode, signal }) => {
|
|
306
|
+
local.onExit(({ exitCode, signal }) => {
|
|
307
|
+
finishExit();
|
|
308
|
+
void session.stop().then(() => this.exited(terminal.terminalId, session, exitCode, signal), () => this.exited(terminal.terminalId, session, undefined, signal)).catch(() => undefined);
|
|
309
|
+
});
|
|
224
310
|
this.sessions.set(terminal.terminalId, session);
|
|
225
311
|
return session;
|
|
226
312
|
}
|
|
@@ -235,6 +321,7 @@ export class TerminalHost {
|
|
|
235
321
|
});
|
|
236
322
|
if (terminal)
|
|
237
323
|
this.confirmed.add(terminalId);
|
|
324
|
+
this.releaseDirectory(terminalId);
|
|
238
325
|
}
|
|
239
326
|
}
|
|
240
327
|
finally {
|
|
@@ -262,6 +349,7 @@ export class TerminalHost {
|
|
|
262
349
|
send(frame);
|
|
263
350
|
};
|
|
264
351
|
session.listeners.add(listener);
|
|
352
|
+
session.lastActivity = Date.now();
|
|
265
353
|
ws.on('message', (raw) => {
|
|
266
354
|
let body;
|
|
267
355
|
try {
|
|
@@ -297,6 +385,7 @@ export class TerminalHost {
|
|
|
297
385
|
}
|
|
298
386
|
if (!resumed)
|
|
299
387
|
return;
|
|
388
|
+
session.lastActivity = Date.now();
|
|
300
389
|
if (frame.type === 'resize') {
|
|
301
390
|
session.pty.resize(frame.cols, frame.rows);
|
|
302
391
|
return;
|
|
@@ -306,12 +395,14 @@ export class TerminalHost {
|
|
|
306
395
|
const detach = () => {
|
|
307
396
|
open = false;
|
|
308
397
|
session.listeners.delete(listener);
|
|
398
|
+
session.lastActivity = Date.now();
|
|
309
399
|
};
|
|
310
400
|
ws.on('close', detach);
|
|
311
401
|
ws.on('error', detach);
|
|
312
402
|
}
|
|
313
403
|
}
|
|
314
404
|
function publish(session, text) {
|
|
405
|
+
session.lastActivity = Date.now();
|
|
315
406
|
const before = session.log.end;
|
|
316
407
|
session.log = appendTerminalOutput(session.log, text);
|
|
317
408
|
const since = terminalOutputSince(session.log, before);
|
|
@@ -326,6 +417,31 @@ function publish(session, text) {
|
|
|
326
417
|
listener(frame);
|
|
327
418
|
}
|
|
328
419
|
}
|
|
420
|
+
/** `true` while a process other than the shell runs in its session; `undefined` when /proc cannot tell. */
|
|
421
|
+
function runsOtherProcesses(shellPid) {
|
|
422
|
+
let names;
|
|
423
|
+
try {
|
|
424
|
+
names = readdirSync('/proc');
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
for (const name of names) {
|
|
430
|
+
if (!/^\d+$/.test(name) || Number(name) === shellPid)
|
|
431
|
+
continue;
|
|
432
|
+
try {
|
|
433
|
+
const stat = readFileSync(`/proc/${name}/stat`, 'utf8');
|
|
434
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
435
|
+
if (Number(fields[3]) === shellPid && !['Z', 'X'].includes(fields[0] ?? ''))
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (!['ENOENT', 'ESRCH'].includes(error.code ?? ''))
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
329
445
|
function exitFrame(session) {
|
|
330
446
|
return {
|
|
331
447
|
type: 'exit',
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
import { Readable, Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
|
|
8
|
+
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
9
|
+
import { createStorageSnapshot, extractStorageSnapshot, STORAGE_SNAPSHOT_MAX_BYTES } from '../workspaces/storage-snapshot.js';
|
|
10
|
+
import { withDirectoryWrite } from './directory-access.js';
|
|
11
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
12
|
+
/** Private control routes. Workspace identity survives disk eviction in the runtime journal. */
|
|
13
|
+
export async function handleWorkspaceStorage(state, request, response, url) {
|
|
14
|
+
const route = /^\/runtime\/workspaces\/([^/]+)\/storage-(status|snapshot|evict|restore)$/.exec(url.pathname);
|
|
15
|
+
if (!route)
|
|
16
|
+
return false;
|
|
17
|
+
const [, workspaceId, action] = route;
|
|
18
|
+
const json = (status, body) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'private, no-store' }); response.end(JSON.stringify(body)); };
|
|
19
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
20
|
+
let download;
|
|
21
|
+
let result;
|
|
22
|
+
const closed = () => { if (!response.writableEnded)
|
|
23
|
+
abort(); };
|
|
24
|
+
request.once('aborted', abort);
|
|
25
|
+
response.once('close', closed);
|
|
26
|
+
try {
|
|
27
|
+
if (state.config.workspaceMode !== 'multi' || !/^[0-9a-f-]{36}$/i.test(workspaceId))
|
|
28
|
+
throw new Error('storage_snapshot_invalid');
|
|
29
|
+
if (state.journal.destroyed())
|
|
30
|
+
throw new Error('runtime_destroyed');
|
|
31
|
+
if ((action === 'status' || action === 'snapshot') ? request.method !== 'GET' : request.method !== 'POST') {
|
|
32
|
+
json(405, { error: 'method_not_allowed' });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
for (const [name, expected] of [['x-playcraft-generation', state.config.generation], ['x-playcraft-runtime-epoch', state.runtimeEpoch]]) {
|
|
36
|
+
const value = request.headers[name];
|
|
37
|
+
if (value !== undefined && value !== String(expected))
|
|
38
|
+
throw new Error('generation_mismatch');
|
|
39
|
+
}
|
|
40
|
+
const parent = await realpath(state.config.workspaceRoot), root = join(parent, workspaceId);
|
|
41
|
+
const stateRoot = await realpath(state.config.stateDir);
|
|
42
|
+
if (stateRoot === root || stateRoot.startsWith(`${root}${sep}`))
|
|
43
|
+
throw new Error('storage_snapshot_invalid');
|
|
44
|
+
const present = async () => {
|
|
45
|
+
const info = await lstat(root).catch(error => { if (error.code === 'ENOENT')
|
|
46
|
+
return undefined; throw error; });
|
|
47
|
+
if (!info)
|
|
48
|
+
return false;
|
|
49
|
+
if (!info.isDirectory() || info.isSymbolicLink() || relative(parent, await realpath(root)) !== workspaceId)
|
|
50
|
+
throw new Error('storage_snapshot_invalid');
|
|
51
|
+
return (await readdir(root)).length > 0;
|
|
52
|
+
};
|
|
53
|
+
if (action === 'status') {
|
|
54
|
+
json(200, { present: await present() });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if ((action === 'snapshot' || action === 'evict') && !state.journal.getWorkspace(workspaceId))
|
|
58
|
+
throw new Error('workspace_not_registered');
|
|
59
|
+
const directories = [...new Set(['root', 'basic', ...(state.journal.getWorkspace(workspaceId)?.directories ?? []).map(item => item.directoryName)])];
|
|
60
|
+
const assertIdle = async () => {
|
|
61
|
+
controller.signal.throwIfAborted();
|
|
62
|
+
if (state.journal.destroyed())
|
|
63
|
+
throw new Error('runtime_destroyed');
|
|
64
|
+
for (const directory of directories)
|
|
65
|
+
if (await workspaceDirectoryOccupied(state, workspaceId, directory))
|
|
66
|
+
throw new Error('workspace_directory_occupied');
|
|
67
|
+
};
|
|
68
|
+
await withDirectoryWrite(state, { workspaceId, directoryName: 'root', mode: 'exclusive', wait: false }, async () => {
|
|
69
|
+
await assertIdle();
|
|
70
|
+
const markerDir = join(state.config.stateDir, 'workspace-storage'), marker = join(markerDir, `${workspaceId}.json`);
|
|
71
|
+
if (action === 'snapshot') {
|
|
72
|
+
if (!(await present()))
|
|
73
|
+
throw new Error('workspace_not_prepared');
|
|
74
|
+
const snapshot = await createStorageSnapshot(root, { signal: controller.signal, excludedRoots: [state.config.stateDir] });
|
|
75
|
+
try {
|
|
76
|
+
await assertIdle();
|
|
77
|
+
download = snapshot;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
await snapshot.cleanup();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action === 'evict') {
|
|
86
|
+
let body = '';
|
|
87
|
+
for await (const part of request) {
|
|
88
|
+
body += part.toString();
|
|
89
|
+
if (body.length > 1024)
|
|
90
|
+
throw new Error('storage_snapshot_invalid');
|
|
91
|
+
}
|
|
92
|
+
const sha256 = JSON.parse(body).sha256;
|
|
93
|
+
if (typeof sha256 !== 'string' || !SHA256.test(sha256))
|
|
94
|
+
throw new Error('storage_snapshot_invalid');
|
|
95
|
+
const removed = join(parent, `.storage-evicted-${workspaceId}-${sha256}`);
|
|
96
|
+
if (!(await present())) {
|
|
97
|
+
const saved = await readFile(marker, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
98
|
+
if (saved?.sha256 !== sha256)
|
|
99
|
+
throw new Error('storage_snapshot_conflict');
|
|
100
|
+
await rm(removed, { recursive: true, force: true });
|
|
101
|
+
result = { evicted: true };
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const snapshot = await createStorageSnapshot(root, { signal: controller.signal, excludedRoots: [state.config.stateDir] });
|
|
105
|
+
try {
|
|
106
|
+
if (snapshot.sha256 !== sha256)
|
|
107
|
+
throw new Error('storage_snapshot_conflict');
|
|
108
|
+
await assertIdle();
|
|
109
|
+
// private
|
|
110
|
+
await mkdir(markerDir, { recursive: true, mode: 0o700 });
|
|
111
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
112
|
+
// private
|
|
113
|
+
await writeFile(temporary, JSON.stringify({ sha256 }), { flag: 'wx', mode: 0o600 });
|
|
114
|
+
await rename(temporary, marker);
|
|
115
|
+
// Moving first makes a partially-deleted directory invisible to readers and safe to retry.
|
|
116
|
+
await rename(root, removed);
|
|
117
|
+
await rm(removed, { recursive: true, force: true });
|
|
118
|
+
result = { evicted: true };
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await snapshot.cleanup();
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (await present())
|
|
126
|
+
throw new Error('storage_snapshot_destination_exists');
|
|
127
|
+
const sha256 = request.headers['x-playcraft-snapshot-sha256'];
|
|
128
|
+
if (typeof sha256 !== 'string' || !SHA256.test(sha256))
|
|
129
|
+
throw new Error('storage_snapshot_invalid');
|
|
130
|
+
const rawLength = request.headers['content-length'];
|
|
131
|
+
if (rawLength !== undefined && (typeof rawLength !== 'string' || !/^\d+$/.test(rawLength) || !Number.isSafeInteger(Number(rawLength)) || Number(rawLength) > STORAGE_SNAPSHOT_MAX_BYTES))
|
|
132
|
+
throw new Error('storage_snapshot_too_large');
|
|
133
|
+
const temporary = await mkdtemp(join(parent, '.storage-restore-'));
|
|
134
|
+
try {
|
|
135
|
+
const incoming = join(temporary, 'incoming.gz'), staged = join(temporary, 'workspace');
|
|
136
|
+
const hash = createHash('sha256');
|
|
137
|
+
let size = 0;
|
|
138
|
+
await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), new Transform({ transform(chunk, _encoding, done) {
|
|
139
|
+
size += chunk.length;
|
|
140
|
+
hash.update(chunk);
|
|
141
|
+
done(size > STORAGE_SNAPSHOT_MAX_BYTES ? new Error('storage_snapshot_too_large') : null, chunk);
|
|
142
|
+
} }), createWriteStream(incoming, { flags: 'wx', mode: 0o600 }), { signal: controller.signal }); // private
|
|
143
|
+
if (hash.digest('hex') !== sha256 || rawLength !== undefined && size !== Number(rawLength))
|
|
144
|
+
throw new Error('storage_snapshot_checksum_mismatch');
|
|
145
|
+
// private
|
|
146
|
+
await mkdir(staged, { mode: 0o700 });
|
|
147
|
+
await extractStorageSnapshot(incoming, staged, { signal: controller.signal });
|
|
148
|
+
await openDirectoryToDevelopment(staged, (await lstat(parent)).gid);
|
|
149
|
+
await assertIdle();
|
|
150
|
+
if (await present())
|
|
151
|
+
throw new Error('storage_snapshot_destination_exists');
|
|
152
|
+
const evicted = await readFile(marker, 'utf8').then(value => JSON.parse(value), () => undefined);
|
|
153
|
+
if (typeof evicted?.sha256 === 'string' && SHA256.test(evicted.sha256))
|
|
154
|
+
await rm(join(parent, `.storage-evicted-${workspaceId}-${evicted.sha256}`), { recursive: true, force: true });
|
|
155
|
+
await rmdir(root).catch(error => { if (error.code !== 'ENOENT')
|
|
156
|
+
throw error; });
|
|
157
|
+
await rename(staged, root);
|
|
158
|
+
await rm(marker, { force: true });
|
|
159
|
+
result = { restored: true };
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await rm(temporary, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
if (result)
|
|
166
|
+
json(200, result);
|
|
167
|
+
if (download) {
|
|
168
|
+
const snapshot = download;
|
|
169
|
+
try {
|
|
170
|
+
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': snapshot.bytes,
|
|
171
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-playcraft-snapshot-sha256': snapshot.sha256 });
|
|
172
|
+
await pipeline(createReadStream(snapshot.path), response, { signal: controller.signal });
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await snapshot.cleanup();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (response.headersSent)
|
|
181
|
+
response.destroy(error instanceof Error ? error : undefined);
|
|
182
|
+
else if (!controller.signal.aborted) {
|
|
183
|
+
const reason = error instanceof Error ? error.message : '';
|
|
184
|
+
const code = /^(?:storage_snapshot_[a-z_]+|workspace_(?:not_registered|not_prepared|directory_occupied)|directory_busy|runtime_destroyed|generation_mismatch)$/.test(reason) ? reason : 'storage_snapshot_failed';
|
|
185
|
+
json(code === 'storage_snapshot_too_large' ? 413 : code === 'storage_snapshot_invalid' ? 400 : 409, { error: code });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
request.off('aborted', abort);
|
|
190
|
+
response.off('close', closed);
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
@@ -105,7 +105,9 @@ export async function handleWorkspaceTransfer(state, request, response, url) {
|
|
|
105
105
|
lastProbe = size;
|
|
106
106
|
assertTransferDiskSpace(temporary, chunk.length).then(() => callback(null, chunk), error => callback(error));
|
|
107
107
|
} });
|
|
108
|
-
await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), guard,
|
|
108
|
+
await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), guard,
|
|
109
|
+
// private
|
|
110
|
+
createWriteStream(bundlePath, { flags: 'wx', mode: 0o600 }), { signal: controller.signal });
|
|
109
111
|
if (!size || (declaredLength !== undefined && size !== declaredLength))
|
|
110
112
|
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
111
113
|
const lockKeys = [...new Set([...directories.map(name => `directory:${workspaceId}:${name}`),
|
|
@@ -18,7 +18,9 @@ export class AppendOnlyLog {
|
|
|
18
18
|
async append(chunk) {
|
|
19
19
|
if (!chunk)
|
|
20
20
|
return;
|
|
21
|
+
// private
|
|
21
22
|
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
23
|
+
// private
|
|
22
24
|
const handle = await open(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
23
25
|
try {
|
|
24
26
|
await handle.write(Buffer.from(chunk));
|
|
@@ -34,6 +36,7 @@ export class AppendOnlyLog {
|
|
|
34
36
|
const tail = content.subarray(Math.max(0, content.length - (this.maxBytes - notice.length)));
|
|
35
37
|
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
36
38
|
try {
|
|
39
|
+
// private
|
|
37
40
|
await writeFile(temporary, Buffer.concat([notice, tail]), { mode: 0o600 });
|
|
38
41
|
await rename(temporary, this.path);
|
|
39
42
|
}
|
|
@@ -6,14 +6,17 @@ import { watch } from 'chokidar';
|
|
|
6
6
|
import { join, relative, sep } from 'node:path';
|
|
7
7
|
import { readdir } from 'node:fs/promises';
|
|
8
8
|
import { generatedWorkspace } from './artifact-receipts.js';
|
|
9
|
+
import { withShareRepair } from './workspace-shares.js';
|
|
9
10
|
export async function refreshWorkspaceArtifacts(state, workspaceId, origin, directoryName = 'basic') {
|
|
10
11
|
if (state.journal.destroyed())
|
|
11
12
|
throw new Error('runtime_destroyed');
|
|
12
13
|
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
13
14
|
const active = state.journal.listSessions().some(session => session.workspaceId === workspaceId
|
|
14
15
|
&& (session.directoryName ?? 'basic') === directoryName && ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? ''));
|
|
15
|
-
const
|
|
16
|
-
|
|
16
|
+
const store = new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName);
|
|
17
|
+
// A scan that hits a drifted directory repairs it once as the owner and retries, so a transient
|
|
18
|
+
// EACCES never turns artifact discovery into a 500 for the session.
|
|
19
|
+
const result = await withShareRepair(state, root, () => store.discover(root, origin?.source, origin?.paths, origin?.playable ?? (active ? { mode: 'hold' } : {})));
|
|
17
20
|
if (result.changed && !state.journal.destroyed()) {
|
|
18
21
|
const event = await state.journal.allocateSourceEvent({
|
|
19
22
|
runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, mkdir, open, readFile, realpath, link, unlink } from 'node:fs/promises';
|
|
4
4
|
import { extname, join, posix, resolve, sep } from 'node:path';
|
|
5
5
|
import { WORKSPACE_ARTIFACT_MAX_BYTES, isWorkspaceArtifactOutputPath, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { openSharedFile } from './shared-write.js';
|
|
6
7
|
const formats = {
|
|
7
8
|
'.png': ['image', 'image/png'], '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
|
|
8
9
|
'.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.avif': ['image', 'image/avif'], '.svg': ['image', 'image/svg+xml'],
|
|
@@ -34,10 +35,12 @@ export async function artifactPath(root, path) {
|
|
|
34
35
|
throw new Error('invalid_artifact_path');
|
|
35
36
|
return target;
|
|
36
37
|
}
|
|
38
|
+
/** Private catalog storage: only the controller reads the runtime state directory. */
|
|
37
39
|
export async function artifactDirectory(root, relative) {
|
|
38
40
|
let target = await realpath(root);
|
|
39
41
|
for (const part of workspaceRelativePathSchema.parse(relative).split('/')) {
|
|
40
42
|
target = join(target, part);
|
|
43
|
+
// private
|
|
41
44
|
await mkdir(target, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
|
|
42
45
|
throw error; });
|
|
43
46
|
const stat = await lstat(target);
|
|
@@ -47,7 +50,7 @@ export async function artifactDirectory(root, relative) {
|
|
|
47
50
|
return target;
|
|
48
51
|
}
|
|
49
52
|
/** Copies the exact bytes observed, and refuses a file changed while being copied. */
|
|
50
|
-
export async function snapshotArtifactFile(root, path, objects, role = 'primary') {
|
|
53
|
+
export async function snapshotArtifactFile(root, path, objects, role = 'primary', shared = false) {
|
|
51
54
|
const target = await artifactPath(root, path);
|
|
52
55
|
const source = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
53
56
|
const temporary = join(objects, `${randomUUID()}.tmp`);
|
|
@@ -56,9 +59,12 @@ export async function snapshotArtifactFile(root, path, objects, role = 'primary'
|
|
|
56
59
|
const before = await source.stat();
|
|
57
60
|
if (!before.isFile() || before.nlink !== 1 || before.size > WORKSPACE_ARTIFACT_MAX_BYTES)
|
|
58
61
|
throw new Error('artifact_file_unavailable');
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// Workspace receipts are written by whichever identity generates the artifact and read by the
|
|
63
|
+
// other; the controller's own object store stays private.
|
|
64
|
+
output = shared
|
|
65
|
+
? await openSharedFile(root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW)
|
|
66
|
+
// private
|
|
67
|
+
: await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
62
68
|
const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024);
|
|
63
69
|
let size = 0;
|
|
64
70
|
for (;;) {
|
|
@@ -176,16 +182,16 @@ export async function artifactResources(root, path) {
|
|
|
176
182
|
throw new Error('artifact_too_many_resources');
|
|
177
183
|
return [...resources].sort();
|
|
178
184
|
}
|
|
179
|
-
export async function snapshotArtifactBundle(root, path, objects, thumbnailPath) {
|
|
185
|
+
export async function snapshotArtifactBundle(root, path, objects, thumbnailPath, shared = false) {
|
|
180
186
|
if (!isWorkspaceArtifactOutputPath(path) || (thumbnailPath !== undefined && !isWorkspaceArtifactOutputPath(thumbnailPath)))
|
|
181
187
|
throw new Error('invalid_artifact_path');
|
|
182
188
|
const resources = await artifactResources(root, path);
|
|
183
|
-
const files = [await snapshotArtifactFile(root, path, objects)];
|
|
189
|
+
const files = [await snapshotArtifactFile(root, path, objects, 'primary', shared)];
|
|
184
190
|
for (const resource of resources)
|
|
185
191
|
if (resource !== path)
|
|
186
|
-
files.push(await snapshotArtifactFile(root, resource, objects, 'resource'));
|
|
192
|
+
files.push(await snapshotArtifactFile(root, resource, objects, 'resource', shared));
|
|
187
193
|
if (thumbnailPath && !files.some(file => file.path === thumbnailPath))
|
|
188
|
-
files.push(await snapshotArtifactFile(root, thumbnailPath, objects, 'thumbnail'));
|
|
194
|
+
files.push(await snapshotArtifactFile(root, thumbnailPath, objects, 'thumbnail', shared));
|
|
189
195
|
if (files.reduce((size, file) => size + file.size, 0) > WORKSPACE_ARTIFACT_MAX_BYTES)
|
|
190
196
|
throw new Error('artifact_bundle_too_large');
|
|
191
197
|
return files;
|
|
@@ -3,9 +3,9 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { isWorkspaceArtifactOutputPath, workspaceArtifactReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
-
import {
|
|
7
|
-
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
6
|
+
import { artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './artifact-files.js';
|
|
8
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
|
+
import { ensureSharedDirectory, openSharedFile } from './shared-write.js';
|
|
9
9
|
export const ARTIFACT_RECEIPTS_DIRECTORY = '.playcraft/artifact-receipts';
|
|
10
10
|
export const artifactReceiptSchema = workspaceArtifactReceiptSchema;
|
|
11
11
|
export async function generatedWorkspace(cwd) {
|
|
@@ -33,21 +33,6 @@ export async function generatedWorkspace(cwd) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
37
|
-
* The receipt tree is written by the development identity and read by the controller. A directory
|
|
38
|
-
* the other identity created cannot be re-shaped here; its owner repairs it on the next write and
|
|
39
|
-
* readers keep working from whatever already carries the shared bits.
|
|
40
|
-
*/
|
|
41
|
-
async function shareArtifactDirectory(root, directory) {
|
|
42
|
-
try {
|
|
43
|
-
await shareWorkspaceAssetDirectory(root, directory);
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
const code = error.code;
|
|
47
|
-
if (code !== 'EACCES' && code !== 'EPERM')
|
|
48
|
-
throw error;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
36
|
/** Preserve final outputs before another command overwrites them; intermediate project assets are not registered. */
|
|
52
37
|
export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd(), expectedDigest, origin) {
|
|
53
38
|
const context = await generatedWorkspace(cwd);
|
|
@@ -63,13 +48,11 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
63
48
|
}
|
|
64
49
|
if (!isWorkspaceArtifactOutputPath(path) || !artifactFormat(path))
|
|
65
50
|
return;
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
await shareArtifactDirectory(context.root, receipts);
|
|
70
|
-
await shareArtifactDirectory(context.root, objects);
|
|
51
|
+
// Generations are written by whichever identity runs the tool; the controller scans the same tree.
|
|
52
|
+
const receipts = await ensureSharedDirectory(context.root, join(context.root, ARTIFACT_RECEIPTS_DIRECTORY));
|
|
53
|
+
const objects = await ensureSharedDirectory(context.root, join(context.root, ARTIFACT_RECEIPTS_DIRECTORY, 'objects'));
|
|
71
54
|
const thumbnailPath = metadata.thumbnailPath ? relativeArtifactPath(context.root, resolve(cwd, metadata.thumbnailPath)) : undefined;
|
|
72
|
-
const files = await snapshotArtifactBundle(context.root, path, objects, thumbnailPath);
|
|
55
|
+
const files = await snapshotArtifactBundle(context.root, path, objects, thumbnailPath, true);
|
|
73
56
|
if (expectedDigest && files.find(file => file.role === 'primary')?.sha256 !== expectedDigest)
|
|
74
57
|
throw new Error('artifact_file_changed');
|
|
75
58
|
const receipt = artifactReceiptSchema.parse({
|
|
@@ -80,9 +63,8 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
80
63
|
...(metadata.duration !== undefined ? { duration: metadata.duration } : {}),
|
|
81
64
|
});
|
|
82
65
|
const temporary = join(receipts, `${receipt.id}.tmp`);
|
|
83
|
-
const handle = await
|
|
66
|
+
const handle = await openSharedFile(context.root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
84
67
|
try {
|
|
85
|
-
await handle.chmod(0o660);
|
|
86
68
|
await handle.writeFile(`${JSON.stringify(receipt)}\n`);
|
|
87
69
|
await handle.sync();
|
|
88
70
|
await handle.close();
|