@playcraft/cli 0.1.55-beta → 0.1.57-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/assets/client.js +6 -4
- package/dist/commands/build.js +38 -0
- package/dist/commands/tools-3d.js +15 -0
- package/dist/commands/tools-generation.js +3 -2
- package/dist/utils/agent-api-client.js +31 -8
- package/dist/utils/agent-generation-provenance.js +45 -0
- package/dist/utils/generation-workspace-scope.js +50 -0
- package/dist/utils/image-generation-retry.js +7 -0
- package/dist/utils/tool-operation-journal.js +34 -4
- package/dist/workspace-runtime/batch/prepare.js +9 -52
- package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
- package/dist/workspace-runtime/codex/launcher-client.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +23 -4
- package/dist/workspace-runtime/previews/finalize.js +64 -0
- package/dist/workspace-runtime/previews/publish.js +31 -8
- package/dist/workspace-runtime/serve.js +8 -30
- package/dist/workspace-runtime/server/http.js +5 -5
- package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
- package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
- package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
- package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
- package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
- package/dist/workspace-runtime/workspaces/git.js +13 -0
- package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
- package/dist/workspace-runtime/workspaces/parameters.js +61 -21
- package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
- package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +15 -8
- package/project-templates/empty/AGENTS.md +1 -1
- package/templates/preview-runtime/v1/README.md +12 -0
- package/templates/preview-runtime/v1/manifest.json +8 -8
- package/templates/preview-runtime/v1/runtime.d.mts +1 -1
- package/templates/preview-runtime/v1/runtime.d.ts +1 -1
- package/templates/preview-runtime/v1/runtime.mjs +591 -54
- package/templates/preview-runtime/v1/types/adapters.d.ts +6 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +2 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +56 -0
- package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
|
@@ -6,6 +6,7 @@ import { basename, dirname } from 'node:path';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
8
|
import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
|
+
import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
|
|
9
10
|
const credentialSchema = z.object({
|
|
10
11
|
token: z.string().min(1), expiresAt: z.string().datetime(),
|
|
11
12
|
configId: z.string().uuid(), model: z.string().min(1).max(128),
|
|
@@ -25,8 +26,10 @@ export class WorkspaceModelBroker {
|
|
|
25
26
|
pending = new Map();
|
|
26
27
|
requests = new Set();
|
|
27
28
|
sessionRequests = new Map();
|
|
29
|
+
generation;
|
|
28
30
|
constructor(state) {
|
|
29
31
|
this.state = state;
|
|
32
|
+
this.generation = new WorkspaceGenerationProvenanceBroker(state);
|
|
30
33
|
}
|
|
31
34
|
async start() {
|
|
32
35
|
if (this.server)
|
|
@@ -67,8 +70,10 @@ export class WorkspaceModelBroker {
|
|
|
67
70
|
}
|
|
68
71
|
// Resolve current model metadata and permission at every native turn boundary.
|
|
69
72
|
const credential = await this.credential(entry, true);
|
|
73
|
+
const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
|
|
70
74
|
if (!route || route.epoch !== this.state.runtimeEpoch) {
|
|
71
|
-
route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url')
|
|
75
|
+
route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
|
|
76
|
+
generationCapability: randomBytes(32).toString('base64url') };
|
|
72
77
|
this.routes.set(routeKey, route);
|
|
73
78
|
}
|
|
74
79
|
// Each session pins its prepared model; shared credential renewal cannot change an active turn.
|
|
@@ -77,12 +82,16 @@ export class WorkspaceModelBroker {
|
|
|
77
82
|
if (!this.valid(entry) || this.routes.get(routeKey) !== route)
|
|
78
83
|
throw new Error('model_access_denied');
|
|
79
84
|
route.selected = { entry, model: credential.model,
|
|
80
|
-
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
|
|
85
|
+
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
|
|
81
86
|
},
|
|
82
87
|
model: credential.model,
|
|
83
88
|
modelConfigId: credential.configId,
|
|
84
89
|
modelProvider: 'playcraft_workspace',
|
|
85
90
|
config: {
|
|
91
|
+
...(sessionId ? { 'shell_environment_policy.set': {
|
|
92
|
+
PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
|
|
93
|
+
PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
|
|
94
|
+
} } : {}),
|
|
86
95
|
'model_providers.playcraft_workspace': {
|
|
87
96
|
name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
|
|
88
97
|
wire_api: 'responses', requires_openai_auth: false,
|
|
@@ -142,18 +151,28 @@ export class WorkspaceModelBroker {
|
|
|
142
151
|
const cancel = () => abort.abort();
|
|
143
152
|
response.once('close', cancel);
|
|
144
153
|
try {
|
|
145
|
-
const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/responses$/.exec(request.url ?? '');
|
|
154
|
+
const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
|
|
146
155
|
const route = match ? this.routes.get(match[1]) : undefined;
|
|
147
156
|
const selected = route?.selected;
|
|
148
157
|
const entry = selected?.entry;
|
|
149
158
|
const presented = Buffer.from(request.headers.authorization ?? '');
|
|
150
|
-
const
|
|
159
|
+
const generationRequest = match?.[2] === 'generation-provenance';
|
|
160
|
+
const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability : route.capability}` : '');
|
|
151
161
|
if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
|
|
152
162
|
|| !timingSafeEqual(presented, expected) || !this.valid(entry)) {
|
|
153
163
|
response.writeHead(403);
|
|
154
164
|
response.end();
|
|
155
165
|
return;
|
|
156
166
|
}
|
|
167
|
+
if (generationRequest) {
|
|
168
|
+
if (!selected?.feedback) {
|
|
169
|
+
response.writeHead(403);
|
|
170
|
+
response.end();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
157
176
|
activeRoute = route;
|
|
158
177
|
const active = this.sessionRequests.get(route) ?? new Set();
|
|
159
178
|
active.add(abort);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH, readWorkspaceRuntimeProfile, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { artifactPath } from '../workspaces/artifact-files.js';
|
|
6
|
+
import { captureGeneratedArtifact } from '../workspaces/artifact-receipts.js';
|
|
7
|
+
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
8
|
+
import { saveWorkspacePlayableOutput } from './output.js';
|
|
9
|
+
import { findExternalLocalReferences } from './single-file.js';
|
|
10
|
+
export function validateWorkspacePlayable(bytes) {
|
|
11
|
+
const html = bytes.toString('utf8');
|
|
12
|
+
if (!bytes.length || bytes.length > PREVIEW_HTML_MAX_BYTES || !/<(?:!doctype\s+html|html|head|title|body|script|canvas)\b/i.test(html)
|
|
13
|
+
|| findExternalLocalReferences(html).length || readWorkspaceRuntimeProfile(html)) {
|
|
14
|
+
throw new Error('preview_not_single_file');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Reads one stable regular file, never a redirected path or a partially copied output. */
|
|
18
|
+
export async function readWorkspacePlayable(root) {
|
|
19
|
+
const file = await open(await artifactPath(root, WORKSPACE_PLAYABLE_OUTPUT_PATH), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
20
|
+
try {
|
|
21
|
+
const before = await file.stat();
|
|
22
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > PREVIEW_HTML_MAX_BYTES)
|
|
23
|
+
throw new Error('preview_not_single_file');
|
|
24
|
+
const bytes = await file.readFile();
|
|
25
|
+
const after = await file.stat();
|
|
26
|
+
if (before.size !== bytes.length || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
27
|
+
throw new Error('artifact_file_changed');
|
|
28
|
+
return { bytes, signature: JSON.stringify([before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]) };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await file.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function workspacePlayableSignature(root) {
|
|
35
|
+
try {
|
|
36
|
+
const file = await open(await artifactPath(root, WORKSPACE_PLAYABLE_OUTPUT_PATH), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
37
|
+
try {
|
|
38
|
+
const stat = await file.stat();
|
|
39
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
40
|
+
throw new Error('preview_not_single_file');
|
|
41
|
+
return JSON.stringify([stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await file.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT')
|
|
49
|
+
return undefined;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** All build entry points retain the same validated bytes and an immutable generation receipt. */
|
|
54
|
+
export async function finalizeWorkspacePlayable(root, built, tool, options = {}) {
|
|
55
|
+
validateWorkspacePlayable(built);
|
|
56
|
+
const bytes = injectPreviewDiagnostics(built);
|
|
57
|
+
validateWorkspacePlayable(bytes);
|
|
58
|
+
if ('expectedSignature' in options && await workspacePlayableSignature(root) !== options.expectedSignature)
|
|
59
|
+
throw new Error('preview_source_changed');
|
|
60
|
+
const current = await readWorkspacePlayable(root).catch(() => undefined);
|
|
61
|
+
if (!current?.bytes.equals(bytes))
|
|
62
|
+
await saveWorkspacePlayableOutput(root, bytes);
|
|
63
|
+
await captureGeneratedArtifact(WORKSPACE_PLAYABLE_OUTPUT_PATH, tool, {}, root, createHash('sha256').update(bytes).digest('hex'), options.source);
|
|
64
|
+
}
|
|
@@ -2,14 +2,18 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
|
-
import { lstat, open, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { lstat, mkdtemp, open, readdir, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
9
|
import { excludeFromGit, readDependencies, scheduleDependencyInstall, } from '../workspaces/dependencies.js';
|
|
9
10
|
import { runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
10
11
|
import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
|
|
11
12
|
import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
|
|
12
|
-
import {
|
|
13
|
+
import { finalizeWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
|
|
14
|
+
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
15
|
+
import { isolateWorkspaceForBuild } from '../workspaces/delivery-build.js';
|
|
16
|
+
import { openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
13
17
|
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
14
18
|
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
15
19
|
import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
|
|
@@ -21,7 +25,7 @@ const BUILDER_TEMP_FOLDER = '.playcraft-temp';
|
|
|
21
25
|
* The generic single-file build. `--mode full` keeps the builder from asking which stages to run.
|
|
22
26
|
* Group-writable output lets the control user, a member of the development group, remove it.
|
|
23
27
|
*/
|
|
24
|
-
const BUILD_COMMAND = 'umask 002 && playcraft build --mode full --platform playcraft --format html';
|
|
28
|
+
const BUILD_COMMAND = 'umask 002 && PLAYCRAFT_BUILD_ISOLATED=1 playcraft build --mode full --platform playcraft --format html';
|
|
25
29
|
class PreviewFailure extends Error {
|
|
26
30
|
code;
|
|
27
31
|
constructor(code) {
|
|
@@ -73,6 +77,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
73
77
|
const output = { log: '' };
|
|
74
78
|
const operationLog = new OperationLogStore(state.config.stateDir);
|
|
75
79
|
let logChain = Promise.resolve();
|
|
80
|
+
let remotePreview = false;
|
|
81
|
+
let buildCopy;
|
|
76
82
|
const appendLog = (text) => {
|
|
77
83
|
logChain = logChain.then(() => operationLog.append(operation.operationId, text)).catch(() => undefined);
|
|
78
84
|
};
|
|
@@ -80,12 +86,27 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
80
86
|
try {
|
|
81
87
|
await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
|
|
82
88
|
const runtimePreview = await prepareRuntimePreview(state, operation, root);
|
|
89
|
+
remotePreview = Boolean(runtimePreview);
|
|
83
90
|
if (runtimePreview)
|
|
84
91
|
await excludeFromGit(root, '/.playcraft-editor-preview/');
|
|
85
92
|
await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
|
|
86
93
|
await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
|
|
87
94
|
await excludeFromGit(root, `/${WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY}/`);
|
|
88
|
-
|
|
95
|
+
if (!runtimePreview) {
|
|
96
|
+
const signature = await workspacePlayableSignature(root);
|
|
97
|
+
buildCopy = await mkdtemp(join(tmpdir(), 'playcraft-preview-build-'));
|
|
98
|
+
// The build runs as the development identity while this copy belongs to the controller.
|
|
99
|
+
await openScratchToDevelopment(buildCopy, (await lstat(root)).gid);
|
|
100
|
+
const project = join(buildCopy, 'project'), staged = join(buildCopy, 'output');
|
|
101
|
+
await isolateWorkspaceForBuild(root, project);
|
|
102
|
+
await buildPreview(state, project, staged, operation.operationId, output, appendLog);
|
|
103
|
+
await finalizeWorkspacePlayable(root, await readSingleHtml(staged), 'preview.publish', {
|
|
104
|
+
expectedSignature: signature, source: { operationId: operation.operationId, ...(operation.sessionId ? { sessionId: operation.sessionId } : {}) },
|
|
105
|
+
});
|
|
106
|
+
await refreshWorkspaceArtifacts(state, workspaceId, undefined, operation.directoryName ?? 'basic');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await buildPreview(state, root, outputDir, operation.operationId, output, appendLog, runtimePreview.command);
|
|
89
110
|
const runtimeBuild = runtimePreview ? await runtimePreview.finish() : undefined;
|
|
90
111
|
const built = runtimeBuild?.bytes ?? await readSingleHtml(outputDir);
|
|
91
112
|
const bytes = injectPreviewDiagnostics(built);
|
|
@@ -94,8 +115,6 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
94
115
|
if (findExternalLocalReferences(bytes.toString('utf8')).length > 0) {
|
|
95
116
|
throw new PreviewFailure('preview_not_single_file');
|
|
96
117
|
}
|
|
97
|
-
if (!runtimePreview)
|
|
98
|
-
await saveWorkspacePlayableOutput(root, bytes);
|
|
99
118
|
const contentDigest = createHash('sha256').update(bytes).digest('hex');
|
|
100
119
|
const contentMd5 = createHash('md5').update(bytes).digest('base64');
|
|
101
120
|
const source = await sourceRevision(root);
|
|
@@ -149,9 +168,11 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
149
168
|
return published;
|
|
150
169
|
}
|
|
151
170
|
catch (error) {
|
|
152
|
-
const code = error instanceof
|
|
171
|
+
const code = error instanceof RuntimePreviewBuildError ? error.code : previewFailureCode(error);
|
|
172
|
+
if (code === 'dependencies_not_ready' && output.log)
|
|
173
|
+
appendLog(`${output.log}\n`);
|
|
153
174
|
appendLog(`\n[${code}] ${error instanceof Error ? error.message : code}\n`);
|
|
154
|
-
if (code !== 'publish_conflict') {
|
|
175
|
+
if (remotePreview && code !== 'publish_conflict') {
|
|
155
176
|
await postControl(state, '/runtime/internal/preview-failures', {
|
|
156
177
|
workspaceId,
|
|
157
178
|
directoryName: operation.directoryName ?? 'basic',
|
|
@@ -166,6 +187,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
166
187
|
finally {
|
|
167
188
|
await logChain;
|
|
168
189
|
await rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
|
|
190
|
+
if (buildCopy)
|
|
191
|
+
await rm(buildCopy, { recursive: true, force: true }).catch(() => undefined);
|
|
169
192
|
}
|
|
170
193
|
}
|
|
171
194
|
export function previewFailureCode(error) {
|
|
@@ -2,7 +2,6 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, readdir } from 'node:fs/promises';
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
|
-
import { createPlatformAdapter } from '@playcraft/build';
|
|
6
5
|
import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
6
|
import { RuntimeJournal } from './persistence/journal.js';
|
|
8
7
|
import { createCodexAppServerAdapter } from './codex/app-server.js';
|
|
@@ -32,7 +31,7 @@ import { revisionScope } from './workspaces/registry.js';
|
|
|
32
31
|
import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
33
32
|
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
34
33
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
35
|
-
import {
|
|
34
|
+
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
35
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
37
36
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
38
37
|
const negotiated = negotiateProtocol(config);
|
|
@@ -355,6 +354,7 @@ function closeServer(server) {
|
|
|
355
354
|
function formatHost(host) {
|
|
356
355
|
return host.includes(':') ? `[${host}]` : host;
|
|
357
356
|
}
|
|
357
|
+
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
358
358
|
function deliveryToolHandlers(state) {
|
|
359
359
|
const contextFor = async (input) => {
|
|
360
360
|
if (input.workspaceId)
|
|
@@ -368,37 +368,15 @@ function deliveryToolHandlers(state) {
|
|
|
368
368
|
const { workspaceId, directoryName } = await contextFor(input);
|
|
369
369
|
if (directoryName === 'root')
|
|
370
370
|
throw new Error('invalid_workspace_directory');
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const operation = await submitTrustedOperation(state, {
|
|
374
|
-
kind: 'delivery.build',
|
|
375
|
-
workspaceId,
|
|
376
|
-
directoryName,
|
|
377
|
-
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
378
|
-
clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
|
|
379
|
-
payload: {
|
|
380
|
-
channel,
|
|
381
|
-
format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
|
|
382
|
-
actor: 'agent',
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
let record;
|
|
386
|
-
for (let attempt = 0; attempt < 40 && !record; attempt++) {
|
|
387
|
-
record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
|
|
388
|
-
if (!record)
|
|
389
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
390
|
-
}
|
|
391
|
-
builds.push({
|
|
392
|
-
buildId: operation.operationId,
|
|
393
|
-
channel,
|
|
394
|
-
status: record?.status ?? operation.status,
|
|
395
|
-
...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
return { builds };
|
|
371
|
+
// Exports go through the platform; a standalone workspace becomes a project first.
|
|
372
|
+
return startPlatformExport(state, { ...input, workspaceId, directoryName });
|
|
399
373
|
},
|
|
400
374
|
async get(input) {
|
|
401
375
|
const { workspaceId, directoryName } = await contextFor(input);
|
|
376
|
+
const sandboxBuild = input.buildId !== undefined && UUID.test(input.buildId);
|
|
377
|
+
if (!sandboxBuild && await platformExportTarget(state, { workspaceId, directoryName })) {
|
|
378
|
+
return readPlatformExport(state, { ...input, workspaceId, directoryName });
|
|
379
|
+
}
|
|
402
380
|
const store = new DeliveryStore(state.config.stateDir, workspaceId);
|
|
403
381
|
const record = input.buildId
|
|
404
382
|
? await store.read(input.buildId)
|
|
@@ -479,10 +479,10 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
479
479
|
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
480
480
|
return;
|
|
481
481
|
}
|
|
482
|
-
await workspaceRoot(state, workspaceId);
|
|
482
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
483
483
|
writeJson(response, 200, url.searchParams.get('refresh') === 'false'
|
|
484
|
-
? await new WorkspaceArtifactStore(state.config.stateDir, workspaceId).list()
|
|
485
|
-
: await refreshWorkspaceArtifacts(state, workspaceId));
|
|
484
|
+
? await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName).list()
|
|
485
|
+
: await refreshWorkspaceArtifacts(state, workspaceId, undefined, directoryName));
|
|
486
486
|
return;
|
|
487
487
|
}
|
|
488
488
|
if (request.method === 'GET' && url.pathname === '/runtime/artifact-content') {
|
|
@@ -493,7 +493,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
493
493
|
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
494
494
|
return;
|
|
495
495
|
}
|
|
496
|
-
await workspaceRoot(state, workspaceId);
|
|
496
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
497
497
|
const parsed = workspaceArtifactContentQuerySchema.safeParse({
|
|
498
498
|
artifactId: url.searchParams.get('artifactId'), versionId: url.searchParams.get('versionId'),
|
|
499
499
|
...(url.searchParams.has('path') ? { path: url.searchParams.get('path') } : {}),
|
|
@@ -505,7 +505,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
505
505
|
return;
|
|
506
506
|
}
|
|
507
507
|
try {
|
|
508
|
-
writeJson(response, 200, await new WorkspaceArtifactStore(state.config.stateDir, workspaceId).content(parsed.data));
|
|
508
|
+
writeJson(response, 200, await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName).content(parsed.data));
|
|
509
509
|
}
|
|
510
510
|
catch (error) {
|
|
511
511
|
const reason = error instanceof Error ? error.message : 'artifact_unavailable';
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
2
|
import { workspaceRoot } from './registry.js';
|
|
3
3
|
import { WorkspaceArtifactStore } from './artifacts.js';
|
|
4
|
-
|
|
4
|
+
import { finalizeWorkspacePlayable, readWorkspacePlayable, validateWorkspacePlayable, workspacePlayableSignature } from '../previews/finalize.js';
|
|
5
|
+
import { watch } from 'chokidar';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
import { readdir } from 'node:fs/promises';
|
|
8
|
+
import { generatedWorkspace } from './artifact-receipts.js';
|
|
9
|
+
export async function refreshWorkspaceArtifacts(state, workspaceId, origin, directoryName = 'basic') {
|
|
5
10
|
if (state.journal.destroyed())
|
|
6
11
|
throw new Error('runtime_destroyed');
|
|
7
|
-
const root = await workspaceRoot(state, workspaceId);
|
|
8
|
-
const
|
|
12
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
13
|
+
const active = state.journal.listSessions().some(session => session.workspaceId === workspaceId
|
|
14
|
+
&& (session.directoryName ?? 'basic') === directoryName && ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? ''));
|
|
15
|
+
const result = await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName)
|
|
16
|
+
.discover(root, origin?.source, origin?.paths, origin?.playable ?? (active ? { mode: 'hold' } : {}));
|
|
9
17
|
if (result.changed && !state.journal.destroyed()) {
|
|
10
18
|
const event = await state.journal.allocateSourceEvent({
|
|
11
19
|
runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
12
20
|
workspaceId, type: 'artifact.updated', occurredAt: result.list.scannedAt,
|
|
13
|
-
payload: { workspaceId, artifacts: result.list.artifacts.map(artifact => ({ id: artifact.id, currentVersionId: artifact.currentVersionId })) },
|
|
21
|
+
payload: { workspaceId, directoryName, artifacts: result.list.artifacts.map(artifact => ({ id: artifact.id, currentVersionId: artifact.currentVersionId })) },
|
|
14
22
|
});
|
|
15
23
|
// The durable source stream remains authoritative when the callback is temporarily unavailable.
|
|
16
24
|
void fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
@@ -23,33 +31,138 @@ export async function refreshWorkspaceArtifacts(state, workspaceId, origin) {
|
|
|
23
31
|
}
|
|
24
32
|
/** Completed commands and turns reconcile outputs, including work done before a failed turn. */
|
|
25
33
|
export function observeWorkspaceArtifacts(state) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const task = refreshWorkspaceArtifacts(state, workspaceId).catch(() => undefined);
|
|
30
|
-
running.add(task);
|
|
31
|
-
void task.finally(() => running.delete(task));
|
|
32
|
-
};
|
|
34
|
+
const running = new Map();
|
|
35
|
+
const starts = new Map();
|
|
36
|
+
const deferred = new Map();
|
|
33
37
|
const stop = state.journal.onSourceEvent((event) => {
|
|
34
|
-
if (!['file.written', 'files.changed', 'filesystem.changed', 'item/completed', 'turn.completed', 'turn/completed', 'turn.failed', 'turn.interrupted'].includes(event.type))
|
|
38
|
+
if (!['file.written', 'files.changed', 'filesystem.changed', 'item/started', 'item/completed', 'turn/started', 'turn.started', 'turn.completed', 'turn/completed', 'turn.failed', 'turn.interrupted'].includes(event.type))
|
|
35
39
|
return;
|
|
36
40
|
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
37
41
|
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
38
|
-
const
|
|
42
|
+
const session = sessionId ? state.journal.getSession(sessionId) : undefined;
|
|
43
|
+
const workspaceId = typeof payload.workspaceId === 'string' ? payload.workspaceId : session?.workspaceId;
|
|
39
44
|
if (!workspaceId)
|
|
40
45
|
return;
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
const directoryName = typeof payload.directoryName === 'string' ? payload.directoryName : session?.directoryName ?? 'basic';
|
|
47
|
+
if (directoryName === 'root')
|
|
48
|
+
return;
|
|
49
|
+
const scope = `${workspaceId}:${directoryName}`;
|
|
50
|
+
const item = payload.item && typeof payload.item === 'object' ? payload.item : undefined;
|
|
51
|
+
const turn = payload.turn && typeof payload.turn === 'object' ? payload.turn : undefined;
|
|
52
|
+
const turnId = String(payload.turnId ?? turn?.id ?? session?.activeTurnId ?? 'turn');
|
|
53
|
+
const turnKey = `${encodeURIComponent(String(payload.threadId ?? session?.threadId ?? sessionId))}/${encodeURIComponent(turnId)}`;
|
|
54
|
+
const command = item?.type === 'commandExecution';
|
|
55
|
+
if (event.type.startsWith('item/') && !command)
|
|
56
|
+
return;
|
|
57
|
+
const executionId = command ? `${turnKey}/${encodeURIComponent(String(item?.id))}` : turnKey;
|
|
58
|
+
const started = event.type.endsWith('started');
|
|
59
|
+
const terminal = command || event.type.startsWith('turn');
|
|
60
|
+
const success = command ? item?.status === 'completed' && item.exitCode === 0
|
|
61
|
+
: !['turn.failed', 'turn.interrupted'].includes(event.type) && (turn?.status ?? payload.status ?? 'completed') === 'completed';
|
|
62
|
+
const task = (running.get(scope) ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
63
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
64
|
+
const store = new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName);
|
|
65
|
+
const source = { kind: 'generated', ...(sessionId ? { sessionId } : {}),
|
|
66
|
+
...(typeof payload.operationId === 'string' ? { operationId: payload.operationId } : {}), tool: command ? 'agent command' : 'agent turn' };
|
|
67
|
+
if (started) {
|
|
68
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source, paths: [], playable: { mode: 'hold', executionId } }, directoryName);
|
|
69
|
+
starts.set(executionId, await workspacePlayableSignature(root).catch(() => undefined));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let force = false;
|
|
73
|
+
let promote = terminal && success;
|
|
74
|
+
const candidate = terminal ? await readWorkspacePlayable(root).catch(() => undefined) : undefined;
|
|
75
|
+
if (terminal && success) {
|
|
76
|
+
force = command && starts.has(executionId) && starts.get(executionId) !== candidate?.signature;
|
|
77
|
+
const occupied = await store.hasPendingPlayableExecution(executionId);
|
|
78
|
+
promote = (!command || force) && !occupied;
|
|
79
|
+
if (command && force && occupied && candidate) {
|
|
80
|
+
try {
|
|
81
|
+
validateWorkspacePlayable(candidate.bytes);
|
|
82
|
+
deferred.set(scope, { bytes: candidate.bytes, source });
|
|
83
|
+
}
|
|
84
|
+
catch { /* An incomplete output never becomes a deferred successful candidate. */ }
|
|
85
|
+
}
|
|
86
|
+
if (promote && candidate && (force || !await store.rejectedPlayable(candidate.bytes))) {
|
|
87
|
+
await finalizeWorkspacePlayable(root, candidate.bytes, source.tool, { expectedSignature: candidate.signature, source }).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const unrelatedFailure = terminal && !success && candidate && (starts.has(executionId) && starts.get(executionId) === candidate.signature
|
|
91
|
+
|| await store.isCurrentPlayable(candidate.bytes));
|
|
92
|
+
starts.delete(executionId);
|
|
93
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source, paths: [], playable: {
|
|
94
|
+
mode: terminal ? success ? promote ? 'success' : 'skip' : unrelatedFailure ? 'skip' : 'failure' : 'hold', ...(terminal ? { executionId } : {}), force,
|
|
95
|
+
} }, directoryName);
|
|
96
|
+
const waiting = deferred.get(scope);
|
|
97
|
+
if (waiting && !await store.hasPendingPlayableExecution(executionId)) {
|
|
98
|
+
deferred.delete(scope);
|
|
99
|
+
const current = await readWorkspacePlayable(root).catch(() => undefined);
|
|
100
|
+
if (current?.bytes.equals(waiting.bytes) && !await store.rejectedPlayable(current.bytes)) {
|
|
101
|
+
await finalizeWorkspacePlayable(root, waiting.bytes, waiting.source.tool, { expectedSignature: current.signature, source: waiting.source });
|
|
102
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source: waiting.source, paths: [], playable: { mode: 'success', force: true } }, directoryName);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}).catch(() => undefined);
|
|
106
|
+
running.set(scope, task);
|
|
45
107
|
});
|
|
108
|
+
const receiptScope = (path) => {
|
|
109
|
+
const parts = relative(state.config.workspaceRoot, path).split(sep).filter(Boolean);
|
|
110
|
+
const offset = state.config.workspaceMode === 'multi' ? 2 : 0;
|
|
111
|
+
const directoryName = offset ? parts[1] : 'basic';
|
|
112
|
+
if (offset && (parts[0]?.startsWith('.') || parts.length > 1 && !/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName)))
|
|
113
|
+
return undefined;
|
|
114
|
+
const tail = parts.slice(offset);
|
|
115
|
+
if (tail.some((part, index) => index === 0 ? part !== '.playcraft' : index === 1 ? part !== 'artifact-receipts' : index === 2 ? !/^[a-f0-9-]{36}\.json$/.test(part) : true))
|
|
116
|
+
return undefined;
|
|
117
|
+
return { parts, offset, directoryName: directoryName ?? 'basic' };
|
|
118
|
+
};
|
|
119
|
+
// Only completed generation receipts are observed; project files and build output are excluded.
|
|
120
|
+
const queueReceipt = (path) => {
|
|
121
|
+
const found = receiptScope(path);
|
|
122
|
+
if (!found || found.parts.length !== found.offset + 3)
|
|
123
|
+
return;
|
|
124
|
+
const scope = `${found.offset ? found.parts[0] : 'single'}:${found.directoryName}`;
|
|
125
|
+
const task = (running.get(scope) ?? Promise.resolve()).catch(() => undefined)
|
|
126
|
+
.then(async () => {
|
|
127
|
+
const workspaceId = found.offset ? found.parts[0] : (await generatedWorkspace(state.config.workspaceRoot))?.workspaceId;
|
|
128
|
+
if (workspaceId)
|
|
129
|
+
await refreshWorkspaceArtifacts(state, workspaceId, undefined, found.directoryName);
|
|
130
|
+
}).catch(() => undefined);
|
|
131
|
+
running.set(scope, task);
|
|
132
|
+
};
|
|
133
|
+
const receipts = watch(state.config.workspaceRoot, {
|
|
134
|
+
persistent: false, followSymlinks: false, depth: state.config.workspaceMode === 'multi' ? 5 : 3,
|
|
135
|
+
ignored: path => !receiptScope(path),
|
|
136
|
+
}).on('add', queueReceipt).on('ready', () => {
|
|
137
|
+
// Reconcile receipts created while the initial directory watches were being attached.
|
|
138
|
+
const task = (async () => {
|
|
139
|
+
let roots = [state.config.workspaceRoot];
|
|
140
|
+
if (state.config.workspaceMode === 'multi') {
|
|
141
|
+
roots = [];
|
|
142
|
+
for (const entry of await readdir(state.config.workspaceRoot, { withFileTypes: true })) {
|
|
143
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
144
|
+
continue;
|
|
145
|
+
const container = join(state.config.workspaceRoot, entry.name);
|
|
146
|
+
for (const directory of await readdir(container, { withFileTypes: true })) {
|
|
147
|
+
if (directory.isDirectory() && /^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directory.name))
|
|
148
|
+
roots.push(join(container, directory.name));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const root of roots) {
|
|
153
|
+
const directory = join(root, '.playcraft/artifact-receipts');
|
|
154
|
+
const names = await readdir(directory).catch(() => []);
|
|
155
|
+
for (const name of names)
|
|
156
|
+
if (/^[a-f0-9-]{36}\.json$/.test(name))
|
|
157
|
+
queueReceipt(join(directory, name));
|
|
158
|
+
}
|
|
159
|
+
})().catch(() => undefined);
|
|
160
|
+
running.set('receipt-startup', task);
|
|
161
|
+
}).on('error', () => undefined);
|
|
46
162
|
return async () => {
|
|
47
163
|
stop();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
pending.clear();
|
|
53
|
-
await Promise.all([...running]);
|
|
164
|
+
await receipts.close();
|
|
165
|
+
await running.get('receipt-startup');
|
|
166
|
+
await Promise.all([...running.values()]);
|
|
54
167
|
};
|
|
55
168
|
}
|
|
@@ -56,7 +56,9 @@ export async function snapshotArtifactFile(root, path, objects, role = 'primary'
|
|
|
56
56
|
const before = await source.stat();
|
|
57
57
|
if (!before.isFile() || before.nlink !== 1 || before.size > WORKSPACE_ARTIFACT_MAX_BYTES)
|
|
58
58
|
throw new Error('artifact_file_unavailable');
|
|
59
|
-
|
|
59
|
+
// Objects are written by whichever identity generates the artifact and read by the other.
|
|
60
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o660);
|
|
61
|
+
await output.chmod(0o660);
|
|
60
62
|
const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024);
|
|
61
63
|
let size = 0;
|
|
62
64
|
for (;;) {
|
|
@@ -4,10 +4,11 @@ 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
6
|
import { artifactDirectory, artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './artifact-files.js';
|
|
7
|
+
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
7
8
|
import { excludeFromGit } from './dependencies.js';
|
|
8
9
|
export const ARTIFACT_RECEIPTS_DIRECTORY = '.playcraft/artifact-receipts';
|
|
9
10
|
export const artifactReceiptSchema = workspaceArtifactReceiptSchema;
|
|
10
|
-
async function generatedWorkspace(cwd) {
|
|
11
|
+
export async function generatedWorkspace(cwd) {
|
|
11
12
|
for (let root = resolve(cwd);; root = dirname(root)) {
|
|
12
13
|
const file = join(root, '.playcraft/workspace-context.json');
|
|
13
14
|
try {
|
|
@@ -32,8 +33,23 @@ async function generatedWorkspace(cwd) {
|
|
|
32
33
|
return undefined;
|
|
33
34
|
}
|
|
34
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
|
+
}
|
|
35
51
|
/** Preserve final outputs before another command overwrites them; intermediate project assets are not registered. */
|
|
36
|
-
export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd()) {
|
|
52
|
+
export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd(), expectedDigest, origin) {
|
|
37
53
|
const context = await generatedWorkspace(cwd);
|
|
38
54
|
if (!context)
|
|
39
55
|
return;
|
|
@@ -49,17 +65,24 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
49
65
|
return;
|
|
50
66
|
const receipts = await artifactDirectory(context.root, ARTIFACT_RECEIPTS_DIRECTORY);
|
|
51
67
|
const objects = await artifactDirectory(context.root, `${ARTIFACT_RECEIPTS_DIRECTORY}/objects`);
|
|
68
|
+
// Generations are written as the development identity while the controller scans this tree.
|
|
69
|
+
await shareArtifactDirectory(context.root, receipts);
|
|
70
|
+
await shareArtifactDirectory(context.root, objects);
|
|
52
71
|
const thumbnailPath = metadata.thumbnailPath ? relativeArtifactPath(context.root, resolve(cwd, metadata.thumbnailPath)) : undefined;
|
|
53
72
|
const files = await snapshotArtifactBundle(context.root, path, objects, thumbnailPath);
|
|
73
|
+
if (expectedDigest && files.find(file => file.role === 'primary')?.sha256 !== expectedDigest)
|
|
74
|
+
throw new Error('artifact_file_changed');
|
|
54
75
|
const receipt = artifactReceiptSchema.parse({
|
|
55
76
|
id: randomUUID(), workspaceId: context.workspaceId, path,
|
|
56
|
-
source: { kind: 'generated', tool, generatedAt: new Date().toISOString()
|
|
77
|
+
source: { kind: 'generated', tool, generatedAt: new Date().toISOString(),
|
|
78
|
+
...(origin?.sessionId ? { sessionId: origin.sessionId } : {}), ...(origin?.operationId ? { operationId: origin.operationId } : {}) }, files,
|
|
57
79
|
...(metadata.width ? { width: metadata.width } : {}), ...(metadata.height ? { height: metadata.height } : {}),
|
|
58
80
|
...(metadata.duration !== undefined ? { duration: metadata.duration } : {}),
|
|
59
81
|
});
|
|
60
82
|
const temporary = join(receipts, `${receipt.id}.tmp`);
|
|
61
|
-
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
83
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o660);
|
|
62
84
|
try {
|
|
85
|
+
await handle.chmod(0o660);
|
|
63
86
|
await handle.writeFile(`${JSON.stringify(receipt)}\n`);
|
|
64
87
|
await handle.sync();
|
|
65
88
|
await handle.close();
|