@playcraft/cli 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/environment-endpoints.js +0 -9
- package/dist/workspace-runtime/codex/app-server.js +69 -65
- package/dist/workspace-runtime/codex/config-toml.js +83 -0
- package/dist/workspace-runtime/codex/model-broker.js +79 -24
- package/dist/workspace-runtime/previews/publish.js +2 -5
- package/dist/workspace-runtime/serve.js +10 -6
- package/dist/workspace-runtime/server/connectivity.js +201 -0
- package/dist/workspace-runtime/server/dispatch.js +83 -78
- package/dist/workspace-runtime/server/endpoints.js +3 -3
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/workspaces/cli-credentials.js +2 -6
- package/dist/workspace-runtime/workspaces/file-snapshot.js +20 -161
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +2 -4
- package/dist/workspace-runtime/workspaces/git.js +67 -81
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -4
- package/package.json +3 -3
- package/dist/workspace-runtime/codex/transcripts.js +0 -94
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +0 -79
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
7
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
@@ -17,6 +17,7 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
|
17
17
|
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { terminalHost } from './terminal-host.js';
|
|
20
|
+
import { probeRuntimeConnectivity } from './connectivity.js';
|
|
20
21
|
export async function handleRuntimeRequest(state, request, response) {
|
|
21
22
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
22
23
|
const access = authorizeRuntime(state, request);
|
|
@@ -33,6 +34,19 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
33
34
|
writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
37
|
+
if (request.method === 'GET' && url.pathname === '/runtime/connectivity') {
|
|
38
|
+
if (writeDestroyed(state, response))
|
|
39
|
+
return;
|
|
40
|
+
const parsed = runtimeConnectivityDocumentSchema.shape.workspaceId.safeParse(url.searchParams.get('workspaceId'));
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const workspaceId = parsed.data;
|
|
46
|
+
await workspaceRoot(state, workspaceId);
|
|
47
|
+
writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
36
50
|
if (request.method === 'GET' && url.pathname === '/runtime/identity') {
|
|
37
51
|
const { runtimeId, userId, environment, generation } = state.config;
|
|
38
52
|
writeJson(response, 200, {
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
5
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
const responseSchema = z.object({
|
|
7
7
|
token: z.string().min(1),
|
|
8
8
|
apiUrl: z.string().url(),
|
|
@@ -37,11 +37,7 @@ export async function ensureCliCredentials(state) {
|
|
|
37
37
|
async function renew(state, entry) {
|
|
38
38
|
const config = state.config;
|
|
39
39
|
const epoch = state.runtimeEpoch;
|
|
40
|
-
const endpoint =
|
|
41
|
-
if (endpoint.protocol !== 'https:' &&
|
|
42
|
-
!(endpoint.protocol === 'http:' &&
|
|
43
|
-
['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
44
|
-
throw new Error('cli_callback_insecure');
|
|
40
|
+
const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.cliCredentials);
|
|
45
41
|
try {
|
|
46
42
|
const response = await fetch(endpoint, {
|
|
47
43
|
method: 'POST',
|
|
@@ -1,77 +1,7 @@
|
|
|
1
1
|
import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { fileSnapshotDigest, fileSnapshotManifestKey,
|
|
4
|
-
import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
|
|
3
|
+
import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
4
|
const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
6
|
-
/** Build output, caches and scratch folders, at any depth: regenerated, so not kept in storage. */
|
|
7
|
-
const UNSTORED_DIRECTORY_NAMES = new Set([
|
|
8
|
-
'dist',
|
|
9
|
-
'dist-all',
|
|
10
|
-
'coverage',
|
|
11
|
-
'.vite',
|
|
12
|
-
'.cache',
|
|
13
|
-
'.turbo',
|
|
14
|
-
'.parcel-cache',
|
|
15
|
-
'.sass-cache',
|
|
16
|
-
'.next',
|
|
17
|
-
'.nuxt',
|
|
18
|
-
'.svelte-kit',
|
|
19
|
-
'tmp',
|
|
20
|
-
'.tmp',
|
|
21
|
-
'.temp',
|
|
22
|
-
]);
|
|
23
|
-
/** Scratch and cache files, at any depth. */
|
|
24
|
-
const UNSTORED_FILE = /(?:\.tmp|\.log|\.tsbuildinfo|^\.eslintcache)$/;
|
|
25
|
-
/**
|
|
26
|
-
* At the workspace root: the preview build's output, the builder's scratch folder, and skill
|
|
27
|
-
* artifacts the sandbox pulls from the registry by `.playcraft/skills.lock.json`.
|
|
28
|
-
*/
|
|
29
|
-
const UNSTORED_ROOT_DIRECTORIES = [
|
|
30
|
-
'.playcraft-preview-out/',
|
|
31
|
-
'.playcraft-temp/',
|
|
32
|
-
'.playcraft/skills/',
|
|
33
|
-
'ta-workspace/logs/',
|
|
34
|
-
];
|
|
35
|
-
/**
|
|
36
|
-
* Recordings, frames and reports the template's verify and record scripts write again on each run.
|
|
37
|
-
* The ring JSON evidence the build gate reads stays, as do the agent-edited profile and catalogs.
|
|
38
|
-
*/
|
|
39
|
-
const UNSTORED_RUN_OUTPUT = [
|
|
40
|
-
/^recordings\/(?:.+\.(?:mp4|webm)$|\.tmp-[^/]+\/|focus\/|clean\/)/,
|
|
41
|
-
/^validate\/(?:[^/]+\.md$|(?:ring\d+\/)?frames\/|ring\d+\/[^/]+\.mp4$)/,
|
|
42
|
-
];
|
|
43
|
-
/**
|
|
44
|
-
* Whether a path goes to checkpoint storage. Credentials, lock files, build output, caches and
|
|
45
|
-
* scratch files stay in the instance: its local copy keeps them, so restoring inside the instance
|
|
46
|
-
* does not remove them.
|
|
47
|
-
*/
|
|
48
|
-
export function keptInStorage(path) {
|
|
49
|
-
if (isWorkspaceAttachmentPath(path))
|
|
50
|
-
return true;
|
|
51
|
-
if (isDisposableBackupPath(path))
|
|
52
|
-
return false;
|
|
53
|
-
if (path.startsWith('.git/'))
|
|
54
|
-
return true;
|
|
55
|
-
if (UNSTORED_ROOT_DIRECTORIES.some((prefix) => path.startsWith(prefix)))
|
|
56
|
-
return false;
|
|
57
|
-
if (UNSTORED_RUN_OUTPUT.some((pattern) => pattern.test(path)))
|
|
58
|
-
return false;
|
|
59
|
-
const segments = path.split('/');
|
|
60
|
-
if (UNSTORED_FILE.test(segments[segments.length - 1]))
|
|
61
|
-
return false;
|
|
62
|
-
return !segments.slice(0, -1).some((segment) => UNSTORED_DIRECTORY_NAMES.has(segment));
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Under `.git/`, what a `local` capture leaves to the remote: packs and their indexes, submodule
|
|
66
|
-
* repositories (`git submodule update` fetches them again), LFS objects, and template hooks.
|
|
67
|
-
*/
|
|
68
|
-
const REMOTE_GIT_DIRECTORIES = new Set([
|
|
69
|
-
'.git/objects/pack',
|
|
70
|
-
'.git/objects/info',
|
|
71
|
-
'.git/modules',
|
|
72
|
-
'.git/lfs',
|
|
73
|
-
'.git/hooks',
|
|
74
|
-
]);
|
|
75
5
|
const READ_ATTEMPTS = 3;
|
|
76
6
|
const realIo = {
|
|
77
7
|
async readdir(path) {
|
|
@@ -83,28 +13,13 @@ const realIo = {
|
|
|
83
13
|
readFile,
|
|
84
14
|
readlink,
|
|
85
15
|
};
|
|
86
|
-
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo
|
|
16
|
+
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
|
|
87
17
|
const entries = [];
|
|
88
18
|
const files = [];
|
|
89
19
|
const bodies = new Map();
|
|
90
20
|
const seen = new Set();
|
|
91
21
|
let status = 'complete';
|
|
92
|
-
|
|
93
|
-
if (name === '.' || name === '..')
|
|
94
|
-
return true;
|
|
95
|
-
if (directory && name === '.git')
|
|
96
|
-
return !(options.git && relativePath === '.git');
|
|
97
|
-
// A submodule's `.git` file points into `.git/modules`, which is not kept either.
|
|
98
|
-
if (!directory && name === '.git' && relativePath !== '.git')
|
|
99
|
-
return true;
|
|
100
|
-
if (directory && SKIP_DIRECTORY_NAMES.has(name))
|
|
101
|
-
return true;
|
|
102
|
-
if (directory && options.git === 'local' && REMOTE_GIT_DIRECTORIES.has(relativePath))
|
|
103
|
-
return true;
|
|
104
|
-
// Git's own lock files only exist while a command runs and must not come back with a restore.
|
|
105
|
-
return !directory && relativePath.startsWith('.git/') && name.endsWith('.lock');
|
|
106
|
-
};
|
|
107
|
-
await walk(root, root, io, revisions, entries, files, bodies, seen, skip, () => {
|
|
22
|
+
await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
|
|
108
23
|
status = 'incomplete';
|
|
109
24
|
});
|
|
110
25
|
for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
|
|
@@ -147,12 +62,6 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
147
62
|
if (hashSnapshotBytes(body) !== entry.contentHash)
|
|
148
63
|
throw new Error('snapshot_mismatch');
|
|
149
64
|
}
|
|
150
|
-
if (applicable.some((entry) => entry.path === '.git/HEAD')) {
|
|
151
|
-
const bundle = applicable.find((entry) => entry.path === UNPUSHED_BUNDLE && entry.kind === 'file');
|
|
152
|
-
if (bundle)
|
|
153
|
-
await addBundleObjects(rootReal, Buffer.from(bundle.contentBase64 ?? '', 'base64'));
|
|
154
|
-
await clearClonedGitMetadata(rootReal);
|
|
155
|
-
}
|
|
156
65
|
for (const entry of applicable) {
|
|
157
66
|
const target = resolve(rootReal, ...entry.path.split('/'));
|
|
158
67
|
const rel = relative(rootReal, target);
|
|
@@ -176,100 +85,50 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
176
85
|
if (entry.mode !== undefined)
|
|
177
86
|
await chmod(target, entry.mode);
|
|
178
87
|
}
|
|
88
|
+
let repositoryReady = false;
|
|
89
|
+
try {
|
|
90
|
+
const git = await lstat(join(rootReal, '.git'));
|
|
91
|
+
repositoryReady = git.isDirectory() && !git.isSymbolicLink();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
repositoryReady = false;
|
|
95
|
+
}
|
|
179
96
|
return {
|
|
180
97
|
skipped,
|
|
181
98
|
directoryReady: true,
|
|
182
|
-
repositoryReady
|
|
99
|
+
repositoryReady,
|
|
183
100
|
environmentReady: false,
|
|
184
101
|
};
|
|
185
102
|
}
|
|
186
|
-
/**
|
|
187
|
-
* The snapshot's `.git` replaces the one a new clone made, objects aside: a ref the clone wrote
|
|
188
|
-
* would otherwise outrank the snapshot's packed refs.
|
|
189
|
-
*/
|
|
190
|
-
async function clearClonedGitMetadata(rootReal) {
|
|
191
|
-
const gitDir = join(rootReal, '.git');
|
|
192
|
-
const info = await lstat(gitDir).catch(() => undefined);
|
|
193
|
-
if (!info?.isDirectory())
|
|
194
|
-
return;
|
|
195
|
-
for (const name of await readdir(gitDir)) {
|
|
196
|
-
if (name !== 'objects')
|
|
197
|
-
await rm(join(gitDir, name), { recursive: true, force: true });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Content goes to the workspace's shared objects, keyed by hash. A hash in `uploaded` was stored by
|
|
202
|
-
* this process before and is not sent again; hashes stored now are added to it. The manifest still
|
|
203
|
-
* lists every stored file, so each snapshot restores on its own. Paths not `keptInStorage` are left
|
|
204
|
-
* out of both the objects and the manifest.
|
|
205
|
-
*/
|
|
206
103
|
export async function publishFileSnapshot(input) {
|
|
207
|
-
const
|
|
208
|
-
const entries = input.tree.entries.filter((entry) => keptInStorage(entry.path));
|
|
209
|
-
for (const entry of entries) {
|
|
104
|
+
for (const entry of input.tree.entries) {
|
|
210
105
|
if (entry.kind !== 'file' || !entry.contentHash)
|
|
211
106
|
continue;
|
|
212
|
-
if (input.uploaded?.has(entry.contentHash) || stored.has(entry.contentHash))
|
|
213
|
-
continue;
|
|
214
107
|
const body = input.tree.bodies.get(entry.path);
|
|
215
108
|
if (!body || hashSnapshotBytes(body) !== entry.contentHash)
|
|
216
109
|
throw new Error('snapshot_body_missing');
|
|
217
|
-
await putObjectBytes(input.baseUrl,
|
|
218
|
-
stored.add(entry.contentHash);
|
|
219
|
-
}
|
|
220
|
-
const native = [];
|
|
221
|
-
for (const transcript of input.native ?? []) {
|
|
222
|
-
const contentHash = hashSnapshotBytes(transcript.body);
|
|
223
|
-
if (input.uploadedTranscripts?.get(transcript.path) !== contentHash) {
|
|
224
|
-
await putObjectBytes(input.baseUrl, nativeTranscriptKey(input.workspaceId, transcript.path), transcript.body, 'cli', input.headers);
|
|
225
|
-
input.uploadedTranscripts?.set(transcript.path, contentHash);
|
|
226
|
-
}
|
|
227
|
-
native.push({
|
|
228
|
-
sessionId: transcript.sessionId,
|
|
229
|
-
threadId: transcript.threadId,
|
|
230
|
-
path: transcript.path,
|
|
231
|
-
contentHash,
|
|
232
|
-
size: transcript.body.byteLength,
|
|
233
|
-
capturedAt: transcript.capturedAt,
|
|
234
|
-
});
|
|
110
|
+
await putObjectBytes(input.baseUrl, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli', input.headers);
|
|
235
111
|
}
|
|
236
|
-
const digest = fileSnapshotDigest(entries);
|
|
112
|
+
const digest = fileSnapshotDigest(input.tree.entries);
|
|
237
113
|
const manifest = fileSnapshotManifestSchema.parse({
|
|
238
114
|
snapshotId: input.snapshotId,
|
|
239
115
|
workspaceId: input.workspaceId,
|
|
240
116
|
generation: input.generation,
|
|
241
117
|
status: input.tree.status,
|
|
242
|
-
entries,
|
|
118
|
+
entries: input.tree.entries,
|
|
243
119
|
digest,
|
|
244
|
-
objectLayout: 'workspace',
|
|
245
|
-
...(native.length > 0 ? { native } : {}),
|
|
246
120
|
});
|
|
247
121
|
const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
|
|
248
122
|
await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
|
|
249
|
-
for (const hash of stored)
|
|
250
|
-
input.uploaded?.add(hash);
|
|
251
123
|
return { manifestKey, checksum: digest, status: input.tree.status };
|
|
252
124
|
}
|
|
253
|
-
|
|
254
|
-
* Digest of what is on disk now, without the deletions recorded against the previous snapshot.
|
|
255
|
-
* Transcripts count too: a turn that only talked still changes what a restore can resume.
|
|
256
|
-
*/
|
|
257
|
-
export function liveTreeDigest(tree, native = []) {
|
|
258
|
-
const files = fileSnapshotDigest(tree.entries.filter((entry) => entry.kind !== 'deleted'));
|
|
259
|
-
if (native.length === 0)
|
|
260
|
-
return files;
|
|
261
|
-
const transcripts = native
|
|
262
|
-
.map((item) => `${item.sessionId}:${item.threadId}:${item.path}:${hashSnapshotBytes(item.body)}`)
|
|
263
|
-
.sort();
|
|
264
|
-
return hashSnapshotBytes(Buffer.from([files, ...transcripts].join('\n')));
|
|
265
|
-
}
|
|
266
|
-
async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, skip, markIncomplete) {
|
|
125
|
+
async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
|
|
267
126
|
const listed = await io.readdir(current);
|
|
268
127
|
for (const entry of listed) {
|
|
128
|
+
if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
|
|
129
|
+
continue;
|
|
269
130
|
const child = join(current, entry.name);
|
|
270
131
|
const relativePath = toPosixRelative(rootReal, child);
|
|
271
|
-
if (skip(relativePath, entry.name, entry.isDirectory()))
|
|
272
|
-
continue;
|
|
273
132
|
if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
|
|
274
133
|
markIncomplete();
|
|
275
134
|
continue;
|
|
@@ -286,7 +145,7 @@ async function walk(rootReal, current, io, revisions, entries, files, bodies, se
|
|
|
286
145
|
continue;
|
|
287
146
|
}
|
|
288
147
|
if (entry.isDirectory()) {
|
|
289
|
-
await walk(rootReal, child, io, revisions, entries, files, bodies, seen,
|
|
148
|
+
await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
|
|
290
149
|
continue;
|
|
291
150
|
}
|
|
292
151
|
if (!entry.isFile())
|
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
10
10
|
import { WorkspaceGitDirectStore } from './git-direct-store.js';
|
|
11
|
-
import {
|
|
11
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
12
12
|
const exec = promisify(execFile);
|
|
13
13
|
/** Only this control process sees the callback credential. Development processes use the local socket. */
|
|
14
14
|
export class WorkspaceGitCredentialBroker {
|
|
@@ -165,9 +165,7 @@ export class WorkspaceGitCredentialBroker {
|
|
|
165
165
|
await this.direct.sweep(true);
|
|
166
166
|
}
|
|
167
167
|
async refresh(binding, key) {
|
|
168
|
-
const endpoint =
|
|
169
|
-
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
170
|
-
throw new Error('git_broker_unavailable');
|
|
168
|
+
const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
|
|
171
169
|
const epoch = this.state.runtimeEpoch;
|
|
172
170
|
const response = await fetch(endpoint, {
|
|
173
171
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { excludeFromGit } from './dependencies.js';
|
|
3
|
-
import { lstat,
|
|
3
|
+
import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -246,95 +246,81 @@ export async function pushWorkspaceGit(root, boundBranch) {
|
|
|
246
246
|
catch { /* Preserve uncertainty; never replay a write automatically. */ }
|
|
247
247
|
throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
|
|
248
248
|
}
|
|
249
|
-
const UNPUSHED_DIRECTORY = '.git/playcraft';
|
|
250
|
-
export const UNPUSHED_BUNDLE = `${UNPUSHED_DIRECTORY}/unpushed.bundle`;
|
|
251
|
-
const UNPUSHED_TIPS = `${UNPUSHED_DIRECTORY}/unpushed.tips`;
|
|
252
249
|
/**
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* baseline, and the whole history is left out rather than bundled.
|
|
250
|
+
* Credential files a save never commits, even when `.gitignore` misses them: env files (their
|
|
251
|
+
* `.example` / `.sample` / `.template` stay), SSH keys, netrc and Git credential stores, and key
|
|
252
|
+
* or certificate bundles.
|
|
257
253
|
*/
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (!marker?.isDirectory())
|
|
262
|
-
return;
|
|
263
|
-
const tips = await git(rootReal, [
|
|
264
|
-
'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes', 'refs/stash',
|
|
265
|
-
]);
|
|
266
|
-
const tipsPath = join(rootReal, UNPUSHED_TIPS);
|
|
267
|
-
if ((await readFile(tipsPath, 'utf8').catch(() => undefined)) === tips)
|
|
268
|
-
return;
|
|
269
|
-
const refs = tips.split('\n').map((line) => line.split(' ')[1]).filter((ref) => Boolean(ref));
|
|
270
|
-
const local = ['--branches', ...(refs.includes('refs/stash') ? ['refs/stash'] : [])];
|
|
271
|
-
const unpushed = refs.some((ref) => ref.startsWith('refs/remotes/'))
|
|
272
|
-
? Number.parseInt((await git(rootReal, ['rev-list', '--count', ...local, '--not', '--remotes'], 60000)).trim(), 10)
|
|
273
|
-
: 0;
|
|
274
|
-
const bundlePath = join(rootReal, UNPUSHED_BUNDLE);
|
|
275
|
-
await mkdir(join(rootReal, UNPUSHED_DIRECTORY), { recursive: true, mode: 0o700 });
|
|
276
|
-
if (unpushed > 0) {
|
|
277
|
-
// The `.lock` name keeps a half-written bundle out of any snapshot.
|
|
278
|
-
const temp = `${bundlePath}.lock`;
|
|
279
|
-
try {
|
|
280
|
-
await git(rootReal, ['bundle', 'create', temp, ...local, '--not', '--remotes'], 120000);
|
|
281
|
-
await rename(temp, bundlePath);
|
|
282
|
-
}
|
|
283
|
-
finally {
|
|
284
|
-
await rm(temp, { force: true });
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
await rm(bundlePath, { force: true });
|
|
289
|
-
}
|
|
290
|
-
await writeFile(tipsPath, tips, { mode: 0o600 });
|
|
254
|
+
const CREDENTIAL_FILE = /^(?:\.env(?:\.(?!(?:example|sample|template)$)[^/]+)?|id_(?:rsa|dsa|ecdsa|ed25519)|\.netrc|\.git-credentials|[^/]*\.(?:pem|key|p12|pfx))$/i;
|
|
255
|
+
export function isCredentialFile(path) {
|
|
256
|
+
return CREDENTIAL_FILE.test(path.split('/').pop() ?? '');
|
|
291
257
|
}
|
|
292
258
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* reports what is missing.
|
|
259
|
+
* Commits every change in the work tree (`git add -A`, credential files aside) and, when asked,
|
|
260
|
+
* pushes the branch. A save with nothing new commits nothing and pushes only what the remote
|
|
261
|
+
* lacks, so a commit whose push failed goes out with the next save.
|
|
297
262
|
*/
|
|
298
|
-
export async function
|
|
299
|
-
const rootReal = await
|
|
300
|
-
|
|
301
|
-
|
|
263
|
+
export async function saveWorkspaceGit(root, input) {
|
|
264
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
265
|
+
await assertBoundBranch(rootReal, input.boundBranch);
|
|
266
|
+
await git(rootReal, ['add', '-A'], 60000);
|
|
267
|
+
const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
|
|
268
|
+
const withheld = staged.filter(isCredentialFile);
|
|
269
|
+
await unstage(rootReal, withheld);
|
|
270
|
+
const cached = await gitAllowFail(rootReal, ['diff', '--cached', '--quiet'], 60000);
|
|
271
|
+
if (cached.code !== 0 && cached.code !== 1) {
|
|
272
|
+
throw new GitWorkspaceError('git_command_failed', cached.stderr.trim() || 'git_diff_failed');
|
|
273
|
+
}
|
|
274
|
+
let commit;
|
|
275
|
+
if (cached.code === 1) {
|
|
276
|
+
await git(rootReal, [...(await commitIdentity(rootReal)), 'commit', '-q', '-m', input.message], 60000);
|
|
277
|
+
commit = (await git(rootReal, ['rev-parse', 'HEAD'])).trim();
|
|
278
|
+
}
|
|
279
|
+
if (!input.push || (await remoteHasHead(rootReal)))
|
|
280
|
+
return { ...(commit ? { commit } : {}), pushed: false, withheld };
|
|
281
|
+
await pushWorkspaceGit(rootReal, input.boundBranch);
|
|
282
|
+
await recordPushedHead(rootReal);
|
|
283
|
+
return { ...(commit ? { commit } : {}), pushed: true, withheld };
|
|
284
|
+
}
|
|
285
|
+
async function unstage(rootReal, paths) {
|
|
286
|
+
if (paths.length === 0)
|
|
302
287
|
return;
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
catch {
|
|
309
|
-
/* Reported by repositoryResolves. */
|
|
310
|
-
}
|
|
311
|
-
finally {
|
|
312
|
-
await rm(temp, { force: true });
|
|
288
|
+
const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD'])).code === 0;
|
|
289
|
+
for (let start = 0; start < paths.length; start += 100) {
|
|
290
|
+
const chunk = paths.slice(start, start + 100);
|
|
291
|
+
// Before the first commit there is no HEAD to reset to; dropping the entries does the same.
|
|
292
|
+
await git(rootReal, born ? ['reset', '-q', '--', ...chunk] : ['rm', '-q', '--cached', '--', ...chunk]);
|
|
313
293
|
}
|
|
314
294
|
}
|
|
315
|
-
/**
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
return branches.code === 0 && branches.stdout.trim() === '';
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
295
|
+
/** A clone carries no committer; the sandbox image sets none either. */
|
|
296
|
+
async function commitIdentity(rootReal) {
|
|
297
|
+
const email = await gitAllowFail(rootReal, ['config', 'user.email']);
|
|
298
|
+
if (email.code === 0 && email.stdout.trim())
|
|
299
|
+
return [];
|
|
300
|
+
return ['-c', 'user.name=PlayCraft Workspace', '-c', 'user.email=workspace@playcraft.local'];
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The remote-tracking ref of the current branch. A fork clone has no fetch refspec for its own
|
|
304
|
+
* branch, so the ref is written here after a confirmed push.
|
|
305
|
+
*/
|
|
306
|
+
async function trackingRef(rootReal) {
|
|
307
|
+
const branch = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
|
|
308
|
+
return branch ? `refs/remotes/origin/${branch}` : undefined;
|
|
309
|
+
}
|
|
310
|
+
async function remoteHasHead(rootReal) {
|
|
311
|
+
const ref = await trackingRef(rootReal);
|
|
312
|
+
if (!ref)
|
|
336
313
|
return false;
|
|
337
|
-
|
|
314
|
+
const [head, tracked] = await Promise.all([
|
|
315
|
+
gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}']),
|
|
316
|
+
gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', `${ref}^{commit}`]),
|
|
317
|
+
]);
|
|
318
|
+
return head.code === 0 && tracked.code === 0 && head.stdout.trim() === tracked.stdout.trim();
|
|
319
|
+
}
|
|
320
|
+
async function recordPushedHead(rootReal) {
|
|
321
|
+
const ref = await trackingRef(rootReal);
|
|
322
|
+
if (ref)
|
|
323
|
+
await gitAllowFail(rootReal, ['update-ref', ref, 'HEAD']);
|
|
338
324
|
}
|
|
339
325
|
export async function ensureWorkspaceGit(root) {
|
|
340
326
|
const rootReal = await realpath(root);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { workspaceRoot } from './registry.js';
|
|
3
|
-
import {
|
|
3
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
|
|
5
5
|
/** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
|
|
6
6
|
export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
@@ -8,9 +8,7 @@ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
|
8
8
|
const epoch = state.runtimeEpoch;
|
|
9
9
|
if (state.journal.destroyed())
|
|
10
10
|
throw new Error('workspace_skills_unavailable');
|
|
11
|
-
const endpoint =
|
|
12
|
-
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
13
|
-
throw new Error('workspace_skills_callback_insecure');
|
|
11
|
+
const endpoint = platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.skillArtifacts);
|
|
14
12
|
const response = await fetch(endpoint, {
|
|
15
13
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
|
16
14
|
headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.18",
|
|
52
|
+
"@playcraft/common": "^0.1.18",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { startLauncherProcess } from './launcher-client.js';
|
|
5
|
-
export const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
|
|
6
|
-
const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
7
|
-
const TRANSCRIPT_PATH = /^sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[A-Za-z0-9_.-]{1,200}\.jsonl$/;
|
|
8
|
-
const COMMAND_TIMEOUT_MS = 60_000;
|
|
9
|
-
/**
|
|
10
|
-
* Reads the transcript Codex keeps for one thread. Codex appends to it while a turn runs, so a
|
|
11
|
-
* trailing partial line is dropped; what is returned always parses line by line.
|
|
12
|
-
*/
|
|
13
|
-
export async function exportTranscript(home, threadId) {
|
|
14
|
-
if (!THREAD_ID.test(threadId))
|
|
15
|
-
return undefined;
|
|
16
|
-
const found = home.launcher
|
|
17
|
-
? await exportAsDeveloper(home.homeDir, home.launcher, threadId)
|
|
18
|
-
: await exportDirect(home.homeDir, threadId);
|
|
19
|
-
if (!found || !transcriptPathFor(found.path, threadId))
|
|
20
|
-
return undefined;
|
|
21
|
-
const end = found.body.lastIndexOf(0x0a);
|
|
22
|
-
if (end < 0)
|
|
23
|
-
return undefined;
|
|
24
|
-
return { path: found.path, body: found.body.subarray(0, end + 1) };
|
|
25
|
-
}
|
|
26
|
-
function transcriptPathFor(path, threadId) {
|
|
27
|
-
return TRANSCRIPT_PATH.test(path) && path.endsWith(`-${threadId}.jsonl`);
|
|
28
|
-
}
|
|
29
|
-
async function exportDirect(homeDir, threadId) {
|
|
30
|
-
const suffix = `-${threadId}.jsonl`;
|
|
31
|
-
for (const year of await names(join(homeDir, 'sessions'))) {
|
|
32
|
-
for (const month of await names(join(homeDir, 'sessions', year))) {
|
|
33
|
-
for (const day of await names(join(homeDir, 'sessions', year, month))) {
|
|
34
|
-
const directory = join('sessions', year, month, day);
|
|
35
|
-
const match = (await names(join(homeDir, directory))).find((name) => name.endsWith(suffix));
|
|
36
|
-
if (!match)
|
|
37
|
-
continue;
|
|
38
|
-
const path = `${directory}/${match}`;
|
|
39
|
-
const absolute = join(homeDir, path);
|
|
40
|
-
if ((await stat(absolute)).size > TRANSCRIPT_MAX_BYTES)
|
|
41
|
-
return undefined;
|
|
42
|
-
return { path, body: await readFile(absolute) };
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
async function exportAsDeveloper(homeDir, launcher, threadId) {
|
|
49
|
-
const limitKib = TRANSCRIPT_MAX_BYTES / 1024;
|
|
50
|
-
const outcome = await runAsDeveloper(launcher, [
|
|
51
|
-
`home=${quote(homeDir)}`,
|
|
52
|
-
`f=$(find "$home/sessions" -mindepth 4 -maxdepth 4 -type f -name ${quote(`rollout-*-${threadId}.jsonl`)} -size -${limitKib}k 2>/dev/null | head -n 1)`,
|
|
53
|
-
'[ -n "$f" ] || exit 3',
|
|
54
|
-
'printf \'%s\\n\' "${f#"$home"/}"',
|
|
55
|
-
'base64 -w 0 "$f"',
|
|
56
|
-
].join('; '));
|
|
57
|
-
if (outcome.code !== 0)
|
|
58
|
-
return undefined;
|
|
59
|
-
const text = outcome.stdout.toString('utf8');
|
|
60
|
-
const newline = text.indexOf('\n');
|
|
61
|
-
if (newline < 0)
|
|
62
|
-
return undefined;
|
|
63
|
-
return { path: text.slice(0, newline), body: Buffer.from(text.slice(newline + 1).trim(), 'base64') };
|
|
64
|
-
}
|
|
65
|
-
async function runAsDeveloper(launcher, command) {
|
|
66
|
-
const child = await startLauncherProcess({
|
|
67
|
-
socketPath: launcher.socketPath,
|
|
68
|
-
processId: `transcript-${randomUUID()}`,
|
|
69
|
-
runtimeEpoch: launcher.runtimeEpoch,
|
|
70
|
-
argv: ['/usr/bin/bash', '-c', `{ ${command}; } </dev/null 2>/dev/null`],
|
|
71
|
-
cwd: launcher.cwd,
|
|
72
|
-
env: {},
|
|
73
|
-
});
|
|
74
|
-
child.stdin?.end();
|
|
75
|
-
const chunks = [];
|
|
76
|
-
child.stdout?.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
77
|
-
const code = await new Promise((resolve) => {
|
|
78
|
-
const timer = setTimeout(() => {
|
|
79
|
-
void child.stop().catch(() => undefined);
|
|
80
|
-
resolve(null);
|
|
81
|
-
}, COMMAND_TIMEOUT_MS);
|
|
82
|
-
child.onExit((value) => {
|
|
83
|
-
clearTimeout(timer);
|
|
84
|
-
resolve(value);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
return { code, stdout: Buffer.concat(chunks) };
|
|
88
|
-
}
|
|
89
|
-
async function names(directory) {
|
|
90
|
-
return readdir(directory).then((entries) => entries.sort(), () => []);
|
|
91
|
-
}
|
|
92
|
-
function quote(value) {
|
|
93
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
94
|
-
}
|