@playcraft/cli 0.1.0 → 0.1.2
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/README.md +14 -2
- package/dist/utils/agent-api-client.js +50 -7
- package/dist/utils/tool-operation-journal.js +82 -16
- package/dist/workspace-runtime/codex/app-server.js +109 -51
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +32 -2
- 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 +23 -7
- package/dist/workspace-runtime/server/dispatch.js +15 -4
- package/dist/workspace-runtime/server/http.js +67 -4
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +276 -0
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +66 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +154 -0
- package/dist/workspace-runtime/workspaces/git.js +16 -3
- package/dist/workspace-runtime/workspaces/prepare.js +29 -172
- package/dist/workspace-runtime/workspaces/registry.js +12 -2
- package/package.json +4 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
1
2
|
import { collectedEvent } from './native-adapter.js';
|
|
2
3
|
/** In-process Codex stand-in. Not the App Server; used to prove the operation/event loop. */
|
|
3
4
|
export function createLoopbackNativeAdapter() {
|
|
@@ -22,7 +23,7 @@ export function createLoopbackNativeAdapter() {
|
|
|
22
23
|
},
|
|
23
24
|
async startThread(input) {
|
|
24
25
|
const threadId = `thread_${input.sessionId}`;
|
|
25
|
-
threads.set(threadId, { sessionId: input.sessionId });
|
|
26
|
+
threads.set(threadId, { sessionId: input.sessionId, items: [] });
|
|
26
27
|
return { threadId };
|
|
27
28
|
},
|
|
28
29
|
async startTurn(input) {
|
|
@@ -40,6 +41,18 @@ export function createLoopbackNativeAdapter() {
|
|
|
40
41
|
turnStarts += 1;
|
|
41
42
|
const turnId = `turn_${input.sessionId}_${input.clientRequestId}`;
|
|
42
43
|
thread.turnId = turnId;
|
|
44
|
+
thread.items.push({
|
|
45
|
+
type: 'userMessage',
|
|
46
|
+
id: `user_${turnId}`,
|
|
47
|
+
content: [{ type: 'text', text: input.text }],
|
|
48
|
+
});
|
|
49
|
+
if (!input.text.includes('[approval]')) {
|
|
50
|
+
thread.items.push({
|
|
51
|
+
type: 'agentMessage',
|
|
52
|
+
id: `agent_${turnId}`,
|
|
53
|
+
text: `loopback:${input.text}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
43
56
|
queueMicrotask(() => {
|
|
44
57
|
emit(collectedEvent('notification', 'item/agentMessage/delta', {
|
|
45
58
|
threadId: input.threadId,
|
|
@@ -104,7 +117,24 @@ export function createLoopbackNativeAdapter() {
|
|
|
104
117
|
turn: { id: thread?.[1].turnId, status: 'completed' },
|
|
105
118
|
}));
|
|
106
119
|
});
|
|
107
|
-
|
|
120
|
+
const found = thread?.[1];
|
|
121
|
+
if (found) {
|
|
122
|
+
found.items.push({
|
|
123
|
+
type: 'agentMessage',
|
|
124
|
+
id: `agent_${found.turnId ?? input.approvalId}`,
|
|
125
|
+
text: 'after-approval',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return { outcome: 'accepted', approvalId: input.approvalId, turnId: found?.turnId };
|
|
129
|
+
},
|
|
130
|
+
async readThread(input) {
|
|
131
|
+
const thread = threads.get(input.threadId);
|
|
132
|
+
if (!thread || thread.sessionId !== input.sessionId)
|
|
133
|
+
return undefined;
|
|
134
|
+
return projectNativeThread(input.threadId, {
|
|
135
|
+
id: input.threadId,
|
|
136
|
+
turns: [{ items: thread.items }],
|
|
137
|
+
});
|
|
108
138
|
},
|
|
109
139
|
};
|
|
110
140
|
}
|
|
@@ -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
|
}
|
|
@@ -12,6 +12,8 @@ import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
|
|
|
12
12
|
import { handleExecutionGroupCrash } from './server/execution-group.js';
|
|
13
13
|
import { RuntimeProcessLock } from './server/lock.js';
|
|
14
14
|
import { probeGitBinary } from './workspaces/git.js';
|
|
15
|
+
import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
|
|
16
|
+
import { WorkspaceModelBroker } from './codex/model-broker.js';
|
|
15
17
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
16
18
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
17
19
|
const negotiated = negotiateProtocol(config);
|
|
@@ -25,26 +27,31 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
25
27
|
const lock = new RuntimeProcessLock(config.stateDir);
|
|
26
28
|
lock.acquire();
|
|
27
29
|
let journal;
|
|
30
|
+
let gitCredentials;
|
|
28
31
|
try {
|
|
29
32
|
journal = new RuntimeJournal(config.stateDir);
|
|
30
33
|
await journal.load();
|
|
31
34
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
|
|
32
|
-
const native = options.nativeAdapter ?? defaultNativeAdapter(config);
|
|
33
35
|
const state = {
|
|
34
36
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
35
37
|
journal,
|
|
36
38
|
acceptedLeaseFence: journal.acceptedLeaseFence(),
|
|
37
39
|
gitAvailable: await probeGitBinary(),
|
|
38
|
-
native,
|
|
40
|
+
native: options.nativeAdapter,
|
|
39
41
|
runtimeEpoch: Math.max(config.runtimeEpoch, journal.storedRuntimeEpoch() ?? 0),
|
|
40
42
|
executionQueue: Promise.resolve(),
|
|
41
43
|
instanceToken: randomUUID(),
|
|
42
44
|
};
|
|
45
|
+
const native = state.native ??= defaultNativeAdapter(state);
|
|
43
46
|
await journal.bindRuntime({
|
|
44
47
|
runtimeId: config.runtimeId,
|
|
45
48
|
generation: config.generation,
|
|
46
49
|
runtimeEpoch: state.runtimeEpoch,
|
|
47
50
|
});
|
|
51
|
+
if (config.workspaceMode === 'multi') {
|
|
52
|
+
gitCredentials = state.gitCredentials = new WorkspaceGitCredentialBroker(state);
|
|
53
|
+
await gitCredentials.start();
|
|
54
|
+
}
|
|
48
55
|
attachNativeCollector(state);
|
|
49
56
|
native?.onUnexpectedExit?.((event) => {
|
|
50
57
|
void handleExecutionGroupCrash(state, event.pid);
|
|
@@ -55,8 +62,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
55
62
|
if (group)
|
|
56
63
|
await journal.noteExecutionGroup(group);
|
|
57
64
|
}
|
|
58
|
-
catch {
|
|
59
|
-
|
|
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}`);
|
|
60
69
|
}
|
|
61
70
|
const server = createServer((request, response) => {
|
|
62
71
|
void handleRuntimeRequest(state, request, response);
|
|
@@ -79,6 +88,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
79
88
|
state,
|
|
80
89
|
url: `http://${formatHost(config.listen.host)}:${port}`,
|
|
81
90
|
async close() {
|
|
91
|
+
await gitCredentials?.close();
|
|
82
92
|
disposeDesktopHost(state);
|
|
83
93
|
disposeTerminalHost(state);
|
|
84
94
|
await closeServer(server);
|
|
@@ -94,6 +104,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
94
104
|
};
|
|
95
105
|
}
|
|
96
106
|
catch (error) {
|
|
107
|
+
await gitCredentials?.close().catch(() => undefined);
|
|
97
108
|
await journal?.close().catch(() => undefined);
|
|
98
109
|
lock.release();
|
|
99
110
|
throw error;
|
|
@@ -127,11 +138,16 @@ function closeServer(server) {
|
|
|
127
138
|
function formatHost(host) {
|
|
128
139
|
return host.includes(':') ? `[${host}]` : host;
|
|
129
140
|
}
|
|
130
|
-
function defaultNativeAdapter(
|
|
141
|
+
function defaultNativeAdapter(state) {
|
|
142
|
+
const config = state.config;
|
|
131
143
|
if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
|
|
132
144
|
return createLoopbackNativeAdapter();
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
});
|
|
135
151
|
}
|
|
136
152
|
return undefined;
|
|
137
153
|
}
|
|
@@ -3,6 +3,7 @@ import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/r
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, 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';
|
|
@@ -117,10 +118,19 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
117
118
|
}
|
|
118
119
|
async function executeCreate(state, scopeKey, operation) {
|
|
119
120
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
let started;
|
|
122
|
+
try {
|
|
123
|
+
started = await native(state).startThread({
|
|
124
|
+
sessionId,
|
|
125
|
+
cwd: await workspaceRoot(state, operation.workspaceId),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error instanceof NativePreparationError) {
|
|
130
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: error.message }));
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
124
134
|
if (!started.threadId) {
|
|
125
135
|
return persist(state, scopeKey, transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' }));
|
|
126
136
|
}
|
|
@@ -469,6 +479,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
469
479
|
}
|
|
470
480
|
async function executeDestroy(state, scopeKey, operation) {
|
|
471
481
|
await state.journal.markDestroyed();
|
|
482
|
+
await state.gitCredentials?.destroy();
|
|
472
483
|
disposeTerminalHost(state);
|
|
473
484
|
try {
|
|
474
485
|
await state.native?.close?.();
|
|
@@ -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, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
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';
|
|
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';
|
|
@@ -64,6 +64,11 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
64
64
|
writeJson(response, 200, handshake(state));
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
+
const historyPath = sessionHistoryPath(url.pathname);
|
|
68
|
+
if (request.method === 'GET' && historyPath) {
|
|
69
|
+
await handleSessionHistory(state, response, historyPath.workspaceId, historyPath.sessionId);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
67
72
|
if (isUnimplementedWorkbenchPath(url.pathname)) {
|
|
68
73
|
if (writeDestroyed(state, response))
|
|
69
74
|
return;
|
|
@@ -206,7 +211,7 @@ function handshake(state) {
|
|
|
206
211
|
if (item.name === 'conversation') {
|
|
207
212
|
if (conversationReady)
|
|
208
213
|
return capability('conversation', true);
|
|
209
|
-
return
|
|
214
|
+
return capability('conversation', false, destroyed ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started');
|
|
210
215
|
}
|
|
211
216
|
if (item.name === 'files') {
|
|
212
217
|
return filesReady
|
|
@@ -538,11 +543,69 @@ function unavailableReason(state, kind) {
|
|
|
538
543
|
return 'runtime_destroyed';
|
|
539
544
|
if (kind === 'preview.publish')
|
|
540
545
|
return 'capability_not_implemented';
|
|
541
|
-
return 'adapter_not_started';
|
|
546
|
+
return state.nativeError ?? 'adapter_not_started';
|
|
547
|
+
}
|
|
548
|
+
function sessionHistoryPath(pathname) {
|
|
549
|
+
const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
|
|
550
|
+
if (!match?.[1] || !match[2])
|
|
551
|
+
return undefined;
|
|
552
|
+
try {
|
|
553
|
+
return {
|
|
554
|
+
workspaceId: decodeURIComponent(match[1]),
|
|
555
|
+
sessionId: decodeURIComponent(match[2]),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
542
561
|
}
|
|
543
562
|
function isUnimplementedWorkbenchPath(pathname) {
|
|
544
563
|
const parts = pathname.split('/').filter(Boolean);
|
|
545
|
-
return parts[0] === 'runtime' &&
|
|
564
|
+
return parts[0] === 'runtime' && parts.includes('previews');
|
|
565
|
+
}
|
|
566
|
+
async function handleSessionHistory(state, response, workspaceId, sessionId) {
|
|
567
|
+
if (writeDestroyed(state, response))
|
|
568
|
+
return;
|
|
569
|
+
const session = state.journal.getSession(sessionId);
|
|
570
|
+
if (!session || (session.workspaceId && session.workspaceId !== workspaceId)) {
|
|
571
|
+
writeJson(response, 404, { error: 'session_not_found' });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (state.config.workspaceMode === 'multi' && session.workspaceId !== workspaceId) {
|
|
575
|
+
writeJson(response, 404, { error: 'session_not_found' });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const readThread = state.native?.readThread;
|
|
579
|
+
if (!readThread) {
|
|
580
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
let read;
|
|
584
|
+
try {
|
|
585
|
+
read = await readThread({ threadId: session.threadId, sessionId });
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (!read || read.threadId !== session.threadId) {
|
|
592
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const body = nativeHistoryReadSchema.safeParse({
|
|
596
|
+
source: 'native',
|
|
597
|
+
sessionId,
|
|
598
|
+
workspaceId,
|
|
599
|
+
threadId: read.threadId,
|
|
600
|
+
...(read.threadName ? { threadName: read.threadName } : {}),
|
|
601
|
+
truncated: read.truncated,
|
|
602
|
+
items: read.items,
|
|
603
|
+
});
|
|
604
|
+
if (!body.success) {
|
|
605
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
writeJson(response, 200, body.data);
|
|
546
609
|
}
|
|
547
610
|
async function readObservedParameters(state, workspaceId) {
|
|
548
611
|
const root = await workspaceRoot(state, workspaceId);
|