@playcraft/cli 0.1.56-beta → 0.1.58-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/client.js +6 -4
- package/dist/commands/tools-3d.js +15 -0
- package/dist/commands/tools-generation.js +3 -2
- package/dist/commands/workspace.js +43 -0
- package/dist/utils/agent-api-client.js +31 -8
- package/dist/utils/agent-generation-provenance.js +45 -0
- package/dist/utils/generation-workspace-scope.js +50 -0
- package/dist/utils/image-generation-retry.js +7 -0
- package/dist/utils/tool-operation-journal.js +34 -4
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/prepare.js +109 -137
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
- package/dist/workspace-runtime/codex/launcher-client.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +23 -4
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +19 -32
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
- package/project-template-v2/.playcraft/skills.lock.json +15 -8
- package/templates/preview-runtime/v1/README.md +4 -0
- package/templates/preview-runtime/v1/manifest.json +6 -6
- package/templates/preview-runtime/v1/runtime.mjs +368 -22
- package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
- package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
|
@@ -6,6 +6,7 @@ import { basename, dirname } from 'node:path';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
8
|
import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
|
+
import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
|
|
9
10
|
const credentialSchema = z.object({
|
|
10
11
|
token: z.string().min(1), expiresAt: z.string().datetime(),
|
|
11
12
|
configId: z.string().uuid(), model: z.string().min(1).max(128),
|
|
@@ -25,8 +26,10 @@ export class WorkspaceModelBroker {
|
|
|
25
26
|
pending = new Map();
|
|
26
27
|
requests = new Set();
|
|
27
28
|
sessionRequests = new Map();
|
|
29
|
+
generation;
|
|
28
30
|
constructor(state) {
|
|
29
31
|
this.state = state;
|
|
32
|
+
this.generation = new WorkspaceGenerationProvenanceBroker(state);
|
|
30
33
|
}
|
|
31
34
|
async start() {
|
|
32
35
|
if (this.server)
|
|
@@ -67,8 +70,10 @@ export class WorkspaceModelBroker {
|
|
|
67
70
|
}
|
|
68
71
|
// Resolve current model metadata and permission at every native turn boundary.
|
|
69
72
|
const credential = await this.credential(entry, true);
|
|
73
|
+
const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
|
|
70
74
|
if (!route || route.epoch !== this.state.runtimeEpoch) {
|
|
71
|
-
route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url')
|
|
75
|
+
route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
|
|
76
|
+
generationCapability: randomBytes(32).toString('base64url') };
|
|
72
77
|
this.routes.set(routeKey, route);
|
|
73
78
|
}
|
|
74
79
|
// Each session pins its prepared model; shared credential renewal cannot change an active turn.
|
|
@@ -77,12 +82,16 @@ export class WorkspaceModelBroker {
|
|
|
77
82
|
if (!this.valid(entry) || this.routes.get(routeKey) !== route)
|
|
78
83
|
throw new Error('model_access_denied');
|
|
79
84
|
route.selected = { entry, model: credential.model,
|
|
80
|
-
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
|
|
85
|
+
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
|
|
81
86
|
},
|
|
82
87
|
model: credential.model,
|
|
83
88
|
modelConfigId: credential.configId,
|
|
84
89
|
modelProvider: 'playcraft_workspace',
|
|
85
90
|
config: {
|
|
91
|
+
...(sessionId ? { 'shell_environment_policy.set': {
|
|
92
|
+
PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
|
|
93
|
+
PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
|
|
94
|
+
} } : {}),
|
|
86
95
|
'model_providers.playcraft_workspace': {
|
|
87
96
|
name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
|
|
88
97
|
wire_api: 'responses', requires_openai_auth: false,
|
|
@@ -142,18 +151,28 @@ export class WorkspaceModelBroker {
|
|
|
142
151
|
const cancel = () => abort.abort();
|
|
143
152
|
response.once('close', cancel);
|
|
144
153
|
try {
|
|
145
|
-
const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/responses$/.exec(request.url ?? '');
|
|
154
|
+
const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
|
|
146
155
|
const route = match ? this.routes.get(match[1]) : undefined;
|
|
147
156
|
const selected = route?.selected;
|
|
148
157
|
const entry = selected?.entry;
|
|
149
158
|
const presented = Buffer.from(request.headers.authorization ?? '');
|
|
150
|
-
const
|
|
159
|
+
const generationRequest = match?.[2] === 'generation-provenance';
|
|
160
|
+
const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability : route.capability}` : '');
|
|
151
161
|
if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
|
|
152
162
|
|| !timingSafeEqual(presented, expected) || !this.valid(entry)) {
|
|
153
163
|
response.writeHead(403);
|
|
154
164
|
response.end();
|
|
155
165
|
return;
|
|
156
166
|
}
|
|
167
|
+
if (generationRequest) {
|
|
168
|
+
if (!selected?.feedback) {
|
|
169
|
+
response.writeHead(403);
|
|
170
|
+
response.end();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
157
176
|
activeRoute = route;
|
|
158
177
|
const active = this.sessionRequests.get(route) ?? new Set();
|
|
159
178
|
active.add(abort);
|
|
@@ -11,6 +11,7 @@ export class RuntimeJournal {
|
|
|
11
11
|
legacyPath;
|
|
12
12
|
client;
|
|
13
13
|
listeners = new Set();
|
|
14
|
+
changeListeners = new Set();
|
|
14
15
|
maintenance;
|
|
15
16
|
engineInfo;
|
|
16
17
|
epochs = [];
|
|
@@ -40,11 +41,13 @@ export class RuntimeJournal {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
async load() {
|
|
44
|
+
// private
|
|
43
45
|
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
44
46
|
const legacy = await readLegacy(this.legacyPath, this.filePath);
|
|
45
47
|
const started = await SqliteWorkerClient.open(this.filePath, legacy?.value ?? null);
|
|
46
48
|
this.client = started.client;
|
|
47
49
|
this.apply(started.opened);
|
|
50
|
+
// private
|
|
48
51
|
if (legacy?.readable)
|
|
49
52
|
await writeFile(this.legacyPath, LEGACY_JOURNAL_SENTINEL, { mode: 0o600 });
|
|
50
53
|
this.maintenance = setInterval(() => {
|
|
@@ -76,6 +79,33 @@ export class RuntimeJournal {
|
|
|
76
79
|
this.listeners.add(listener);
|
|
77
80
|
return () => this.listeners.delete(listener);
|
|
78
81
|
}
|
|
82
|
+
onChange(listener) {
|
|
83
|
+
this.changeListeners.add(listener);
|
|
84
|
+
return () => this.changeListeners.delete(listener);
|
|
85
|
+
}
|
|
86
|
+
directoryBarriers() { return Object.values(this.snapshot.directoryBarriers); }
|
|
87
|
+
async putDirectoryBarrier(barrier) {
|
|
88
|
+
await this.run('upsertRecord', { bucket: 'directory_barriers', key: barrier.owner,
|
|
89
|
+
body: { ...barrier, queuedAt: this.snapshot.directoryBarriers[barrier.owner]?.queuedAt ?? barrier.queuedAt ?? new Date().toISOString() } });
|
|
90
|
+
}
|
|
91
|
+
async deleteDirectoryBarrier(owner) {
|
|
92
|
+
await this.run('deleteRecord', { bucket: 'directory_barriers', key: owner });
|
|
93
|
+
}
|
|
94
|
+
deferredDirectoryOperations() { return Object.values(this.snapshot.deferredDirectoryOperations); }
|
|
95
|
+
async deferDirectoryOperation(input) {
|
|
96
|
+
await this.run('upsertRecord', { bucket: 'directory_deferred_operations', key: input.operationId,
|
|
97
|
+
body: { ...input, queuedAt: this.snapshot.deferredDirectoryOperations[input.operationId]?.queuedAt ?? input.queuedAt ?? new Date().toISOString() } });
|
|
98
|
+
}
|
|
99
|
+
/** The accepted command and its replayable input commit together before any writer can start. */
|
|
100
|
+
async putPendingOperation(scopeKey, operation, payload, recovery) {
|
|
101
|
+
const parsed = operationRecordSchema.parse(operation);
|
|
102
|
+
await this.run('putPendingOperation', { scopeKey, operation: parsed,
|
|
103
|
+
pending: { operationId: parsed.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}), queuedAt: parsed.createdAt } });
|
|
104
|
+
return this.snapshot.operations[scopeKey];
|
|
105
|
+
}
|
|
106
|
+
async deleteDeferredDirectoryOperation(operationId) {
|
|
107
|
+
await this.run('deleteRecord', { bucket: 'directory_deferred_operations', key: operationId });
|
|
108
|
+
}
|
|
79
109
|
getWorkspace(workspaceId) {
|
|
80
110
|
return this.snapshot.workspaces[workspaceId];
|
|
81
111
|
}
|
|
@@ -288,6 +318,8 @@ export class RuntimeJournal {
|
|
|
288
318
|
throw new Error('sqlite_not_open');
|
|
289
319
|
const result = await this.client.request(type, payload);
|
|
290
320
|
this.apply(result);
|
|
321
|
+
for (const listener of this.changeListeners)
|
|
322
|
+
listener();
|
|
291
323
|
for (const event of result.committedEvents ?? []) {
|
|
292
324
|
for (const listener of this.listeners)
|
|
293
325
|
listener(event);
|
|
@@ -356,6 +388,8 @@ function emptySnapshot() {
|
|
|
356
388
|
fileRevisions: {},
|
|
357
389
|
approvals: {},
|
|
358
390
|
managedWrites: {},
|
|
391
|
+
directoryBarriers: {},
|
|
392
|
+
deferredDirectoryOperations: {},
|
|
359
393
|
workspaceDependencies: {},
|
|
360
394
|
executionGroup: { stopConfirmed: false, replaced: false },
|
|
361
395
|
lastSourceSequence: 0,
|
|
@@ -64,6 +64,15 @@ function dispatch(type, payload) {
|
|
|
64
64
|
case 'putOperation':
|
|
65
65
|
putOperation(database, String(body.scopeKey), body.operation);
|
|
66
66
|
return;
|
|
67
|
+
case 'putPendingOperation': {
|
|
68
|
+
const operation = operationRecordSchema.parse(body.operation);
|
|
69
|
+
const pending = asRecord(body.pending);
|
|
70
|
+
if (operation.status !== 'queued' || pending.operationId !== operation.operationId || pending.scopeKey !== body.scopeKey)
|
|
71
|
+
throw new Error('invalid_pending_operation');
|
|
72
|
+
putOperation(database, String(body.scopeKey), operation);
|
|
73
|
+
upsertRecord(database, 'directory_deferred_operations', operation.operationId, pending);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
67
76
|
case 'setAcceptedFence':
|
|
68
77
|
setMeta(database, 'accepted_lease_fence', String(Math.max(metaNumber(database, 'accepted_lease_fence'), Number(body.value) || 0)));
|
|
69
78
|
return;
|
|
@@ -158,6 +167,8 @@ function openDatabase(payload) {
|
|
|
158
167
|
ON native_inbox(thread_id, ingress_sequence) WHERE converted = 0;
|
|
159
168
|
CREATE INDEX IF NOT EXISTS records_native_session_thread
|
|
160
169
|
ON records(json_extract(body_json, '$.threadId')) WHERE bucket = 'sessions';
|
|
170
|
+
CREATE INDEX IF NOT EXISTS operation_intents_sandbox_operation
|
|
171
|
+
ON operation_intents(sandbox_operation_id);
|
|
161
172
|
CREATE INDEX IF NOT EXISTS native_inbox_pending_parent
|
|
162
173
|
ON native_inbox(COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
|
|
163
174
|
json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')))
|
|
@@ -353,7 +364,7 @@ function allocateEvent(database, body) {
|
|
|
353
364
|
WHERE runtime_id = ? AND generation = ? AND runtime_epoch = ?
|
|
354
365
|
RETURNING next_sequence`)
|
|
355
366
|
.get(runtimeId, generation, runtimeEpoch);
|
|
356
|
-
const payload = body
|
|
367
|
+
const payload = operationEventPayload(database, body);
|
|
357
368
|
const event = pendingSourceEventSchema.parse({
|
|
358
369
|
source: { runtimeId, generation, runtimeEpoch, sourceSequence: allocated.next_sequence },
|
|
359
370
|
payloadHash: hashOperationPayload(payload),
|
|
@@ -832,6 +843,8 @@ function dump(database, includeEvents = true) {
|
|
|
832
843
|
fileRevisions: bucketObject(database, 'file_revisions'),
|
|
833
844
|
approvals: bucketObject(database, 'approvals'),
|
|
834
845
|
managedWrites: bucketObject(database, 'managed_writes'),
|
|
846
|
+
directoryBarriers: bucketObject(database, 'directory_barriers'),
|
|
847
|
+
deferredDirectoryOperations: bucketObject(database, 'directory_deferred_operations'),
|
|
835
848
|
workspaceDependencies: bucketObject(database, 'workspace_dependencies'),
|
|
836
849
|
executionGroup: executionGroup(database),
|
|
837
850
|
stoppedRuntimeEpochs: stoppedExecutionEpochs(database, metaString(database, 'runtime_id'), metaNumber(database, 'generation')),
|
|
@@ -878,7 +891,16 @@ function operationsObject(database) {
|
|
|
878
891
|
return operations;
|
|
879
892
|
}
|
|
880
893
|
function putOperation(database, scopeKey, value) {
|
|
881
|
-
|
|
894
|
+
let operation = operationRecordSchema.parse(value);
|
|
895
|
+
if (operation.status !== 'queued')
|
|
896
|
+
database.prepare("DELETE FROM records WHERE bucket = 'directory_deferred_operations' AND record_key = ?").run(operation.operationId);
|
|
897
|
+
const existing = database.prepare('SELECT gateway_operation_id FROM operation_intents WHERE scope_key = ?')
|
|
898
|
+
.get(scopeKey);
|
|
899
|
+
if (existing?.gateway_operation_id) {
|
|
900
|
+
if (operation.gatewayOperationId && operation.gatewayOperationId !== existing.gateway_operation_id)
|
|
901
|
+
throw new Error('gateway_operation_mismatch');
|
|
902
|
+
operation = { ...operation, gatewayOperationId: existing.gateway_operation_id };
|
|
903
|
+
}
|
|
882
904
|
if (metaString(database, 'runtime_id') && operation.generation === metaNumber(database, 'generation') && operation.runtimeEpoch === metaNumber(database, 'runtime_epoch')) {
|
|
883
905
|
const key = JSON.stringify([scopeKey, operation.operationId]);
|
|
884
906
|
database.prepare("INSERT INTO records (bucket, record_key, body_json) VALUES ('operation_execution_scopes', ?, ?) ON CONFLICT DO NOTHING")
|
|
@@ -898,7 +920,7 @@ function putOperation(database, scopeKey, value) {
|
|
|
898
920
|
thread_id = excluded.thread_id,
|
|
899
921
|
turn_id = excluded.turn_id,
|
|
900
922
|
reconcile_status = excluded.reconcile_status`)
|
|
901
|
-
.run(scopeKey, JSON.stringify(operation), null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
|
|
923
|
+
.run(scopeKey, JSON.stringify(operation), operation.gatewayOperationId ?? null, operation.sandboxOperationId ?? operation.operationId, operation.payloadHash, operation.nativeRequestId ?? null, operation.nativeThreadId ?? null, operation.nativeTurnId ?? null, operation.status);
|
|
902
924
|
}
|
|
903
925
|
function insertEventRow(database, event, links) {
|
|
904
926
|
database
|
|
@@ -1017,17 +1039,57 @@ function operationByTurn(database, turnId, threadId) {
|
|
|
1017
1039
|
}
|
|
1018
1040
|
function linkedPayload(params, session, subAgent) {
|
|
1019
1041
|
const payload = { ...asRecord(params) };
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1042
|
+
// Native notifications cannot assert the platform identity or override durable ownership.
|
|
1043
|
+
for (const key of ['operationId', 'gatewayOperationId', 'platformOperationId', 'sandboxOperationId',
|
|
1044
|
+
'workspaceId', 'sessionId', 'directoryName', 'runtimeId', 'generation', 'runtimeEpoch', 'userId', 'environment', 'execution', 'subAgent',
|
|
1045
|
+
'directoryAccessMode', 'dispatchAttempted'])
|
|
1046
|
+
delete payload[key];
|
|
1023
1047
|
if (typeof session?.workspaceId === 'string')
|
|
1024
1048
|
payload.workspaceId = session.workspaceId;
|
|
1025
1049
|
if (typeof session?.sessionId === 'string')
|
|
1026
1050
|
payload.sessionId = session.sessionId;
|
|
1051
|
+
if (session)
|
|
1052
|
+
payload.directoryName = optionalString(session.directoryName) ?? 'basic';
|
|
1027
1053
|
if (subAgent)
|
|
1028
1054
|
payload.subAgent = subAgent;
|
|
1029
1055
|
return payload;
|
|
1030
1056
|
}
|
|
1057
|
+
/** Identity enters the immutable event body before hashing, including synthetic stop events. */
|
|
1058
|
+
function operationEventPayload(database, body) {
|
|
1059
|
+
const operationId = optionalString(body.operationId);
|
|
1060
|
+
if (!operationId)
|
|
1061
|
+
return body.payload ?? {};
|
|
1062
|
+
const row = database.prepare('SELECT operation_json FROM operation_intents WHERE sandbox_operation_id = ? LIMIT 1')
|
|
1063
|
+
.get(operationId);
|
|
1064
|
+
if (!row)
|
|
1065
|
+
return body.payload ?? {};
|
|
1066
|
+
const operation = operationRecordSchema.parse(JSON.parse(row.operation_json));
|
|
1067
|
+
if ((operation.generation !== undefined && operation.generation !== Number(body.generation)) ||
|
|
1068
|
+
(operation.runtimeEpoch !== undefined && operation.runtimeEpoch !== Number(body.runtimeEpoch)))
|
|
1069
|
+
return body.payload ?? {};
|
|
1070
|
+
const payload = { ...asRecord(body.payload) };
|
|
1071
|
+
delete payload.gatewayOperationId;
|
|
1072
|
+
delete payload.platformOperationId;
|
|
1073
|
+
payload.operationId = operation.gatewayOperationId ?? operation.operationId;
|
|
1074
|
+
payload.sandboxOperationId = operation.operationId;
|
|
1075
|
+
if (operation.gatewayOperationId)
|
|
1076
|
+
payload.gatewayOperationId = operation.gatewayOperationId;
|
|
1077
|
+
if (operation.workspaceId)
|
|
1078
|
+
payload.workspaceId = operation.workspaceId;
|
|
1079
|
+
if (operation.sessionId)
|
|
1080
|
+
payload.sessionId = operation.sessionId;
|
|
1081
|
+
payload.directoryName = operation.directoryName ?? 'basic';
|
|
1082
|
+
const subAgent = asRecord(payload.subAgent);
|
|
1083
|
+
const threadId = optionalString(body.threadId) ?? nativeIds(payload).threadId ??
|
|
1084
|
+
(Object.keys(subAgent).length ? undefined : operation.nativeThreadId);
|
|
1085
|
+
const turnId = optionalString(body.turnId) ?? nativeIds(payload).turnId ??
|
|
1086
|
+
(Object.keys(subAgent).length ? undefined : operation.nativeTurnId);
|
|
1087
|
+
if (threadId)
|
|
1088
|
+
payload.threadId = threadId;
|
|
1089
|
+
if (turnId)
|
|
1090
|
+
payload.turnId = turnId;
|
|
1091
|
+
return payload;
|
|
1092
|
+
}
|
|
1031
1093
|
function nativeIds(params) {
|
|
1032
1094
|
const record = asRecord(params);
|
|
1033
1095
|
const turn = asRecord(record.turn);
|
|
@@ -8,6 +8,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
|
|
|
8
8
|
let directory = workspace;
|
|
9
9
|
for (const segment of dirname(WORKSPACE_PLAYABLE_OUTPUT_PATH).split('/')) {
|
|
10
10
|
directory = join(directory, segment);
|
|
11
|
+
// shared
|
|
11
12
|
const created = await mkdir(directory, { mode: 0o2775 }).then(() => true, error => {
|
|
12
13
|
if (error.code !== 'EEXIST')
|
|
13
14
|
throw error;
|
|
@@ -22,6 +23,7 @@ export async function saveWorkspacePlayableOutput(root, bytes) {
|
|
|
22
23
|
const target = join(workspace, WORKSPACE_PLAYABLE_OUTPUT_PATH);
|
|
23
24
|
const temporary = join(directory, `.playable-${randomUUID()}.tmp`);
|
|
24
25
|
try {
|
|
26
|
+
// shared
|
|
25
27
|
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o664 });
|
|
26
28
|
await chmod(temporary, 0o664);
|
|
27
29
|
const stat = await lstat(target).catch(error => { if (error.code === 'ENOENT')
|
|
@@ -17,6 +17,7 @@ import { openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
|
17
17
|
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
18
18
|
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
19
19
|
import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
|
|
20
|
+
import { driftScope, permissionDriftFromOutput, repairWorkspaceShares, } from '../workspaces/workspace-shares.js';
|
|
20
21
|
const BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
21
22
|
/** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
|
|
22
23
|
const OUTPUT_FOLDER = '.playcraft-preview-out';
|
|
@@ -60,12 +61,12 @@ export function trackPreviewPublish(state, task) {
|
|
|
60
61
|
export async function settlePreviewPublishes(state) {
|
|
61
62
|
await Promise.all([...(running.get(state) ?? [])]);
|
|
62
63
|
}
|
|
63
|
-
/**
|
|
64
|
+
/** Restart alone does not prove the old builder stopped; retain its exclusive directory claim. */
|
|
64
65
|
export async function recoverInterruptedPreviews(journal) {
|
|
65
66
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
66
67
|
if (operation.kind !== 'preview.publish' || operation.status !== 'accepted')
|
|
67
68
|
continue;
|
|
68
|
-
await journal.putOperation(scopeKey, transitionOperation(operation, '
|
|
69
|
+
await journal.putOperation(scopeKey, transitionOperation(operation, 'uncertain', { error: 'preview_interrupted' }));
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
export async function runPreviewPublish(state, operation, root) {
|
|
@@ -215,7 +216,7 @@ async function ensureDependencies(state, workspaceId, root, output) {
|
|
|
215
216
|
async function buildPreview(state, root, outputDir, operationId, output, onLog, runtimeCommand) {
|
|
216
217
|
// Tests replace the build; the real one needs a project and the published CLI.
|
|
217
218
|
const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
|
|
218
|
-
const
|
|
219
|
+
const run = () => runDeveloperCommand(state, {
|
|
219
220
|
processId: `preview-${operationId}`,
|
|
220
221
|
cwd: root,
|
|
221
222
|
command: runtimeCommand ?? `${planned} --output ${shellQuote(outputDir)} .`,
|
|
@@ -226,8 +227,22 @@ async function buildPreview(state, root, outputDir, operationId, output, onLog,
|
|
|
226
227
|
onLog?.(text);
|
|
227
228
|
},
|
|
228
229
|
});
|
|
229
|
-
|
|
230
|
-
|
|
230
|
+
let outcome = await run();
|
|
231
|
+
if (outcome.code === undefined)
|
|
232
|
+
throw new PreviewFailure('preview_process_unknown');
|
|
233
|
+
if (outcome.code !== 0) {
|
|
234
|
+
// The build runs as the development identity; a directory only that identity can repair makes
|
|
235
|
+
// it fail on its own cwd. Repair the shared subtree as its owner and retry exactly once.
|
|
236
|
+
const drift = permissionDriftFromOutput(output.log);
|
|
237
|
+
if (drift) {
|
|
238
|
+
await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
|
|
239
|
+
outcome = await run();
|
|
240
|
+
if (outcome.code === undefined)
|
|
241
|
+
throw new PreviewFailure('preview_process_unknown');
|
|
242
|
+
}
|
|
243
|
+
if (outcome.code !== 0)
|
|
244
|
+
throw new PreviewFailure(drift ? 'workspace_permission_drift' : 'preview_build_failed');
|
|
245
|
+
}
|
|
231
246
|
}
|
|
232
247
|
/** Reads the one HTML file without following links, so the uploaded bytes are what was checked. */
|
|
233
248
|
async function readSingleHtml(outputDir) {
|
|
@@ -166,6 +166,8 @@ export async function prepareRuntimePreview(state, operation, root) {
|
|
|
166
166
|
dataRevision: before.dataRevision ?? 0,
|
|
167
167
|
schemaRevision: before.schemaRevision ?? 0,
|
|
168
168
|
dataHash: before.dataHash,
|
|
169
|
+
...(before.runtimeI18n ? { runtimeI18nRevision: before.runtimeI18n.themeRevision,
|
|
170
|
+
...(before.runtimeI18n.themeHash ? { runtimeI18nHash: before.runtimeI18n.themeHash } : {}) } : {}),
|
|
169
171
|
},
|
|
170
172
|
};
|
|
171
173
|
const context = { buildId, ...profileBase };
|
|
@@ -187,6 +189,8 @@ export async function prepareRuntimePreview(state, operation, root) {
|
|
|
187
189
|
after.schemaHash !== before.schemaHash ||
|
|
188
190
|
after.dataRevision !== before.dataRevision ||
|
|
189
191
|
after.schemaRevision !== before.schemaRevision ||
|
|
192
|
+
after.runtimeI18n?.themeHash !== before.runtimeI18n?.themeHash ||
|
|
193
|
+
after.runtimeI18n?.themeRevision !== before.runtimeI18n?.themeRevision ||
|
|
190
194
|
digest(await insideFile(root, manifestPath)) !== digest(manifest))
|
|
191
195
|
throw new RuntimePreviewBuildError('runtime_preview_source_changed');
|
|
192
196
|
for (const [path, hash] of gameHashes) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
|
|
3
4
|
export const MANAGED_WRITE_SOURCES = ['manual', 'agent', 'background'];
|
|
4
5
|
const BACKGROUND_WRITER = `
|
|
5
6
|
const fs = require('node:fs');
|
|
@@ -23,16 +24,48 @@ export function probeProcess(pid) {
|
|
|
23
24
|
return undefined;
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
export function spawnBackgroundWriter(absolutePath) {
|
|
27
|
+
export function spawnBackgroundWriter(absolutePath, stopped) {
|
|
27
28
|
const child = spawn(process.execPath, ['-e', BACKGROUND_WRITER, absolutePath], {
|
|
28
29
|
detached: true,
|
|
29
30
|
stdio: 'ignore',
|
|
30
31
|
});
|
|
31
32
|
child.unref();
|
|
33
|
+
child.once('error', () => undefined);
|
|
34
|
+
child.once('exit', () => stopped?.());
|
|
32
35
|
if (!child.pid)
|
|
33
36
|
throw new Error('background_write_failed');
|
|
34
37
|
return child.pid;
|
|
35
38
|
}
|
|
39
|
+
/** A writer keeps its directory claim after the request that created it has finished. */
|
|
40
|
+
export async function startManagedBackgroundWriter(state, record, absolutePath) {
|
|
41
|
+
if (state.journal.listManagedWrites().some(item => item.writeId === record.writeId && !item.stoppedAt))
|
|
42
|
+
throw new Error('write_already_active');
|
|
43
|
+
const release = await holdDirectoryAccess(state, managedDirectoryScope(state, record.workspaceId), `managed:${record.writeId}`, 'shared');
|
|
44
|
+
let pid;
|
|
45
|
+
let persisted = Promise.resolve();
|
|
46
|
+
const finish = async () => {
|
|
47
|
+
await persisted;
|
|
48
|
+
const current = state.journal.listManagedWrites().find(item => item.writeId === record.writeId);
|
|
49
|
+
if (current && current.startedAt === record.startedAt && !current.stoppedAt) {
|
|
50
|
+
await state.journal.putManagedWrite({ ...current, stoppedAt: new Date().toISOString() });
|
|
51
|
+
}
|
|
52
|
+
release();
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await state.journal.putManagedWrite(record);
|
|
56
|
+
pid = spawnBackgroundWriter(absolutePath, () => { void finish().catch(() => undefined); });
|
|
57
|
+
persisted = state.journal.putManagedWrite({ ...record, pid });
|
|
58
|
+
await persisted;
|
|
59
|
+
return pid;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (pid === undefined || await stopBackgroundWriter(pid)) {
|
|
63
|
+
persisted = Promise.resolve();
|
|
64
|
+
await finish();
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
36
69
|
export async function stopBackgroundWriter(pid) {
|
|
37
70
|
signal(pid, 'SIGTERM');
|
|
38
71
|
if (await waitUntilStopped(pid, 2_000))
|
|
@@ -58,12 +91,21 @@ function signal(pid, name) {
|
|
|
58
91
|
async function waitUntilStopped(pid, timeoutMs) {
|
|
59
92
|
const deadline = Date.now() + timeoutMs;
|
|
60
93
|
while (Date.now() < deadline) {
|
|
61
|
-
const alive =
|
|
94
|
+
const alive = probeProcessGroup(pid);
|
|
62
95
|
if (alive === false)
|
|
63
96
|
return true;
|
|
64
|
-
if (alive === undefined)
|
|
65
|
-
return false;
|
|
66
97
|
await delay(50);
|
|
67
98
|
}
|
|
68
|
-
return
|
|
99
|
+
return probeProcessGroup(pid) === false;
|
|
100
|
+
}
|
|
101
|
+
function probeProcessGroup(pid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(-pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code !== 'ESRCH')
|
|
108
|
+
return undefined;
|
|
109
|
+
return probeProcess(pid);
|
|
110
|
+
}
|
|
69
111
|
}
|
|
@@ -2,14 +2,14 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, readdir } from 'node:fs/promises';
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
|
-
import { createPlatformAdapter } from '@playcraft/build';
|
|
6
5
|
import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
6
|
import { RuntimeJournal } from './persistence/journal.js';
|
|
8
7
|
import { createCodexAppServerAdapter } from './codex/app-server.js';
|
|
9
8
|
import { trustedMemberPolicy } from './batch/policy.js';
|
|
10
9
|
import { batchDirectoryHeld } from './batch/occupancy.js';
|
|
11
10
|
import { createLoopbackNativeAdapter } from './codex/loopback.js';
|
|
12
|
-
import { attachNativeCollector, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
|
|
11
|
+
import { attachNativeCollector, executeOperation, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
|
|
12
|
+
import { restoreDeferredDirectoryOperations } from './server/directory-access.js';
|
|
13
13
|
import { handleRuntimeRequest } from './server/http.js';
|
|
14
14
|
import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
|
|
15
15
|
import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
|
|
@@ -32,7 +32,7 @@ import { revisionScope } from './workspaces/registry.js';
|
|
|
32
32
|
import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
33
33
|
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
34
34
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
35
|
-
import {
|
|
35
|
+
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
36
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
37
37
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
38
38
|
const negotiated = negotiateProtocol(config);
|
|
@@ -58,7 +58,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
58
58
|
await recoverInterruptedPreviews(journal);
|
|
59
59
|
await recoverInterruptedParameterVerifications(journal);
|
|
60
60
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
61
|
-
|
|
61
|
+
// shared: in multi-workspace mode this is /workspaces, the container both identities traverse.
|
|
62
|
+
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o2770 });
|
|
62
63
|
const state = runtimeState = {
|
|
63
64
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
64
65
|
configPath: options.configPath,
|
|
@@ -100,6 +101,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
100
101
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
101
102
|
if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
102
103
|
continue;
|
|
104
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
105
|
+
continue;
|
|
103
106
|
const published = recoveredImports.get(operation.operationId);
|
|
104
107
|
const result = operation.result && 'paths' in operation.result ? { ...operation.result,
|
|
105
108
|
paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
|
|
@@ -151,6 +154,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
151
154
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
152
155
|
if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
153
156
|
continue;
|
|
157
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
158
|
+
continue;
|
|
154
159
|
const recovered = recoveredBundles.get(operation.operationId), receipt = recovered?.receipt;
|
|
155
160
|
let next = operation;
|
|
156
161
|
if (receipt) {
|
|
@@ -182,6 +187,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
182
187
|
if (!['directory.rename', 'directory.delete'].includes(operation.kind)
|
|
183
188
|
|| !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
184
189
|
continue;
|
|
190
|
+
if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
|
|
191
|
+
continue;
|
|
185
192
|
const kind = operation.kind;
|
|
186
193
|
let next = operation;
|
|
187
194
|
try {
|
|
@@ -273,6 +280,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
273
280
|
state.nativeError = code === 'codex_model_catalog_unavailable' ? code : 'codex_start_failed';
|
|
274
281
|
console.error(`[workspace-runtime] ${state.nativeError}`);
|
|
275
282
|
}
|
|
283
|
+
await restoreDeferredDirectoryOperations(state, (scopeKey, operation, payload, recovery) => executeOperation(state, scopeKey, operation, payload, recovery));
|
|
276
284
|
const server = createServer((request, response) => {
|
|
277
285
|
void handleRuntimeRequest(state, request, response);
|
|
278
286
|
});
|
|
@@ -355,6 +363,7 @@ function closeServer(server) {
|
|
|
355
363
|
function formatHost(host) {
|
|
356
364
|
return host.includes(':') ? `[${host}]` : host;
|
|
357
365
|
}
|
|
366
|
+
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
358
367
|
function deliveryToolHandlers(state) {
|
|
359
368
|
const contextFor = async (input) => {
|
|
360
369
|
if (input.workspaceId)
|
|
@@ -368,37 +377,15 @@ function deliveryToolHandlers(state) {
|
|
|
368
377
|
const { workspaceId, directoryName } = await contextFor(input);
|
|
369
378
|
if (directoryName === 'root')
|
|
370
379
|
throw new Error('invalid_workspace_directory');
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const operation = await submitTrustedOperation(state, {
|
|
374
|
-
kind: 'delivery.build',
|
|
375
|
-
workspaceId,
|
|
376
|
-
directoryName,
|
|
377
|
-
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
378
|
-
clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
|
|
379
|
-
payload: {
|
|
380
|
-
channel,
|
|
381
|
-
format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
|
|
382
|
-
actor: 'agent',
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
let record;
|
|
386
|
-
for (let attempt = 0; attempt < 40 && !record; attempt++) {
|
|
387
|
-
record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
|
|
388
|
-
if (!record)
|
|
389
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
390
|
-
}
|
|
391
|
-
builds.push({
|
|
392
|
-
buildId: operation.operationId,
|
|
393
|
-
channel,
|
|
394
|
-
status: record?.status ?? operation.status,
|
|
395
|
-
...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
return { builds };
|
|
380
|
+
// Exports go through the platform; a standalone workspace becomes a project first.
|
|
381
|
+
return startPlatformExport(state, { ...input, workspaceId, directoryName });
|
|
399
382
|
},
|
|
400
383
|
async get(input) {
|
|
401
384
|
const { workspaceId, directoryName } = await contextFor(input);
|
|
385
|
+
const sandboxBuild = input.buildId !== undefined && UUID.test(input.buildId);
|
|
386
|
+
if (!sandboxBuild && await platformExportTarget(state, { workspaceId, directoryName })) {
|
|
387
|
+
return readPlatformExport(state, { ...input, workspaceId, directoryName });
|
|
388
|
+
}
|
|
402
389
|
const store = new DeliveryStore(state.config.stateDir, workspaceId);
|
|
403
390
|
const record = input.buildId
|
|
404
391
|
? await store.read(input.buildId)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
1
|
+
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
2
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
3
3
|
import { buildOperationRecord } from './operation-record.js';
|
|
4
4
|
/**
|
|
@@ -29,6 +29,18 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
29
29
|
operationId: decision.operation.operationId,
|
|
30
30
|
}));
|
|
31
31
|
}
|
|
32
|
+
if (decision.action === 'reuse' && parsed.gatewayOperationId) {
|
|
33
|
+
if (decision.operation.gatewayOperationId && decision.operation.gatewayOperationId !== parsed.gatewayOperationId) {
|
|
34
|
+
return rejected(409, runtimeError('idempotency_conflict', 'gateway_operation_mismatch', {
|
|
35
|
+
operationId: decision.operation.operationId,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
if (!decision.operation.gatewayOperationId) {
|
|
39
|
+
decision.operation = await state.journal.putOperation(scopeKey, {
|
|
40
|
+
...decision.operation, gatewayOperationId: parsed.gatewayOperationId,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
32
44
|
const retryWithRecovery = decision.action === 'reuse' && parsed.kind === 'session.turn'
|
|
33
45
|
&& Boolean(parsed.sessionRecovery) && decision.operation.status === 'failed'
|
|
34
46
|
&& isLostThreadError(decision.operation.error);
|
|
@@ -38,6 +50,12 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
38
50
|
operationId: decision.operation.operationId,
|
|
39
51
|
}));
|
|
40
52
|
}
|
|
53
|
+
if (decision.operation.status === 'queued' && takesWorkspaceDirectory(decision.operation.kind) &&
|
|
54
|
+
!isTurnControl(decision.operation.kind) && !state.journal.deferredDirectoryOperations().some(item => item.operationId === decision.operation.operationId)) {
|
|
55
|
+
const queued = await state.journal.putPendingOperation(scopeKey, decision.operation, options.executionPayload, parsed.sessionRecovery);
|
|
56
|
+
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
57
|
+
return { rejected: false, operation: executed, executed, reused: true };
|
|
58
|
+
}
|
|
41
59
|
return { rejected: false, operation: decision.operation, executed: decision.operation, reused: true };
|
|
42
60
|
}
|
|
43
61
|
const validation = await options.validate?.(scopeKey);
|
|
@@ -51,7 +69,9 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
51
69
|
}
|
|
52
70
|
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, options.leaseFence);
|
|
53
71
|
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
54
|
-
const queued =
|
|
72
|
+
const queued = takesWorkspaceDirectory(operation.kind) && !isTurnControl(operation.kind) && operation.kind !== 'runtime.destroy'
|
|
73
|
+
? await state.journal.putPendingOperation(scopeKey, operation, options.executionPayload, parsed.sessionRecovery)
|
|
74
|
+
: await state.journal.putOperation(scopeKey, operation);
|
|
55
75
|
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
56
76
|
return { rejected: false, operation: queued, executed, reused: false };
|
|
57
77
|
});
|