@playcraft/cli 0.1.28 → 0.1.30
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 +357 -45
- package/dist/workspace-runtime/codex/compact.js +46 -0
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +26 -4
- package/dist/workspace-runtime/codex/loopback.js +5 -2
- package/dist/workspace-runtime/codex/mcp-config.js +39 -0
- package/dist/workspace-runtime/codex/model-broker.js +8 -3
- package/dist/workspace-runtime/codex/permissions.js +70 -0
- package/dist/workspace-runtime/codex/work-mode.js +15 -0
- package/dist/workspace-runtime/persistence/native-subagents.js +2 -2
- package/dist/workspace-runtime/persistence/sqlite-worker.js +50 -11
- package/dist/workspace-runtime/production/native.js +2 -0
- package/dist/workspace-runtime/production/save-command.js +11 -0
- package/dist/workspace-runtime/production/stages.js +16 -16
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/dispatch.js +221 -13
- package/dist/workspace-runtime/server/http.js +54 -4
- package/dist/workspace-runtime/workspaces/composer-context.js +125 -0
- package/dist/workspace-runtime/workspaces/files-search.js +116 -0
- package/package.json +3 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NativeRpcResponseError } from './jsonrpc-stdio.js';
|
|
2
|
+
/** The compact receipt is empty. A separately observed turn supplies its durable identity. */
|
|
3
|
+
export async function requestNativeCompaction(input) {
|
|
4
|
+
let turnId;
|
|
5
|
+
let rejectStarted = () => { };
|
|
6
|
+
let unsubscribe = () => { };
|
|
7
|
+
let timer;
|
|
8
|
+
const started = new Promise((resolve, reject) => {
|
|
9
|
+
rejectStarted = reject;
|
|
10
|
+
timer = setTimeout(() => reject(new Error('compact_start_timeout')), input.timeoutMs);
|
|
11
|
+
unsubscribe = input.onCollected(event => {
|
|
12
|
+
if (event.kind === 'process_exit') {
|
|
13
|
+
reject(new Error('compact_process_exited'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (event.method !== 'turn/started')
|
|
17
|
+
return;
|
|
18
|
+
const params = record(event.params), turn = record(params.turn);
|
|
19
|
+
if (params.threadId !== input.threadId || typeof turn.id !== 'string' || !turn.id || turn.id === input.previousTurnId)
|
|
20
|
+
return;
|
|
21
|
+
turnId = turn.id;
|
|
22
|
+
resolve(turn.id);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
// Subscribe before sending: Codex may emit the new turn before acknowledging this request.
|
|
27
|
+
await Promise.all([started, input.client.sendReserved(input.requestId, 'thread/compact/start', {
|
|
28
|
+
threadId: input.threadId,
|
|
29
|
+
}, input.timeoutMs)]);
|
|
30
|
+
return { outcome: 'accepted', threadId: input.threadId, turnId: turnId };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof NativeRpcResponseError && !turnId)
|
|
34
|
+
return { outcome: 'failed', error: 'compact_start_rejected' };
|
|
35
|
+
return { outcome: 'uncertain', threadId: input.threadId, ...(turnId ? { turnId } : {}) };
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
if (timer)
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
unsubscribe();
|
|
41
|
+
rejectStarted(new Error('compact_request_finished'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function record(value) {
|
|
45
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
46
|
+
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
+
export class NativeRpcResponseError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'NativeRpcResponseError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
2
10
|
export function createJsonRpcStdioClient(child) {
|
|
3
11
|
const stdin = child.stdin;
|
|
4
12
|
const stdout = child.stdout;
|
|
@@ -10,6 +18,14 @@ export function createJsonRpcStdioClient(child) {
|
|
|
10
18
|
let turnStarts = 0;
|
|
11
19
|
let buffer = '';
|
|
12
20
|
let closed = false;
|
|
21
|
+
const onTransportError = () => {
|
|
22
|
+
for (const [id, waiter] of pending) {
|
|
23
|
+
clearTimeout(waiter.timer);
|
|
24
|
+
waiter.reject(new Error('jsonrpc_closed'));
|
|
25
|
+
pending.delete(id);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
stdin.on('error', onTransportError);
|
|
13
29
|
const onData = (chunk) => {
|
|
14
30
|
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
15
31
|
let newline = buffer.indexOf('\n');
|
|
@@ -31,14 +47,14 @@ export function createJsonRpcStdioClient(child) {
|
|
|
31
47
|
catch {
|
|
32
48
|
return;
|
|
33
49
|
}
|
|
34
|
-
if (message.id !== undefined && pending.has(Number(message.id))) {
|
|
50
|
+
if (!message.method && message.id !== undefined && pending.has(Number(message.id))) {
|
|
35
51
|
const waiter = pending.get(Number(message.id));
|
|
36
52
|
if (!waiter)
|
|
37
53
|
return;
|
|
38
54
|
pending.delete(Number(message.id));
|
|
39
55
|
clearTimeout(waiter.timer);
|
|
40
56
|
if (message.error) {
|
|
41
|
-
waiter.reject(new
|
|
57
|
+
waiter.reject(new NativeRpcResponseError(message.error.message || 'jsonrpc_error', message.error.code));
|
|
42
58
|
return;
|
|
43
59
|
}
|
|
44
60
|
waiter.resolve(message.result);
|
|
@@ -53,7 +69,7 @@ export function createJsonRpcStdioClient(child) {
|
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
const send = (payload) => {
|
|
56
|
-
if (closed)
|
|
72
|
+
if (closed || stdin.destroyed || stdin.writableEnded)
|
|
57
73
|
throw new Error('jsonrpc_closed');
|
|
58
74
|
stdin.write(`${JSON.stringify(payload)}\n`);
|
|
59
75
|
};
|
|
@@ -104,7 +120,13 @@ export function createJsonRpcStdioClient(child) {
|
|
|
104
120
|
return () => emitter.off('serverRequest', listener);
|
|
105
121
|
},
|
|
106
122
|
respond(id, result) {
|
|
107
|
-
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
if (closed || stdin.destroyed || stdin.writableEnded) {
|
|
125
|
+
reject(new Error('jsonrpc_closed'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
stdin.write(`${JSON.stringify({ id, result })}\n`, (error) => error ? reject(error) : resolve());
|
|
129
|
+
});
|
|
108
130
|
},
|
|
109
131
|
async close() {
|
|
110
132
|
if (closed)
|
|
@@ -66,7 +66,7 @@ export function createLoopbackNativeAdapter() {
|
|
|
66
66
|
if (input.text.includes('[approval]')) {
|
|
67
67
|
const approvalId = `approval_${input.clientRequestId}`;
|
|
68
68
|
approvals.add(JSON.stringify([input.sessionId, approvalId]));
|
|
69
|
-
emit(collectedEvent('approval_request', 'item/
|
|
69
|
+
emit(collectedEvent('approval_request', 'item/commandExecution/requestApproval', {
|
|
70
70
|
threadId: input.threadId,
|
|
71
71
|
turnId,
|
|
72
72
|
approvalId,
|
|
@@ -98,6 +98,9 @@ export function createLoopbackNativeAdapter() {
|
|
|
98
98
|
},
|
|
99
99
|
async respondApproval(input) {
|
|
100
100
|
const key = JSON.stringify([input.sessionId, input.approvalId]);
|
|
101
|
+
const accepted = input.response?.kind === 'decision' ? input.response.accepted : input.response ? undefined : input.accepted;
|
|
102
|
+
if (typeof accepted !== 'boolean')
|
|
103
|
+
return { outcome: 'failed', approvalId: input.approvalId };
|
|
101
104
|
if (!approvals.has(key))
|
|
102
105
|
return { outcome: 'failed', approvalId: input.approvalId };
|
|
103
106
|
approvals.delete(key);
|
|
@@ -105,7 +108,7 @@ export function createLoopbackNativeAdapter() {
|
|
|
105
108
|
queueMicrotask(() => {
|
|
106
109
|
emit(collectedEvent('notification', 'approval.resolved', {
|
|
107
110
|
approvalId: input.approvalId,
|
|
108
|
-
accepted
|
|
111
|
+
accepted,
|
|
109
112
|
threadId: thread?.[0],
|
|
110
113
|
turnId: thread?.[1].turnId,
|
|
111
114
|
}));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { platformEndpoint, PLATFORM_ROUTES, workspaceMcpConfigSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { NativePreparationError } from './native-adapter.js';
|
|
4
|
+
function canonical(value) {
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return value.map(canonical);
|
|
7
|
+
if (value && typeof value === 'object')
|
|
8
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
|
9
|
+
.map(([key, entry]) => [key, canonical(entry)]));
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
/** MCP (Model Context Protocol, 模型上下文协议) settings are scoped to one native thread. */
|
|
13
|
+
export function nativeMcpConfig(value) {
|
|
14
|
+
const parsed = workspaceMcpConfigSchema.safeParse(value);
|
|
15
|
+
if (!parsed.success)
|
|
16
|
+
throw new NativePreparationError('workspace_mcp_unavailable');
|
|
17
|
+
return {
|
|
18
|
+
config: { mcp_servers: parsed.data.mcpServers },
|
|
19
|
+
hash: createHash('sha256').update(JSON.stringify(canonical(parsed.data))).digest('hex'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Resolve current owner settings at every turn boundary without writing secrets to a checkout. */
|
|
23
|
+
export async function loadWorkspaceMcpConfig(state, workspaceId) {
|
|
24
|
+
const config = state.config;
|
|
25
|
+
const runtimeEpoch = state.runtimeEpoch;
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.workspaceMcp), {
|
|
28
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
29
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
30
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch, workspaceId }),
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok || state.journal.destroyed() || state.runtimeEpoch !== runtimeEpoch)
|
|
33
|
+
throw new Error('workspace_mcp_unavailable');
|
|
34
|
+
return workspaceMcpConfigSchema.parse(await response.json());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new NativePreparationError('workspace_mcp_unavailable');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -49,7 +49,7 @@ export class WorkspaceModelBroker {
|
|
|
49
49
|
activate();
|
|
50
50
|
return settings;
|
|
51
51
|
}
|
|
52
|
-
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
|
|
52
|
+
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
|
|
53
53
|
if (!this.server)
|
|
54
54
|
throw new Error('workspace_not_registered');
|
|
55
55
|
const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
|
|
@@ -76,7 +76,8 @@ export class WorkspaceModelBroker {
|
|
|
76
76
|
activate: () => {
|
|
77
77
|
if (!this.valid(entry) || this.routes.get(routeKey) !== route)
|
|
78
78
|
throw new Error('model_access_denied');
|
|
79
|
-
route.selected = { entry, model: credential.model
|
|
79
|
+
route.selected = { entry, model: credential.model,
|
|
80
|
+
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
|
|
80
81
|
},
|
|
81
82
|
model: credential.model,
|
|
82
83
|
modelConfigId: credential.configId,
|
|
@@ -199,7 +200,11 @@ export class WorkspaceModelBroker {
|
|
|
199
200
|
try {
|
|
200
201
|
const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
|
|
201
202
|
method: 'POST', redirect: 'error', signal: abort.signal,
|
|
202
|
-
headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json'
|
|
203
|
+
headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json',
|
|
204
|
+
...(selected?.feedback ? {
|
|
205
|
+
'x-playcraft-cloud-session-id': selected.feedback.sessionId,
|
|
206
|
+
'x-playcraft-client-request-id': selected.feedback.clientRequestId,
|
|
207
|
+
} : {}) },
|
|
203
208
|
body: JSON.stringify(body),
|
|
204
209
|
});
|
|
205
210
|
if (upstream.status === 401 || upstream.status === 403)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const NATIVE_APPROVALS_REVIEWER = 'user';
|
|
2
|
+
/** Fixed production/coordinator boundaries may only become narrower. */
|
|
3
|
+
export function executionPolicy(cwd, mode, fixed) {
|
|
4
|
+
const directory = fixed?.cwd ?? cwd;
|
|
5
|
+
if (mode === 'read-only')
|
|
6
|
+
return { cwd: directory, approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } };
|
|
7
|
+
if (mode === 'auto')
|
|
8
|
+
return fixed ?? { cwd, approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } };
|
|
9
|
+
if (fixed?.sandboxPolicy.type === 'readOnly')
|
|
10
|
+
return { ...fixed, sandboxPolicy: { type: 'readOnly', networkAccess: false } };
|
|
11
|
+
return { cwd: directory, approvalPolicy: fixed ? 'never' : 'on-request', sandboxPolicy: {
|
|
12
|
+
type: 'workspaceWrite', writableRoots: fixed?.sandboxPolicy.type === 'workspaceWrite' ? fixed.sandboxPolicy.writableRoots : [directory],
|
|
13
|
+
networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true,
|
|
14
|
+
} };
|
|
15
|
+
}
|
|
16
|
+
export function threadExecutionParams(execution) {
|
|
17
|
+
const sandbox = execution.sandboxPolicy.type === 'readOnly' ? 'read-only'
|
|
18
|
+
: execution.sandboxPolicy.type === 'workspaceWrite' ? 'workspace-write' : 'danger-full-access';
|
|
19
|
+
const policy = execution.sandboxPolicy;
|
|
20
|
+
return { cwd: execution.cwd, approvalPolicy: execution.approvalPolicy, approvalsReviewer: NATIVE_APPROVALS_REVIEWER, sandbox,
|
|
21
|
+
config: policy.type === 'workspaceWrite' ? {
|
|
22
|
+
'sandbox_workspace_write.writable_roots': policy.writableRoots,
|
|
23
|
+
'sandbox_workspace_write.network_access': policy.networkAccess,
|
|
24
|
+
'sandbox_workspace_write.exclude_tmpdir_env_var': policy.excludeTmpdirEnvVar,
|
|
25
|
+
'sandbox_workspace_write.exclude_slash_tmp': policy.excludeSlashTmp,
|
|
26
|
+
} : {} };
|
|
27
|
+
}
|
|
28
|
+
export const APPROVAL_METHODS = new Set([
|
|
29
|
+
'item/commandExecution/requestApproval', 'item/fileChange/requestApproval',
|
|
30
|
+
'item/permissions/requestApproval', 'item/tool/requestUserInput',
|
|
31
|
+
]);
|
|
32
|
+
function record(value) {
|
|
33
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
34
|
+
}
|
|
35
|
+
/** Returns only the native request's permissions; a browser cannot supply new paths or network access. */
|
|
36
|
+
export function nativeApprovalResponse(method, request, response, canEscalate) {
|
|
37
|
+
if (method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval') {
|
|
38
|
+
if (response.kind !== 'decision' || (response.accepted && !canEscalate))
|
|
39
|
+
return undefined;
|
|
40
|
+
const decision = response.accepted ? 'accept' : Array.isArray(request.availableDecisions)
|
|
41
|
+
&& !request.availableDecisions.includes('decline') && request.availableDecisions.includes('cancel') ? 'cancel' : 'decline';
|
|
42
|
+
if (Array.isArray(request.availableDecisions) && !request.availableDecisions.includes(decision))
|
|
43
|
+
return undefined;
|
|
44
|
+
return { decision };
|
|
45
|
+
}
|
|
46
|
+
if (method === 'item/permissions/requestApproval') {
|
|
47
|
+
if (response.kind !== 'permissions' || (response.accepted && !canEscalate))
|
|
48
|
+
return undefined;
|
|
49
|
+
const requested = record(request.permissions);
|
|
50
|
+
if (!request.permissions || Object.keys(requested).some(key => !['network', 'fileSystem'].includes(key)))
|
|
51
|
+
return undefined;
|
|
52
|
+
return { permissions: response.accepted ? Object.fromEntries(Object.entries(requested).filter(([, value]) => value != null)) : {}, scope: response.scope };
|
|
53
|
+
}
|
|
54
|
+
if (method !== 'item/tool/requestUserInput' || response.kind !== 'user-input' || !Array.isArray(request.questions) || !request.questions.length)
|
|
55
|
+
return undefined;
|
|
56
|
+
const questions = request.questions.map(record);
|
|
57
|
+
const ids = questions.map(question => question.id);
|
|
58
|
+
if (ids.some(id => typeof id !== 'string' || !id) || new Set(ids).size !== ids.length
|
|
59
|
+
|| Object.keys(response.answers).length !== ids.length || Object.keys(response.answers).some(id => !ids.includes(id)))
|
|
60
|
+
return undefined;
|
|
61
|
+
for (const question of questions) {
|
|
62
|
+
const answers = response.answers[question.id]?.answers;
|
|
63
|
+
if (!answers || answers.length !== 1 || !answers[0]?.trim())
|
|
64
|
+
return undefined;
|
|
65
|
+
const options = Array.isArray(question.options) ? question.options.map(record) : [];
|
|
66
|
+
if (options.length && question.isOther !== true && !options.some(option => option.label === answers[0]))
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return { answers: response.answers };
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function workModeExecution(execution, workMode) {
|
|
2
|
+
return workMode === 'plan' ? {
|
|
3
|
+
...execution, approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false },
|
|
4
|
+
} : execution;
|
|
5
|
+
}
|
|
6
|
+
export function nativeCollaborationMode(model, effort, workMode) {
|
|
7
|
+
return {
|
|
8
|
+
mode: workMode === 'plan' ? 'plan' : 'default',
|
|
9
|
+
settings: {
|
|
10
|
+
model,
|
|
11
|
+
reasoning_effort: effort === 'max' ? 'xhigh' : effort ?? null,
|
|
12
|
+
developer_instructions: null,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -132,13 +132,13 @@ function remember(database, childId, parentId, owner, metadata, confirmed = true
|
|
|
132
132
|
return true;
|
|
133
133
|
}
|
|
134
134
|
function ownsOperation(session, rootThreadId, found) {
|
|
135
|
-
return Boolean(found &&
|
|
135
|
+
return Boolean(found && ['session.turn', 'session.review', 'session.compact'].includes(found.operation.kind) && found.operation.sessionId === session.sessionId &&
|
|
136
136
|
(found.operation.workspaceId === undefined || found.operation.workspaceId === session.workspaceId) && found.operation.nativeThreadId === rootThreadId);
|
|
137
137
|
}
|
|
138
138
|
function readOperation(database, column, value, threadId) {
|
|
139
139
|
if (!value)
|
|
140
140
|
return undefined;
|
|
141
|
-
const row = database.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE ${column} = ?${threadId ? ' AND thread_id = ?' : ''}`)
|
|
141
|
+
const row = database.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE ${column} = ?${threadId ? ' AND thread_id = ?' : ''}${column === 'turn_id' ? " AND json_extract(operation_json, '$.kind') IN ('session.turn', 'session.review', 'session.compact')" : ''}`)
|
|
142
142
|
.get(...(threadId ? [value, threadId] : [value]));
|
|
143
143
|
if (!row)
|
|
144
144
|
return undefined;
|
|
@@ -329,13 +329,13 @@ function quarantineReservedTurns(database) {
|
|
|
329
329
|
if (!parsed.success)
|
|
330
330
|
continue;
|
|
331
331
|
const operation = parsed.data;
|
|
332
|
-
if (
|
|
332
|
+
if (!['session.turn', 'session.review', 'session.compact', 'session.steer'].includes(operation.kind) || operation.status !== 'dispatched')
|
|
333
333
|
continue;
|
|
334
|
-
if (!operation.nativeRequestId || operation.nativeTurnId)
|
|
334
|
+
if (!operation.nativeRequestId || (operation.kind !== 'session.steer' && operation.nativeTurnId))
|
|
335
335
|
continue;
|
|
336
336
|
const uncertain = transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' });
|
|
337
337
|
putOperation(database, row.scope_key, uncertain);
|
|
338
|
-
if (operation.sessionId) {
|
|
338
|
+
if (operation.sessionId && operation.kind !== 'session.steer') {
|
|
339
339
|
const session = readSession(database, operation.sessionId);
|
|
340
340
|
if (session) {
|
|
341
341
|
upsertRecord(database, 'sessions', operation.sessionId, { ...session, turnBlock: 'uncertain', turnStatus: 'starting' });
|
|
@@ -512,8 +512,9 @@ function convertInboxRow(database, ingress, input) {
|
|
|
512
512
|
const params = input.params ?? JSON.parse(row.payload_json || '{}');
|
|
513
513
|
const payload = linkedPayload(params, input.session, input.subAgent);
|
|
514
514
|
const nativeApprovalId = optionalString(payload.approvalId) || optionalString(payload.itemId) || input.itemId;
|
|
515
|
-
const
|
|
516
|
-
|
|
515
|
+
const approvalRequest = input.method?.includes('requestApproval') || input.method === 'item/tool/requestUserInput';
|
|
516
|
+
const childApproval = input.subAgent && nativeApprovalId && approvalRequest;
|
|
517
|
+
if (input.subAgent && nativeApprovalId && (childApproval || input.method === 'approval.resolved' || input.method === 'serverRequest/resolved')) {
|
|
517
518
|
payload.approvalId = `subagent_${createHash('sha256').update(JSON.stringify([input.subAgent.threadId, nativeApprovalId])).digest('hex')}`;
|
|
518
519
|
}
|
|
519
520
|
const type = input.kind === 'process_exit' ? 'process.exited' : input.method || input.kind;
|
|
@@ -544,16 +545,44 @@ function convertInboxRow(database, ingress, input) {
|
|
|
544
545
|
approvalId: payload.approvalId,
|
|
545
546
|
sessionId: input.operation.operation.sessionId,
|
|
546
547
|
runtimeEpoch: input.runtimeEpoch,
|
|
547
|
-
nativeRequestId: `approval:${nativeApprovalId}`,
|
|
548
|
+
nativeRequestId: optionalString(payload.requestId) ?? `approval:${nativeApprovalId}`,
|
|
548
549
|
nativeApprovalId,
|
|
549
550
|
nativeThreadId: input.subAgent.threadId,
|
|
551
|
+
nativeTurnId: input.turnId,
|
|
552
|
+
rootThreadId: input.subAgent.rootThreadId,
|
|
553
|
+
rootTurnId: input.operation.operation.nativeTurnId,
|
|
554
|
+
method: input.method,
|
|
555
|
+
request: payload,
|
|
550
556
|
status: 'pending',
|
|
551
557
|
});
|
|
552
558
|
}
|
|
559
|
+
settleNativeApprovals(database, input, payload);
|
|
553
560
|
if (!input.subAgent)
|
|
554
561
|
applyNativeToState(database, { ...input, operation: resolveOperation(database, input, payload) }, payload);
|
|
555
562
|
return event;
|
|
556
563
|
}
|
|
564
|
+
/** An ended native request must never reappear as a pending approval after reconnect. */
|
|
565
|
+
function settleNativeApprovals(database, input, payload) {
|
|
566
|
+
const resolved = input.method === 'approval.resolved' || input.method === 'serverRequest/resolved';
|
|
567
|
+
const terminal = Boolean(terminalTurn(input.method ?? '', payload));
|
|
568
|
+
if (!resolved && !terminal && input.kind !== 'process_exit')
|
|
569
|
+
return;
|
|
570
|
+
const rows = database.prepare("SELECT record_key, body_json FROM records WHERE bucket = 'approvals'").all();
|
|
571
|
+
for (const row of rows) {
|
|
572
|
+
const approval = asRecord(JSON.parse(row.body_json));
|
|
573
|
+
if (approval.status !== 'pending')
|
|
574
|
+
continue;
|
|
575
|
+
const matchesThread = approval.nativeThreadId === input.threadId;
|
|
576
|
+
const matchesRequest = (payload.approvalId && approval.approvalId === payload.approvalId)
|
|
577
|
+
|| (payload.requestId !== undefined && String(approval.nativeRequestId) === String(payload.requestId));
|
|
578
|
+
const endedParent = terminal && approval.rootThreadId === input.threadId && approval.rootTurnId === input.turnId;
|
|
579
|
+
if (input.kind !== 'process_exit' && !endedParent && (!matchesThread || (resolved ? !matchesRequest
|
|
580
|
+
: input.turnId && approval.nativeTurnId && approval.nativeTurnId !== input.turnId)))
|
|
581
|
+
continue;
|
|
582
|
+
upsertRecord(database, 'approvals', row.record_key, { ...approval,
|
|
583
|
+
status: input.method === 'approval.resolved' ? payload.accepted === false ? 'rejected' : 'approved' : 'expired' });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
557
586
|
function applyRecordedTurn(database, threadId, turnId) {
|
|
558
587
|
const rows = database
|
|
559
588
|
.prepare(`SELECT type, payload_json, thread_id, session_id FROM source_events
|
|
@@ -561,7 +590,7 @@ function applyRecordedTurn(database, threadId, turnId) {
|
|
|
561
590
|
ORDER BY source_sequence`)
|
|
562
591
|
.all(threadId, turnId, `%"${turnId}"%`);
|
|
563
592
|
for (const row of rows) {
|
|
564
|
-
if (!row.type.includes('requestApproval') && !row.type.includes('requestUserInput') && !row.type.includes('completed') && !row.type.includes('interrupted') && row.type !== 'turn.failed' && row.type !== 'approval.resolved') {
|
|
593
|
+
if (!row.type.includes('requestApproval') && !row.type.includes('requestUserInput') && !row.type.includes('completed') && !row.type.includes('interrupted') && row.type !== 'turn.failed' && row.type !== 'approval.resolved' && row.type !== 'serverRequest/resolved') {
|
|
565
594
|
continue;
|
|
566
595
|
}
|
|
567
596
|
const payload = asRecord(JSON.parse(row.payload_json));
|
|
@@ -586,14 +615,19 @@ function resolveOperation(database, input, payload) {
|
|
|
586
615
|
}
|
|
587
616
|
function applyNativeToState(database, input, payload) {
|
|
588
617
|
const method = input.method ?? '';
|
|
618
|
+
settleNativeApprovals(database, input, payload);
|
|
589
619
|
const session = input.session;
|
|
590
620
|
if (session?.sessionId) {
|
|
591
621
|
const next = { ...session };
|
|
592
622
|
const approval = method.includes('requestApproval') || method.includes('requestUserInput');
|
|
593
623
|
if (approval)
|
|
594
624
|
next.turnStatus = moveTurn(String(session.turnStatus ?? 'running'), 'waiting_for_user');
|
|
595
|
-
else if (method === 'approval.resolved')
|
|
596
|
-
|
|
625
|
+
else if (method === 'approval.resolved' || method === 'serverRequest/resolved') {
|
|
626
|
+
const waiting = database.prepare("SELECT 1 FROM records WHERE bucket = 'approvals' AND json_extract(body_json, '$.sessionId') = ? AND json_extract(body_json, '$.status') = 'pending' AND json_extract(body_json, '$.nativeThreadId') = ? LIMIT 1")
|
|
627
|
+
.get(session.sessionId, session.threadId);
|
|
628
|
+
if (!waiting)
|
|
629
|
+
next.turnStatus = moveTurn(String(session.turnStatus ?? 'waiting_for_user'), 'running');
|
|
630
|
+
}
|
|
597
631
|
else if (method.includes('delta') || method === 'turn/started' || method === 'turn.started') {
|
|
598
632
|
const current = String(session.turnStatus ?? 'starting');
|
|
599
633
|
if (current !== 'waiting_for_user')
|
|
@@ -618,7 +652,12 @@ function applyNativeToState(database, input, payload) {
|
|
|
618
652
|
approvalId,
|
|
619
653
|
sessionId: operation.sessionId,
|
|
620
654
|
runtimeEpoch: operation.runtimeEpoch ?? 0,
|
|
621
|
-
nativeRequestId:
|
|
655
|
+
nativeRequestId: optionalString(payload.requestId) ?? `approval:${approvalId}`,
|
|
656
|
+
nativeThreadId: input.threadId,
|
|
657
|
+
nativeTurnId: input.turnId,
|
|
658
|
+
nativeApprovalId: approvalId,
|
|
659
|
+
method,
|
|
660
|
+
request: payload,
|
|
622
661
|
status: 'pending',
|
|
623
662
|
});
|
|
624
663
|
operation = operationRecordSchema.parse({
|
|
@@ -902,7 +941,7 @@ function sessionByThread(database, threadId) {
|
|
|
902
941
|
}
|
|
903
942
|
function operationByTurn(database, turnId, threadId) {
|
|
904
943
|
const row = database
|
|
905
|
-
.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?${threadId ? ' AND thread_id = ?' : ''}`)
|
|
944
|
+
.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?${threadId ? ' AND thread_id = ?' : ''} AND json_extract(operation_json, '$.kind') IN ('session.turn', 'session.review', 'session.compact')`)
|
|
906
945
|
.get(...(threadId ? [turnId, threadId] : [turnId]));
|
|
907
946
|
if (!row)
|
|
908
947
|
return undefined;
|
|
@@ -6,6 +6,7 @@ import { ingestCollected, publishSourceEvents, recordNativeEvents } from '../ser
|
|
|
6
6
|
import { verifyProductionLease } from './storage.js';
|
|
7
7
|
import { processBirth, stopProductionGroup } from './processes.js';
|
|
8
8
|
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
9
|
+
import { loadWorkspaceMcpConfig } from '../codex/mcp-config.js';
|
|
9
10
|
export async function runProductionNative(state, input, root, record, track) {
|
|
10
11
|
if (!input.model)
|
|
11
12
|
throw new Error('production_model_configuration_required');
|
|
@@ -25,6 +26,7 @@ export async function runProductionNative(state, input, root, record, track) {
|
|
|
25
26
|
homeDir: process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
26
27
|
? `/var/lib/playcraft/codex/production-${input.task.attemptId}` : join(state.config.stateDir, 'production-native', input.task.attemptId),
|
|
27
28
|
model: input.model.model, processGroup: true,
|
|
29
|
+
loadMcpConfig: () => loadWorkspaceMcpConfig(state, input.task.workspaceId),
|
|
28
30
|
fixedExecution: { cwd: root, approvalPolicy: 'never', sandboxPolicy: { type: 'workspaceWrite', writableRoots: [root],
|
|
29
31
|
networkAccess: true, excludeTmpdirEnvVar: true, excludeSlashTmp: true } },
|
|
30
32
|
fixedThreadConfig: { model: input.model.model, modelProvider: 'playcraft_production', config: {
|
|
@@ -22,3 +22,14 @@ git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save gen
|
|
|
22
22
|
git -c protocol.version=0 -c core.hooksPath=/dev/null -c remote.origin.url="$PRODUCTION_REPOSITORY" -c remote.origin.pushurl="$PRODUCTION_REPOSITORY" push --set-upstream origin HEAD:"refs/heads/$PRODUCTION_BRANCH"
|
|
23
23
|
git rev-parse HEAD`;
|
|
24
24
|
}
|
|
25
|
+
/** Commits generated engineering locally inside the sandbox without pushing a branch to remote origin. */
|
|
26
|
+
export function productionLocalCommitCommand() {
|
|
27
|
+
return `set -eu
|
|
28
|
+
git -c core.hooksPath=/dev/null -c core.fsmonitor=false add -A .
|
|
29
|
+
if [ -n "$(git ls-files -- .playcraft.json playcraft.config.json ':(glob)**/.playcraft.json' ':(glob)**/playcraft.config.json')" ]; then
|
|
30
|
+
printf 'production_git_private_path\\n' >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save generated engineering"
|
|
34
|
+
git rev-parse HEAD`;
|
|
35
|
+
}
|
|
@@ -10,7 +10,7 @@ import { registerWorkspaceDirectory } from '../workspaces/directories.js';
|
|
|
10
10
|
import { workspaceContainerRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
11
11
|
import { excludeFromGit, settleDependencyInstalls } from '../workspaces/dependencies.js';
|
|
12
12
|
import { resolveProductionDependencies, verifyProductionDependencyLocks } from './dependencies.js';
|
|
13
|
-
import {
|
|
13
|
+
import { productionLocalCommitCommand } from './save-command.js';
|
|
14
14
|
import { productionOutputStore, verifyProductionLease } from './storage.js';
|
|
15
15
|
import { runProductionCommand } from './processes.js';
|
|
16
16
|
import { runProductionNative } from './native.js';
|
|
@@ -105,11 +105,9 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
105
105
|
return { generated: true };
|
|
106
106
|
}
|
|
107
107
|
if (stage === 'save') {
|
|
108
|
-
if (!input.git)
|
|
109
|
-
throw new Error('production_git_scope_required');
|
|
110
108
|
const binding = state.journal.getWorkspace(workspaceId);
|
|
111
109
|
const branch = input.directoryName === 'basic' ? binding.branchName : binding.directories?.find(value => value.directoryName === input.directoryName)?.branchName;
|
|
112
|
-
if (input.git.branchName !== branch || await git(root, 'symbolic-ref', '--short', 'HEAD') !== branch)
|
|
110
|
+
if ((input.git && input.git.branchName !== branch) || await git(root, 'symbolic-ref', '--short', 'HEAD') !== branch)
|
|
113
111
|
throw new Error('production_git_binding_mismatch');
|
|
114
112
|
await excludeFromGit(root);
|
|
115
113
|
await excludeFromGit(root, '/.env*');
|
|
@@ -117,15 +115,8 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
117
115
|
await excludeFromGit(root, '/.playcraft-preview-out/');
|
|
118
116
|
await git(root, 'config', 'user.name', 'PlayCraft');
|
|
119
117
|
await git(root, 'config', 'user.email', 'playcraft@localhost');
|
|
120
|
-
const env = {
|
|
121
|
-
|
|
122
|
-
if (input.git.username && input.git.password)
|
|
123
|
-
Object.assign(env, { GIT_CONFIG_COUNT: '1',
|
|
124
|
-
GIT_CONFIG_KEY_0: `http.${input.git.repositoryUrl}.extraHeader`,
|
|
125
|
-
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${input.git.username}:${input.git.password}`).toString('base64')}` });
|
|
126
|
-
if (typeof input.payload.expectedParent === 'string')
|
|
127
|
-
env.PRODUCTION_EXPECTED_PARENT = input.payload.expectedParent;
|
|
128
|
-
await run(root, productionSaveCommand(), env, true);
|
|
118
|
+
const env = { GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
|
|
119
|
+
await run(root, productionLocalCommitCommand(), env, true);
|
|
129
120
|
const commitHash = await git(root, 'rev-parse', 'HEAD');
|
|
130
121
|
const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-save` });
|
|
131
122
|
return { snapshot, saved: { attemptId: input.task.attemptId, resultVersionId: resultId, workspaceId, sourceDigest: snapshot.checksum, commitHash, pushedCommitHash: commitHash } };
|
|
@@ -146,8 +137,17 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
146
137
|
if (!snapshot || snapshot.commitHash !== saved.commitHash || snapshot.checksum !== saved.sourceDigest)
|
|
147
138
|
throw new Error('production_saved_snapshot_mismatch');
|
|
148
139
|
const destination = join(root, '.playcraft-temp', `production-${input.task.attemptId}`);
|
|
140
|
+
const removeDestination = async () => {
|
|
141
|
+
try {
|
|
142
|
+
await rm(destination, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
await run(destination, 'chmod -R u+rwX,g+rwX .').catch(() => undefined);
|
|
146
|
+
await rm(destination, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
149
|
await excludeFromGit(root, '/.playcraft-temp/');
|
|
150
|
-
await
|
|
150
|
+
await removeDestination();
|
|
151
151
|
try {
|
|
152
152
|
await checkoutBatchCommit({ source: { workspaceId, root }, frozen: snapshot, destination });
|
|
153
153
|
await openDirectoryToDevelopment(destination, (await lstat(state.config.workspaceRoot)).gid);
|
|
@@ -174,7 +174,7 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
174
174
|
sourceDigest: snapshot.checksum, commitHash: snapshot.commitHash, buildId: `${input.task.attemptId}-build`, succeeded: true } };
|
|
175
175
|
}
|
|
176
176
|
finally {
|
|
177
|
-
await
|
|
177
|
+
await removeDestination();
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
if (stage === 'preview') {
|
|
@@ -190,7 +190,7 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
190
190
|
try {
|
|
191
191
|
const browser = process.env.PLAYCRAFT_PRODUCTION_PREVIEW_COMMAND ?? 'chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=1000 --dump-dom';
|
|
192
192
|
const opened = await run(root, `${browser} ${quote(`file://${previewPath}`)}`);
|
|
193
|
-
if (
|
|
193
|
+
if (!/<\/?html[\s>]/i.test(opened))
|
|
194
194
|
throw new Error('production_preview_not_opened');
|
|
195
195
|
return { opened: true, outputDigest: built.outputDigest };
|
|
196
196
|
}
|
|
@@ -20,6 +20,7 @@ import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
|
|
|
20
20
|
import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
|
|
21
21
|
import { workspaceContextFromRoot } from './workspaces/registry.js';
|
|
22
22
|
import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
23
|
+
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
23
24
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
24
25
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
25
26
|
const negotiated = negotiateProtocol(config);
|
|
@@ -191,6 +192,10 @@ function defaultNativeAdapter(state) {
|
|
|
191
192
|
return createCodexAppServerAdapter({
|
|
192
193
|
config,
|
|
193
194
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
195
|
+
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
196
|
+
const { workspaceId } = await workspaceContextFromRoot(state, cwd);
|
|
197
|
+
return loadWorkspaceMcpConfig(state, workspaceId);
|
|
198
|
+
} } : {}),
|
|
194
199
|
loadSkillArtifact: async (cwd, entry) => {
|
|
195
200
|
const { workspaceId, directoryName } = await workspaceContextFromRoot(state, cwd);
|
|
196
201
|
return loadWorkspaceSkillArtifact(state, workspaceId, entry, directoryName === 'root' ? 'basic' : directoryName);
|