@playcraft/cli 0.1.1 → 0.1.3
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/workspace-runtime/codex/app-server.js +21 -4
- package/dist/workspace-runtime/codex/model-broker.js +183 -0
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/serve.js +15 -7
- package/dist/workspace-runtime/server/dispatch.js +46 -6
- package/dist/workspace-runtime/server/http.js +13 -4
- package/dist/workspace-runtime/workspaces/git.js +122 -15
- package/package.json +3 -3
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { writePlaycraftCodexConfig } from './config-toml.js';
|
|
7
7
|
import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
|
|
8
8
|
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
|
-
import { collectedEvent } from './native-adapter.js';
|
|
9
|
+
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
10
10
|
function asRecord(value) {
|
|
11
11
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
12
12
|
? value
|
|
@@ -74,8 +74,20 @@ export function createCodexAppServerAdapter(options) {
|
|
|
74
74
|
startPromise ??= start();
|
|
75
75
|
await startPromise;
|
|
76
76
|
}
|
|
77
|
+
async function threadConfig(cwd) {
|
|
78
|
+
try {
|
|
79
|
+
return await options.modelBroker?.threadConfig(cwd) ?? {};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const reason = error instanceof Error ? error.message : '';
|
|
83
|
+
throw new NativePreparationError(['model_config_required', 'model_callback_insecure',
|
|
84
|
+
'model_credential_scope_mismatch', 'workspace_not_registered'].includes(reason)
|
|
85
|
+
? reason : 'model_credential_unavailable');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
77
88
|
async function start() {
|
|
78
|
-
|
|
89
|
+
await options.modelBroker?.start();
|
|
90
|
+
const token = options.modelBroker ? 'workspace-broker-managed' : await readToken(options);
|
|
79
91
|
const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
|
|
80
92
|
const { command, prefix } = spawnCommand(bin);
|
|
81
93
|
const args = [...prefix, ...(options.args ?? ['app-server'])];
|
|
@@ -98,7 +110,9 @@ export function createCodexAppServerAdapter(options) {
|
|
|
98
110
|
processId: `codex-${randomUUID()}`,
|
|
99
111
|
runtimeEpoch: executionEpoch,
|
|
100
112
|
argv: [command, ...args],
|
|
101
|
-
|
|
113
|
+
// The launcher admits the dev home, not the shared /workspaces root.
|
|
114
|
+
// Each thread supplies its registered workspace separately.
|
|
115
|
+
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
102
116
|
env: { CODEX_HOME: homeDir, OPENAI_API_KEY: token },
|
|
103
117
|
});
|
|
104
118
|
}
|
|
@@ -230,7 +244,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
230
244
|
if (!rpc)
|
|
231
245
|
return false;
|
|
232
246
|
try {
|
|
233
|
-
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd
|
|
247
|
+
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
|
|
248
|
+
...await threadConfig(input.cwd ?? '') }, 30_000);
|
|
234
249
|
const resumedId = result.thread?.id ?? result.threadId;
|
|
235
250
|
if (resumedId !== input.threadId)
|
|
236
251
|
return false;
|
|
@@ -275,6 +290,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
275
290
|
const result = await rpc.request('thread/start', {
|
|
276
291
|
cwd: input.cwd,
|
|
277
292
|
model,
|
|
293
|
+
...await threadConfig(input.cwd),
|
|
278
294
|
});
|
|
279
295
|
const threadId = result.thread?.id;
|
|
280
296
|
if (!threadId)
|
|
@@ -356,6 +372,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
356
372
|
await current.stop();
|
|
357
373
|
child = undefined;
|
|
358
374
|
live = undefined;
|
|
375
|
+
await options.modelBroker?.close();
|
|
359
376
|
},
|
|
360
377
|
};
|
|
361
378
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { basename } from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { workspaceRoot } from '../workspaces/registry.js';
|
|
8
|
+
const credentialSchema = z.object({
|
|
9
|
+
token: z.string().min(1), expiresAt: z.string().datetime(),
|
|
10
|
+
configId: z.string().uuid(), model: z.string().min(1).max(128),
|
|
11
|
+
claims: z.object({
|
|
12
|
+
environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
|
|
13
|
+
generation: z.number().int(), runtimeEpoch: z.number().int(),
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
/** Keeps renewable execution credentials in control memory, outside Codex and project files. */
|
|
17
|
+
export class WorkspaceModelBroker {
|
|
18
|
+
state;
|
|
19
|
+
server;
|
|
20
|
+
baseUrl = '';
|
|
21
|
+
entries = new Map();
|
|
22
|
+
pending = new Map();
|
|
23
|
+
requests = new Set();
|
|
24
|
+
constructor(state) {
|
|
25
|
+
this.state = state;
|
|
26
|
+
}
|
|
27
|
+
async start() {
|
|
28
|
+
if (this.server)
|
|
29
|
+
return;
|
|
30
|
+
const server = createServer({ maxHeaderSize: 8192 }, (request, response) => {
|
|
31
|
+
void this.forward(request, response);
|
|
32
|
+
});
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
server.once('error', reject);
|
|
35
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
36
|
+
});
|
|
37
|
+
this.server = server;
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === 'string')
|
|
40
|
+
throw new Error('model_broker_unavailable');
|
|
41
|
+
this.baseUrl = `http://127.0.0.1:${address.port}`;
|
|
42
|
+
}
|
|
43
|
+
async threadConfig(cwd) {
|
|
44
|
+
const workspaceId = basename(cwd);
|
|
45
|
+
if (!this.server || await workspaceRoot(this.state, workspaceId) !== cwd)
|
|
46
|
+
throw new Error('workspace_not_registered');
|
|
47
|
+
let entry = this.entries.get(workspaceId);
|
|
48
|
+
if (!entry || entry.epoch !== this.state.runtimeEpoch) {
|
|
49
|
+
entry = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
|
|
50
|
+
this.entries.set(workspaceId, entry);
|
|
51
|
+
}
|
|
52
|
+
const credential = await this.credential(entry);
|
|
53
|
+
return {
|
|
54
|
+
model: credential.model,
|
|
55
|
+
modelProvider: 'playcraft_workspace',
|
|
56
|
+
config: {
|
|
57
|
+
'model_providers.playcraft_workspace': {
|
|
58
|
+
name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${workspaceId}`,
|
|
59
|
+
wire_api: 'responses', requires_openai_auth: false,
|
|
60
|
+
http_headers: { Authorization: `Bearer ${entry.capability}` },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
valid(entry) {
|
|
66
|
+
return Boolean(this.server && !this.state.journal.destroyed()
|
|
67
|
+
&& this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
|
|
68
|
+
&& this.entries.get(entry.workspaceId) === entry);
|
|
69
|
+
}
|
|
70
|
+
async credential(entry) {
|
|
71
|
+
if (!this.valid(entry))
|
|
72
|
+
throw new Error('model_access_denied');
|
|
73
|
+
if (entry.credential && Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
|
|
74
|
+
return entry.credential;
|
|
75
|
+
let pending = this.pending.get(entry);
|
|
76
|
+
if (!pending) {
|
|
77
|
+
pending = this.renew(entry).finally(() => this.pending.delete(entry));
|
|
78
|
+
this.pending.set(entry, pending);
|
|
79
|
+
}
|
|
80
|
+
return pending;
|
|
81
|
+
}
|
|
82
|
+
async renew(entry) {
|
|
83
|
+
const config = this.state.config;
|
|
84
|
+
const endpoint = new URL('/runtime/internal/model-credentials', config.callbackUrl);
|
|
85
|
+
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:'
|
|
86
|
+
&& ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
87
|
+
throw new Error('model_callback_insecure');
|
|
88
|
+
const response = await fetch(endpoint, {
|
|
89
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
90
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
|
|
92
|
+
runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId }),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const error = await response.json().catch(() => ({}));
|
|
96
|
+
throw new Error(error.reason === 'model_config_required' ? 'model_config_required' : 'model_credential_unavailable');
|
|
97
|
+
}
|
|
98
|
+
const credential = credentialSchema.parse(await response.json());
|
|
99
|
+
const claims = credential.claims;
|
|
100
|
+
if (!this.valid(entry) || claims.workspaceId !== entry.workspaceId || claims.runtimeId !== config.runtimeId
|
|
101
|
+
|| claims.userId !== config.userId || claims.environment !== config.environment
|
|
102
|
+
|| claims.generation !== config.generation || claims.runtimeEpoch !== entry.epoch
|
|
103
|
+
|| Date.parse(credential.expiresAt) <= Date.now() + 1000)
|
|
104
|
+
throw new Error('model_credential_scope_mismatch');
|
|
105
|
+
entry.credential = credential;
|
|
106
|
+
return credential;
|
|
107
|
+
}
|
|
108
|
+
async forward(request, response) {
|
|
109
|
+
const abort = new AbortController();
|
|
110
|
+
this.requests.add(abort);
|
|
111
|
+
const cancel = () => abort.abort();
|
|
112
|
+
response.once('close', cancel);
|
|
113
|
+
try {
|
|
114
|
+
const match = /^\/([0-9a-f-]{36})\/responses$/.exec(request.url ?? '');
|
|
115
|
+
const entry = match && this.entries.get(match[1]);
|
|
116
|
+
const presented = Buffer.from(request.headers.authorization ?? '');
|
|
117
|
+
const expected = Buffer.from(entry ? `Bearer ${entry.capability}` : '');
|
|
118
|
+
if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
|
|
119
|
+
|| !timingSafeEqual(presented, expected) || !this.valid(entry)) {
|
|
120
|
+
response.writeHead(403);
|
|
121
|
+
response.end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let size = 0;
|
|
126
|
+
for await (const chunk of request) {
|
|
127
|
+
size += chunk.length;
|
|
128
|
+
if (size > 16 * 1024 * 1024) {
|
|
129
|
+
response.writeHead(413);
|
|
130
|
+
response.end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
chunks.push(Buffer.from(chunk));
|
|
134
|
+
}
|
|
135
|
+
const credential = await this.credential(entry);
|
|
136
|
+
if (!this.valid(entry))
|
|
137
|
+
throw new Error('model_access_denied');
|
|
138
|
+
const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
|
|
139
|
+
try {
|
|
140
|
+
const upstream = await fetch(`${this.state.config.modelProxyUrl.replace(/\/+$/, '')}/v1/${credential.configId}/responses`, {
|
|
141
|
+
method: 'POST', redirect: 'error', signal: abort.signal,
|
|
142
|
+
headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json' },
|
|
143
|
+
body: Buffer.concat(chunks),
|
|
144
|
+
});
|
|
145
|
+
if (upstream.status === 401 || upstream.status === 403)
|
|
146
|
+
entry.credential = undefined;
|
|
147
|
+
response.writeHead(upstream.status, {
|
|
148
|
+
'content-type': upstream.headers.get('content-type') ?? 'application/json', 'cache-control': 'no-store',
|
|
149
|
+
});
|
|
150
|
+
if (upstream.body)
|
|
151
|
+
await pipeline(Readable.fromWeb(upstream.body), response);
|
|
152
|
+
else
|
|
153
|
+
response.end();
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(expires);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
if (!response.headersSent) {
|
|
161
|
+
response.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
162
|
+
response.end(JSON.stringify({ error: { message: 'model_credential_unavailable', type: 'server_error' } }));
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
response.destroy();
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
response.off('close', cancel);
|
|
169
|
+
this.requests.delete(abort);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async close() {
|
|
173
|
+
const server = this.server;
|
|
174
|
+
this.server = undefined;
|
|
175
|
+
this.entries.clear();
|
|
176
|
+
for (const request of this.requests)
|
|
177
|
+
request.abort();
|
|
178
|
+
if (server) {
|
|
179
|
+
server.closeAllConnections();
|
|
180
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Failure before a native request was sent; safe to report as failed instead of uncertain. */
|
|
2
|
+
export class NativePreparationError extends Error {
|
|
3
|
+
}
|
|
1
4
|
export function nativeEvent(type, payload) {
|
|
2
5
|
return { type, payload, occurredAt: new Date().toISOString() };
|
|
3
6
|
}
|
|
@@ -13,6 +13,7 @@ import { handleExecutionGroupCrash } from './server/execution-group.js';
|
|
|
13
13
|
import { RuntimeProcessLock } from './server/lock.js';
|
|
14
14
|
import { probeGitBinary } from './workspaces/git.js';
|
|
15
15
|
import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
|
|
16
|
+
import { WorkspaceModelBroker } from './codex/model-broker.js';
|
|
16
17
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
17
18
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
18
19
|
const negotiated = negotiateProtocol(config);
|
|
@@ -31,17 +32,17 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
31
32
|
journal = new RuntimeJournal(config.stateDir);
|
|
32
33
|
await journal.load();
|
|
33
34
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
|
|
34
|
-
const native = options.nativeAdapter ?? defaultNativeAdapter(config);
|
|
35
35
|
const state = {
|
|
36
36
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
37
37
|
journal,
|
|
38
38
|
acceptedLeaseFence: journal.acceptedLeaseFence(),
|
|
39
39
|
gitAvailable: await probeGitBinary(),
|
|
40
|
-
native,
|
|
40
|
+
native: options.nativeAdapter,
|
|
41
41
|
runtimeEpoch: Math.max(config.runtimeEpoch, journal.storedRuntimeEpoch() ?? 0),
|
|
42
42
|
executionQueue: Promise.resolve(),
|
|
43
43
|
instanceToken: randomUUID(),
|
|
44
44
|
};
|
|
45
|
+
const native = state.native ??= defaultNativeAdapter(state);
|
|
45
46
|
await journal.bindRuntime({
|
|
46
47
|
runtimeId: config.runtimeId,
|
|
47
48
|
generation: config.generation,
|
|
@@ -61,8 +62,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
61
62
|
if (group)
|
|
62
63
|
await journal.noteExecutionGroup(group);
|
|
63
64
|
}
|
|
64
|
-
catch {
|
|
65
|
-
|
|
65
|
+
catch (error) {
|
|
66
|
+
const code = error instanceof Error ? error.message : '';
|
|
67
|
+
state.nativeError = code === 'model_proxy_token_missing' ? code : 'codex_start_failed';
|
|
68
|
+
console.error(`[workspace-runtime] ${state.nativeError}`);
|
|
66
69
|
}
|
|
67
70
|
const server = createServer((request, response) => {
|
|
68
71
|
void handleRuntimeRequest(state, request, response);
|
|
@@ -135,11 +138,16 @@ function closeServer(server) {
|
|
|
135
138
|
function formatHost(host) {
|
|
136
139
|
return host.includes(':') ? `[${host}]` : host;
|
|
137
140
|
}
|
|
138
|
-
function defaultNativeAdapter(
|
|
141
|
+
function defaultNativeAdapter(state) {
|
|
142
|
+
const config = state.config;
|
|
139
143
|
if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
|
|
140
144
|
return createLoopbackNativeAdapter();
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
const managed = config.workspaceMode === 'multi' && Boolean(process.env.PLAYCRAFT_LAUNCHER_SOCKET);
|
|
146
|
+
if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex'
|
|
147
|
+
|| (!process.env.PLAYCRAFT_NATIVE_ADAPTER && managed)) {
|
|
148
|
+
return createCodexAppServerAdapter({ config,
|
|
149
|
+
...(managed ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
150
|
+
});
|
|
143
151
|
}
|
|
144
152
|
return undefined;
|
|
145
153
|
}
|
|
@@ -2,11 +2,12 @@ import { withObservedFile } from '../workspaces/revisions.js';
|
|
|
2
2
|
import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
6
7
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
7
8
|
import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
|
|
8
9
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
9
|
-
import { GitWorkspaceError, commitWorkspaceGit, pushWorkspaceGit } from '../workspaces/git.js';
|
|
10
|
+
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
10
11
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
11
12
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
12
13
|
import { executionGroupRestarted } from './execution-group.js';
|
|
@@ -25,6 +26,9 @@ const LOCAL_KINDS = new Set([
|
|
|
25
26
|
'file.write',
|
|
26
27
|
'git.commit',
|
|
27
28
|
'git.push',
|
|
29
|
+
'git.stage',
|
|
30
|
+
'git.unstage',
|
|
31
|
+
'git.discard',
|
|
28
32
|
'terminal.create',
|
|
29
33
|
'parameter.patch',
|
|
30
34
|
]);
|
|
@@ -87,6 +91,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
87
91
|
if (operation.kind === 'git.push') {
|
|
88
92
|
return executeGitPush(state, scopeKey, current, payload);
|
|
89
93
|
}
|
|
94
|
+
if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
|
|
95
|
+
return executeGitIndex(state, scopeKey, current, payload);
|
|
96
|
+
}
|
|
90
97
|
if (operation.kind === 'terminal.create') {
|
|
91
98
|
return executeTerminalCreate(state, scopeKey, current, payload);
|
|
92
99
|
}
|
|
@@ -117,10 +124,19 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
117
124
|
}
|
|
118
125
|
async function executeCreate(state, scopeKey, operation) {
|
|
119
126
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
let started;
|
|
128
|
+
try {
|
|
129
|
+
started = await native(state).startThread({
|
|
130
|
+
sessionId,
|
|
131
|
+
cwd: await workspaceRoot(state, operation.workspaceId),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof NativePreparationError) {
|
|
136
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: error.message }));
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
124
140
|
if (!started.threadId) {
|
|
125
141
|
return persist(state, scopeKey, transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' }));
|
|
126
142
|
}
|
|
@@ -610,6 +626,30 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
610
626
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
611
627
|
}
|
|
612
628
|
}
|
|
629
|
+
const GIT_INDEX_CHANGES = {
|
|
630
|
+
'git.stage': { run: stageWorkspaceGit, event: 'git.staged' },
|
|
631
|
+
'git.unstage': { run: unstageWorkspaceGit, event: 'git.unstaged' },
|
|
632
|
+
'git.discard': { run: discardWorkspaceGit, event: 'git.discarded' },
|
|
633
|
+
};
|
|
634
|
+
/** Stage, unstage and discard share a payload; each records its own event so the browser refreshes. */
|
|
635
|
+
async function executeGitIndex(state, scopeKey, operation, payload) {
|
|
636
|
+
const change = GIT_INDEX_CHANGES[operation.kind];
|
|
637
|
+
const parsed = gitPathsPayloadSchema.safeParse(payload);
|
|
638
|
+
if (!parsed.success) {
|
|
639
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
await change.run(await workspaceRoot(state, operation.workspaceId), parsed.data);
|
|
643
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
644
|
+
await recordNativeEvents(state, accepted, [
|
|
645
|
+
{ type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
|
|
646
|
+
]);
|
|
647
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
613
653
|
async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
614
654
|
const parsed = parameterPatchPayloadSchema.safeParse(payload);
|
|
615
655
|
if (!parsed.success) {
|
|
@@ -1,12 +1,12 @@
|
|
|
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, 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, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
7
7
|
import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
|
|
8
8
|
import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
|
|
9
|
-
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
9
|
+
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
10
10
|
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
11
11
|
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
12
12
|
import { terminalHost } from './terminal-host.js';
|
|
@@ -131,6 +131,15 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
131
131
|
await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
|
+
if (request.method === 'GET' && url.pathname === '/runtime/git/file') {
|
|
135
|
+
const ref = gitFileRefSchema.safeParse(url.searchParams.get('ref'));
|
|
136
|
+
if (!ref.success) {
|
|
137
|
+
writeJson(response, 400, { error: 'invalid_git_ref' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), url.searchParams.get('path') ?? '', ref.data));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
134
143
|
if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
|
|
135
144
|
await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
136
145
|
return;
|
|
@@ -211,7 +220,7 @@ function handshake(state) {
|
|
|
211
220
|
if (item.name === 'conversation') {
|
|
212
221
|
if (conversationReady)
|
|
213
222
|
return capability('conversation', true);
|
|
214
|
-
return
|
|
223
|
+
return capability('conversation', false, destroyed ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started');
|
|
215
224
|
}
|
|
216
225
|
if (item.name === 'files') {
|
|
217
226
|
return filesReady
|
|
@@ -543,7 +552,7 @@ function unavailableReason(state, kind) {
|
|
|
543
552
|
return 'runtime_destroyed';
|
|
544
553
|
if (kind === 'preview.publish')
|
|
545
554
|
return 'capability_not_implemented';
|
|
546
|
-
return 'adapter_not_started';
|
|
555
|
+
return state.nativeError ?? 'adapter_not_started';
|
|
547
556
|
}
|
|
548
557
|
function sessionHistoryPath(pathname) {
|
|
549
558
|
const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { gitDiffSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
const GIT_DIFF_MAX_BYTES = 1024 * 1024;
|
|
7
7
|
const GIT_PREFIX = [
|
|
8
8
|
'-c',
|
|
@@ -42,15 +42,96 @@ export async function readWorkspaceGitStatus(root) {
|
|
|
42
42
|
ahead = Number.parseInt(right ?? '0', 10) || 0;
|
|
43
43
|
}
|
|
44
44
|
const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
|
|
45
|
+
// Line counts are a convenience; a repository without commits still lists its files.
|
|
46
|
+
const [staged, unstaged] = await Promise.all([
|
|
47
|
+
gitAllowFail(rootReal, ['diff', '--cached', '--numstat', '-z']),
|
|
48
|
+
gitAllowFail(rootReal, ['diff', '--numstat', '-z']),
|
|
49
|
+
]);
|
|
50
|
+
const stagedCounts = staged.code === 0 ? parseNumstat(staged.stdout) : new Map();
|
|
51
|
+
const unstagedCounts = unstaged.code === 0 ? parseNumstat(unstaged.stdout) : new Map();
|
|
45
52
|
return gitStatusSchema.parse({
|
|
46
53
|
initialized: true,
|
|
47
54
|
branch,
|
|
48
55
|
ahead,
|
|
49
56
|
behind,
|
|
50
57
|
hasUpstream: upstream.code === 0,
|
|
51
|
-
files: parsePorcelain(porcelain)
|
|
58
|
+
files: parsePorcelain(porcelain).map((entry) => ({
|
|
59
|
+
...entry,
|
|
60
|
+
...(stagedCounts.has(entry.path) ? { staged: stagedCounts.get(entry.path) } : {}),
|
|
61
|
+
...(unstagedCounts.has(entry.path) ? { unstaged: unstagedCounts.get(entry.path) } : {}),
|
|
62
|
+
})),
|
|
52
63
|
});
|
|
53
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* One file as stored in the last commit or in the index. Reads the blob as is, without filters,
|
|
67
|
+
* so the browser compares exactly what Git holds.
|
|
68
|
+
*/
|
|
69
|
+
export async function readWorkspaceGitFile(root, path, ref) {
|
|
70
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
71
|
+
assertWorkspacePaths([path]);
|
|
72
|
+
const object = ref === 'HEAD' ? `HEAD:${path}` : `:${path}`;
|
|
73
|
+
const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
|
|
74
|
+
const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
|
|
75
|
+
if (size.code !== 0)
|
|
76
|
+
return absent;
|
|
77
|
+
if (Number.parseInt(size.stdout.trim(), 10) > WORKSPACE_FILE_CONTENT_MAX) {
|
|
78
|
+
throw new GitWorkspaceError('git_file_too_large', path);
|
|
79
|
+
}
|
|
80
|
+
const blob = await gitAllowFail(rootReal, ['cat-file', 'blob', object]);
|
|
81
|
+
if (blob.code !== 0)
|
|
82
|
+
return absent;
|
|
83
|
+
// Git treats a NUL byte as the mark of binary content; so does the diff view.
|
|
84
|
+
const binary = blob.stdout.includes('\0');
|
|
85
|
+
return gitFileContentSchema.parse({ path, ref, exists: true, binary, content: binary ? '' : blob.stdout });
|
|
86
|
+
}
|
|
87
|
+
/** Adds the paths to the index, including deletions. */
|
|
88
|
+
export async function stageWorkspaceGit(root, payload) {
|
|
89
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
90
|
+
assertWorkspacePaths(payload.paths);
|
|
91
|
+
await git(rootReal, ['--literal-pathspecs', 'add', '-A', '--', ...payload.paths]);
|
|
92
|
+
}
|
|
93
|
+
/** Returns the paths' index entries to the last commit, leaving the work tree alone. */
|
|
94
|
+
export async function unstageWorkspaceGit(root, payload) {
|
|
95
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
96
|
+
assertWorkspacePaths(payload.paths);
|
|
97
|
+
const head = await gitAllowFail(rootReal, ['rev-parse', '--verify', '--quiet', 'HEAD']);
|
|
98
|
+
if (head.code === 0) {
|
|
99
|
+
await git(rootReal, ['--literal-pathspecs', 'restore', '--staged', '--', ...payload.paths]);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Before the first commit there is nothing to restore from; the entries are simply removed.
|
|
103
|
+
await git(rootReal, ['--literal-pathspecs', 'rm', '--cached', '-r', '-q', '--ignore-unmatch', '--', ...payload.paths]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Throws away work-tree changes: tracked files return to their staged content, untracked files are
|
|
108
|
+
* deleted. Staged content is never touched, so a staged change survives a discard.
|
|
109
|
+
*/
|
|
110
|
+
export async function discardWorkspaceGit(root, payload) {
|
|
111
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
112
|
+
assertWorkspacePaths(payload.paths);
|
|
113
|
+
const others = await git(rootReal, ['--literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ...payload.paths]);
|
|
114
|
+
const untracked = new Set(others.split('\0').filter((path) => path.length > 0));
|
|
115
|
+
const tracked = payload.paths.filter((path) => !untracked.has(path));
|
|
116
|
+
if (tracked.length > 0) {
|
|
117
|
+
await git(rootReal, ['--literal-pathspecs', 'restore', '--worktree', '--', ...tracked]);
|
|
118
|
+
}
|
|
119
|
+
for (const path of untracked) {
|
|
120
|
+
const target = resolve(rootReal, path);
|
|
121
|
+
// The parent must resolve inside the workspace, so a symlinked directory cannot redirect the delete.
|
|
122
|
+
assertInsideRoot(rootReal, join(await realpath(dirname(target)), basename(target)));
|
|
123
|
+
if ((await lstat(target)).isDirectory())
|
|
124
|
+
throw new GitWorkspaceError('invalid_workspace_path', path);
|
|
125
|
+
await unlink(target);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function assertWorkspacePaths(paths) {
|
|
129
|
+
for (const relativePath of paths) {
|
|
130
|
+
if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
|
|
131
|
+
throw new GitWorkspaceError('invalid_workspace_path', relativePath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
54
135
|
export async function readWorkspaceGitDiff(root) {
|
|
55
136
|
const rootReal = await ensureWorkspaceGit(root);
|
|
56
137
|
const vsHead = await gitAllowFail(rootReal, ['diff', '--no-color', 'HEAD']);
|
|
@@ -92,12 +173,11 @@ function splitLogLine(line) {
|
|
|
92
173
|
}
|
|
93
174
|
export async function commitWorkspaceGit(root, payload) {
|
|
94
175
|
const rootReal = await ensureWorkspaceGit(root);
|
|
95
|
-
if (payload.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
176
|
+
if (payload.stagedOnly) {
|
|
177
|
+
// The index is the commit; nothing is added.
|
|
178
|
+
}
|
|
179
|
+
else if (payload.paths && payload.paths.length > 0) {
|
|
180
|
+
assertWorkspacePaths(payload.paths);
|
|
101
181
|
await git(rootReal, ['add', '--', ...payload.paths]);
|
|
102
182
|
}
|
|
103
183
|
else {
|
|
@@ -202,6 +282,28 @@ function parsePorcelain(raw) {
|
|
|
202
282
|
}
|
|
203
283
|
return files;
|
|
204
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* `diff --numstat -z`: `added<TAB>removed<TAB>path<NUL>`, or for a rename an empty path followed by
|
|
287
|
+
* `old<NUL>new<NUL>`. Binary files report `-` and are left out.
|
|
288
|
+
*/
|
|
289
|
+
function parseNumstat(raw) {
|
|
290
|
+
const counts = new Map();
|
|
291
|
+
const parts = raw.split('\0');
|
|
292
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
293
|
+
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(parts[index] ?? '');
|
|
294
|
+
if (!match)
|
|
295
|
+
continue;
|
|
296
|
+
let path = match[3];
|
|
297
|
+
if (path === '') {
|
|
298
|
+
path = parts[index + 2] ?? '';
|
|
299
|
+
index += 2;
|
|
300
|
+
}
|
|
301
|
+
if (match[1] === '-' || !path)
|
|
302
|
+
continue;
|
|
303
|
+
counts.set(path, { added: Number(match[1]), removed: Number(match[2]) });
|
|
304
|
+
}
|
|
305
|
+
return counts;
|
|
306
|
+
}
|
|
205
307
|
async function git(root, args, timeoutMs = 15000) {
|
|
206
308
|
const result = await gitAllowFail(root, args, timeoutMs);
|
|
207
309
|
if (result.code !== 0) {
|
|
@@ -227,17 +329,18 @@ function gitEnv() {
|
|
|
227
329
|
function runGitProcess(cwd, args, timeoutMs) {
|
|
228
330
|
return new Promise((resolve, reject) => {
|
|
229
331
|
const child = spawn('git', args, { cwd, env: gitEnv(), windowsHide: true });
|
|
230
|
-
|
|
231
|
-
|
|
332
|
+
// Chunks are decoded together; a character split across two chunks would otherwise be corrupted.
|
|
333
|
+
const stdout = [];
|
|
334
|
+
const stderr = [];
|
|
232
335
|
const timer = setTimeout(() => {
|
|
233
336
|
child.kill('SIGKILL');
|
|
234
337
|
reject(new GitWorkspaceError('git_command_failed', 'timeout'));
|
|
235
338
|
}, timeoutMs);
|
|
236
339
|
child.stdout.on('data', (chunk) => {
|
|
237
|
-
stdout
|
|
340
|
+
stdout.push(Buffer.from(chunk));
|
|
238
341
|
});
|
|
239
342
|
child.stderr.on('data', (chunk) => {
|
|
240
|
-
stderr
|
|
343
|
+
stderr.push(Buffer.from(chunk));
|
|
241
344
|
});
|
|
242
345
|
child.on('error', (error) => {
|
|
243
346
|
clearTimeout(timer);
|
|
@@ -249,7 +352,11 @@ function runGitProcess(cwd, args, timeoutMs) {
|
|
|
249
352
|
});
|
|
250
353
|
child.on('close', (code) => {
|
|
251
354
|
clearTimeout(timer);
|
|
252
|
-
resolve({
|
|
355
|
+
resolve({
|
|
356
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
357
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
358
|
+
code: code ?? 1,
|
|
359
|
+
});
|
|
253
360
|
});
|
|
254
361
|
});
|
|
255
362
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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.3",
|
|
52
|
+
"@playcraft/common": "^0.1.3",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|