@playcraft/cli 0.1.23 → 0.1.25
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/cli-root-help.js +1 -0
- package/dist/commands/playable.js +53 -0
- package/dist/commands/tools-3d.js +11 -0
- package/dist/commands/tools-batch.js +42 -14
- package/dist/commands/tools-generation.js +6 -0
- package/dist/commands/video.js +111 -0
- package/dist/index.js +29 -3
- package/dist/project-skills/builtin-import.js +225 -0
- package/dist/project-skills/commands.js +6 -1
- package/dist/project-skills/local-store.js +73 -1
- package/dist/project-skills/messages.js +8 -0
- package/dist/recording/messages.js +40 -0
- package/dist/recording/project-digest.js +85 -0
- package/dist/recording/recorder.js +293 -0
- package/dist/recording/scenario.js +50 -0
- package/dist/utils/agent-api-client.js +39 -3
- package/dist/utils/video-evidence-git.js +81 -0
- package/dist/video-replication/client.js +222 -0
- package/dist/video-replication/comparison-evidence.js +149 -0
- package/dist/video-replication/comparison-files.js +57 -0
- package/dist/video-replication/comparison-log.js +72 -0
- package/dist/video-replication/comparison-messages.js +104 -0
- package/dist/video-replication/comparison-output.js +63 -0
- package/dist/video-replication/comparison-preflight.js +66 -0
- package/dist/video-replication/comparison.js +216 -0
- package/dist/video-replication/evidence.js +165 -0
- package/dist/video-replication/media.js +111 -0
- package/dist/video-replication/messages.js +89 -0
- package/dist/workspace-runtime/codex/app-server.js +65 -12
- package/dist/workspace-runtime/codex/config-toml.js +3 -2
- package/dist/workspace-runtime/persistence/native-subagents.js +162 -0
- package/dist/workspace-runtime/persistence/sqlite-client.js +17 -1
- package/dist/workspace-runtime/persistence/sqlite-worker.js +116 -74
- package/dist/workspace-runtime/production/native.js +21 -1
- package/dist/workspace-runtime/production/save-command.js +5 -1
- package/dist/workspace-runtime/production/source-copy.js +91 -0
- package/dist/workspace-runtime/production/stages.js +48 -21
- package/dist/workspace-runtime/serve.js +3 -0
- package/dist/workspace-runtime/server/dispatch.js +34 -6
- package/dist/workspace-runtime/server/http.js +60 -3
- package/dist/workspace-runtime/server/terminal-host.js +2 -2
- package/dist/workspace-runtime/workspaces/artifact-events.js +55 -0
- package/dist/workspace-runtime/workspaces/artifact-files.js +195 -0
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +89 -0
- package/dist/workspace-runtime/workspaces/artifacts.js +260 -0
- package/dist/workspace-runtime/workspaces/attachments.js +70 -4
- package/dist/workspace-runtime/workspaces/batch-commit.js +1 -1
- package/dist/workspace-runtime/workspaces/cli-context.js +23 -0
- package/dist/workspace-runtime/workspaces/directories.js +10 -3
- package/dist/workspace-runtime/workspaces/directory-access.js +9 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +31 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +12 -4
- package/dist/workspace-runtime/workspaces/files.js +8 -1
- package/dist/workspace-runtime/workspaces/prepare.js +2 -1
- package/dist/workspace-runtime/workspaces/registry.js +13 -5
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +45 -0
- package/dist/workspace-runtime/workspaces/skills.js +28 -18
- package/dist/workspace-runtime/workspaces/video-attachments.js +49 -0
- package/package.json +7 -4
- package/project-template/.claude/skills/playcraft-replica-validation/SKILL.md +25 -0
- package/project-template/.claude/skills/playcraft-replica-validation/references/contracts.md +79 -0
- package/project-template/.claude/skills/playcraft-video-understanding/SKILL.md +29 -0
- package/project-template/.claude/skills/playcraft-video-understanding/references/contracts.md +37 -0
- package/project-templates/empty/AGENTS.md +2 -0
- package/project-templates/empty/gitignore +2 -0
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* reports 3.51.2 and must not be used for this file.
|
|
8
8
|
* Do not read Codex JSONL from this worker.
|
|
9
9
|
*/
|
|
10
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
10
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
11
11
|
import { chmodSync, mkdirSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
import { parentPort } from 'node:worker_threads';
|
|
14
14
|
import Database from 'better-sqlite3-pinned';
|
|
15
|
+
import { clearPendingNativeFollowups, learnNativeSubagents, nativeEventOwner, nativeSubagentThreads } from './native-subagents.js';
|
|
15
16
|
import { hashOperationPayload, operationRecordSchema, pendingSourceEventSchema, terminalRecordSchema, workspaceRegistrationSchema, transitionOperation, canTransitionTurn, } from '@playcraft/common/cloud-workspace/runtime';
|
|
16
17
|
const SCHEMA_VERSION = 1;
|
|
17
18
|
const MIN_SQLITE = [3, 51, 3];
|
|
@@ -154,6 +155,16 @@ function openDatabase(payload) {
|
|
|
154
155
|
else {
|
|
155
156
|
quarantineReservedTurns(database);
|
|
156
157
|
}
|
|
158
|
+
database.exec(`
|
|
159
|
+
CREATE INDEX IF NOT EXISTS native_inbox_pending_thread
|
|
160
|
+
ON native_inbox(thread_id, ingress_sequence) WHERE converted = 0;
|
|
161
|
+
CREATE INDEX IF NOT EXISTS records_native_session_thread
|
|
162
|
+
ON records(json_extract(body_json, '$.threadId')) WHERE bucket = 'sessions';
|
|
163
|
+
CREATE INDEX IF NOT EXISTS native_inbox_pending_parent
|
|
164
|
+
ON native_inbox(COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
|
|
165
|
+
json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')))
|
|
166
|
+
WHERE converted = 0 AND method = 'thread/started';
|
|
167
|
+
`);
|
|
157
168
|
return {
|
|
158
169
|
...dump(database),
|
|
159
170
|
imported,
|
|
@@ -391,34 +402,13 @@ function ingest(database, body) {
|
|
|
391
402
|
const ingress = nextIngress(database);
|
|
392
403
|
const kind = String(body.kind);
|
|
393
404
|
const method = optionalString(body.method);
|
|
394
|
-
const session = threadId ? sessionByThread(database, threadId) : undefined;
|
|
395
405
|
const hintedTurn = turnId || nativeIds(body.params).turnId;
|
|
396
|
-
const operation = hintedTurn ? operationByTurn(database, hintedTurn) : undefined;
|
|
397
|
-
const linked = canConvert(kind, threadId, hintedTurn, session, operation);
|
|
398
406
|
database
|
|
399
407
|
.prepare(`INSERT INTO native_inbox (
|
|
400
408
|
ingress_sequence, runtime_epoch, kind, method, thread_id, turn_id, item_id, payload_json, converted, received_at
|
|
401
409
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
|
|
402
410
|
.run(ingress, Number(body.runtimeEpoch) || 0, kind, method ?? null, threadId ?? null, hintedTurn ?? null, itemId ?? null, JSON.stringify(body.params ?? {}), String(body.occurredAt));
|
|
403
|
-
|
|
404
|
-
return { committedEvents: [] };
|
|
405
|
-
const earlier = operation
|
|
406
|
-
? releaseInbox(database, threadId ?? '', hintedTurn ?? '', { scopeKey: operation.scopeKey, operation: operation.operation }, ingress)
|
|
407
|
-
: [];
|
|
408
|
-
const event = convertInboxRow(database, ingress, {
|
|
409
|
-
runtimeId: String(body.runtimeId),
|
|
410
|
-
generation: Number(body.generation) || 0,
|
|
411
|
-
runtimeEpoch: Number(body.runtimeEpoch) || 0,
|
|
412
|
-
kind,
|
|
413
|
-
method,
|
|
414
|
-
threadId,
|
|
415
|
-
turnId: hintedTurn,
|
|
416
|
-
itemId,
|
|
417
|
-
occurredAt: String(body.occurredAt),
|
|
418
|
-
session,
|
|
419
|
-
operation,
|
|
420
|
-
});
|
|
421
|
-
return { committedEvents: event ? [...earlier, event] : earlier };
|
|
411
|
+
return { committedEvents: releaseInbox(database, { ingress }, { runtimeId: String(body.runtimeId), generation: Number(body.generation) || 0 }) };
|
|
422
412
|
}
|
|
423
413
|
function bindTurn(database, body) {
|
|
424
414
|
const operation = operationRecordSchema.parse(body.operation);
|
|
@@ -437,41 +427,81 @@ function bindTurn(database, body) {
|
|
|
437
427
|
}
|
|
438
428
|
const threadId = String(body.threadId);
|
|
439
429
|
const turnId = String(body.turnId);
|
|
440
|
-
const released = releaseInbox(database,
|
|
430
|
+
const released = releaseInbox(database, { rootThreadId: threadId, turnId });
|
|
441
431
|
applyRecordedTurn(database, threadId, turnId);
|
|
442
432
|
return { committedEvents: released };
|
|
443
433
|
}
|
|
444
|
-
function releaseInbox(database,
|
|
445
|
-
const rows = database
|
|
446
|
-
.prepare(`SELECT ingress_sequence FROM native_inbox
|
|
447
|
-
WHERE converted = 0
|
|
448
|
-
AND (? IS NULL OR ingress_sequence < ?)
|
|
449
|
-
AND (turn_id = ? OR (thread_id = ? AND turn_id IS NULL))
|
|
450
|
-
ORDER BY ingress_sequence`)
|
|
451
|
-
.all(beforeIngress ?? null, beforeIngress ?? null, turnId, threadId);
|
|
434
|
+
function releaseInbox(database, selection, identity) {
|
|
452
435
|
const events = [];
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
436
|
+
const rows = [];
|
|
437
|
+
const queued = new Set();
|
|
438
|
+
const enqueue = (pending) => {
|
|
439
|
+
for (const row of pending) {
|
|
440
|
+
const ingress = Number(row.ingress_sequence);
|
|
441
|
+
if (!queued.has(ingress)) {
|
|
442
|
+
queued.add(ingress);
|
|
443
|
+
rows.push(row);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
const enqueueChildren = (parentId) => enqueue(database.prepare(`
|
|
448
|
+
SELECT * FROM native_inbox WHERE converted = 0 AND method = 'thread/started'
|
|
449
|
+
AND COALESCE(json_extract(payload_json, '$.thread.parentThreadId'),
|
|
450
|
+
json_extract(payload_json, '$.thread.source.subAgent.thread_spawn.parent_thread_id')) = ?
|
|
451
|
+
ORDER BY ingress_sequence
|
|
452
|
+
`).all(parentId));
|
|
453
|
+
const enqueueThread = (threadId) => {
|
|
454
|
+
enqueue(database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND thread_id = ? ORDER BY ingress_sequence').all(threadId));
|
|
455
|
+
enqueueChildren(threadId);
|
|
456
|
+
};
|
|
457
|
+
if ('ingress' in selection) {
|
|
458
|
+
const current = database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND ingress_sequence = ?').get(selection.ingress);
|
|
459
|
+
if (current) {
|
|
460
|
+
const threadId = optionalString(current.thread_id), turnId = optionalString(current.turn_id);
|
|
461
|
+
if (nativeEventOwner(database, threadId, turnId)) {
|
|
462
|
+
// A receipt can bind between notifications. Drain that thread's earlier events first.
|
|
463
|
+
enqueue(database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND thread_id = ? AND (turn_id = ? OR turn_id IS NULL) AND ingress_sequence <= ? ORDER BY ingress_sequence')
|
|
464
|
+
.all(threadId, turnId ?? null, selection.ingress));
|
|
465
|
+
}
|
|
466
|
+
else
|
|
467
|
+
enqueue([current]);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
enqueue(database.prepare('SELECT * FROM native_inbox WHERE converted = 0 AND thread_id = ? AND (turn_id = ? OR turn_id IS NULL) ORDER BY ingress_sequence')
|
|
472
|
+
.all(selection.rootThreadId, selection.turnId));
|
|
473
|
+
enqueueChildren(selection.rootThreadId);
|
|
474
|
+
for (const threadId of nativeSubagentThreads(database, selection.rootThreadId))
|
|
475
|
+
enqueueThread(threadId);
|
|
476
|
+
}
|
|
477
|
+
for (const stored of rows) {
|
|
478
|
+
const threadId = optionalString(stored.thread_id);
|
|
479
|
+
const turnId = optionalString(stored.turn_id);
|
|
480
|
+
const method = optionalString(stored.method);
|
|
481
|
+
const params = JSON.parse(String(stored.payload_json || '{}'));
|
|
482
|
+
if (stored.kind === 'process_exit')
|
|
483
|
+
for (const child of clearPendingNativeFollowups(database))
|
|
484
|
+
enqueueThread(child);
|
|
485
|
+
for (const child of learnNativeSubagents(database, method ?? '', params, threadId, turnId))
|
|
486
|
+
enqueueThread(child);
|
|
487
|
+
const owner = nativeEventOwner(database, threadId, turnId);
|
|
488
|
+
if (stored.kind !== 'process_exit' && !owner) {
|
|
489
|
+
queued.delete(Number(stored.ingress_sequence));
|
|
456
490
|
continue;
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
threadId,
|
|
466
|
-
turnId: optionalString(stored.turn_id) || turnId,
|
|
467
|
-
itemId: optionalString(stored.item_id),
|
|
468
|
-
occurredAt: String(stored.received_at),
|
|
469
|
-
session,
|
|
470
|
-
operation,
|
|
471
|
-
params: JSON.parse(String(stored.payload_json || '{}')),
|
|
491
|
+
}
|
|
492
|
+
const event = convertInboxRow(database, Number(stored.ingress_sequence), {
|
|
493
|
+
runtimeId: identity?.runtimeId ?? (metaString(database, 'runtime_id') || 'runtime'),
|
|
494
|
+
generation: identity?.generation ?? metaNumber(database, 'generation'),
|
|
495
|
+
runtimeEpoch: Number(stored.runtime_epoch),
|
|
496
|
+
kind: String(stored.kind), method, threadId, turnId,
|
|
497
|
+
itemId: optionalString(stored.item_id), occurredAt: String(stored.received_at),
|
|
498
|
+
session: owner?.session, operation: owner?.operation, subAgent: owner?.subAgent, params,
|
|
472
499
|
});
|
|
473
500
|
if (event)
|
|
474
501
|
events.push(event);
|
|
502
|
+
if (!owner?.subAgent && threadId && terminalTurn(method ?? '', asRecord(params)))
|
|
503
|
+
for (const child of nativeSubagentThreads(database, threadId))
|
|
504
|
+
enqueueThread(child);
|
|
475
505
|
}
|
|
476
506
|
return events;
|
|
477
507
|
}
|
|
@@ -480,7 +510,12 @@ function convertInboxRow(database, ingress, input) {
|
|
|
480
510
|
if (!row || row.converted)
|
|
481
511
|
return undefined;
|
|
482
512
|
const params = input.params ?? JSON.parse(row.payload_json || '{}');
|
|
483
|
-
const payload = linkedPayload(params, input.session);
|
|
513
|
+
const payload = linkedPayload(params, input.session, input.subAgent);
|
|
514
|
+
const nativeApprovalId = optionalString(payload.approvalId) || optionalString(payload.itemId) || input.itemId;
|
|
515
|
+
const childApproval = input.subAgent && nativeApprovalId && input.method?.includes('requestApproval');
|
|
516
|
+
if (input.subAgent && nativeApprovalId && (childApproval || input.method === 'approval.resolved')) {
|
|
517
|
+
payload.approvalId = `subagent_${createHash('sha256').update(JSON.stringify([input.subAgent.threadId, nativeApprovalId])).digest('hex')}`;
|
|
518
|
+
}
|
|
484
519
|
const type = input.kind === 'process_exit' ? 'process.exited' : input.method || input.kind;
|
|
485
520
|
const allocated = allocateEvent(database, {
|
|
486
521
|
runtimeId: input.runtimeId || metaString(database, 'runtime_id'),
|
|
@@ -504,20 +539,34 @@ function convertInboxRow(database, ingress, input) {
|
|
|
504
539
|
SET converted = 1, source_sequence = ?, payload_json = '{}'
|
|
505
540
|
WHERE ingress_sequence = ?`)
|
|
506
541
|
.run(event.source.sourceSequence, ingress);
|
|
507
|
-
|
|
542
|
+
if (childApproval && input.operation?.operation.sessionId) {
|
|
543
|
+
upsertRecord(database, 'approvals', `${input.operation.operation.sessionId}:${String(payload.approvalId)}`, {
|
|
544
|
+
approvalId: payload.approvalId,
|
|
545
|
+
sessionId: input.operation.operation.sessionId,
|
|
546
|
+
runtimeEpoch: input.runtimeEpoch,
|
|
547
|
+
nativeRequestId: `approval:${nativeApprovalId}`,
|
|
548
|
+
nativeApprovalId,
|
|
549
|
+
nativeThreadId: input.subAgent.threadId,
|
|
550
|
+
status: 'pending',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
if (!input.subAgent)
|
|
554
|
+
applyNativeToState(database, { ...input, operation: resolveOperation(database, input, payload) }, payload);
|
|
508
555
|
return event;
|
|
509
556
|
}
|
|
510
557
|
function applyRecordedTurn(database, threadId, turnId) {
|
|
511
558
|
const rows = database
|
|
512
559
|
.prepare(`SELECT type, payload_json, thread_id, session_id FROM source_events
|
|
513
|
-
WHERE turn_id = ? OR payload_json LIKE ?
|
|
560
|
+
WHERE (thread_id = ? OR thread_id IS NULL) AND (turn_id = ? OR payload_json LIKE ?)
|
|
514
561
|
ORDER BY source_sequence`)
|
|
515
|
-
.all(turnId, `%"${turnId}"%`);
|
|
562
|
+
.all(threadId, turnId, `%"${turnId}"%`);
|
|
516
563
|
for (const row of rows) {
|
|
517
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') {
|
|
518
565
|
continue;
|
|
519
566
|
}
|
|
520
567
|
const payload = asRecord(JSON.parse(row.payload_json));
|
|
568
|
+
if (payload.subAgent)
|
|
569
|
+
continue;
|
|
521
570
|
const session = sessionByThread(database, threadId) ?? (row.session_id ? readSession(database, row.session_id) : undefined);
|
|
522
571
|
applyNativeToState(database, {
|
|
523
572
|
method: row.type,
|
|
@@ -525,14 +574,14 @@ function applyRecordedTurn(database, threadId, turnId) {
|
|
|
525
574
|
threadId: optionalString(row.thread_id) || threadId,
|
|
526
575
|
turnId,
|
|
527
576
|
session,
|
|
528
|
-
operation: operationByTurn(database, turnId),
|
|
577
|
+
operation: operationByTurn(database, turnId, threadId),
|
|
529
578
|
}, payload);
|
|
530
579
|
}
|
|
531
580
|
}
|
|
532
581
|
function resolveOperation(database, input, payload) {
|
|
533
582
|
const turnId = input.turnId || nativeIds(payload).turnId;
|
|
534
583
|
if (turnId)
|
|
535
|
-
return operationByTurn(database, turnId
|
|
584
|
+
return input.operation ?? operationByTurn(database, turnId, input.threadId ?? nativeIds(payload).threadId);
|
|
536
585
|
return input.operation;
|
|
537
586
|
}
|
|
538
587
|
function applyNativeToState(database, input, payload) {
|
|
@@ -563,7 +612,7 @@ function applyNativeToState(database, input, payload) {
|
|
|
563
612
|
if (!input.operation)
|
|
564
613
|
return;
|
|
565
614
|
let operation = input.operation.operation;
|
|
566
|
-
const approvalId = optionalString(payload.
|
|
615
|
+
const approvalId = optionalString(payload.approvalId) || optionalString(payload.itemId) || input.itemId;
|
|
567
616
|
if ((method.includes('requestApproval') || method.includes('requestUserInput')) && approvalId && operation.sessionId) {
|
|
568
617
|
upsertRecord(database, 'approvals', `${operation.sessionId}:${approvalId}`, {
|
|
569
618
|
approvalId,
|
|
@@ -851,10 +900,10 @@ function sessionByThread(database, threadId) {
|
|
|
851
900
|
return undefined;
|
|
852
901
|
return matches[0];
|
|
853
902
|
}
|
|
854
|
-
function operationByTurn(database, turnId) {
|
|
903
|
+
function operationByTurn(database, turnId, threadId) {
|
|
855
904
|
const row = database
|
|
856
|
-
.prepare(
|
|
857
|
-
.get(turnId);
|
|
905
|
+
.prepare(`SELECT scope_key, operation_json FROM operation_intents WHERE turn_id = ?${threadId ? ' AND thread_id = ?' : ''}`)
|
|
906
|
+
.get(...(threadId ? [turnId, threadId] : [turnId]));
|
|
858
907
|
if (!row)
|
|
859
908
|
return undefined;
|
|
860
909
|
const parsed = operationRecordSchema.safeParse(JSON.parse(row.operation_json));
|
|
@@ -862,24 +911,17 @@ function operationByTurn(database, turnId) {
|
|
|
862
911
|
return undefined;
|
|
863
912
|
return { scopeKey: row.scope_key, operation: parsed.data };
|
|
864
913
|
}
|
|
865
|
-
function
|
|
866
|
-
|
|
867
|
-
return true;
|
|
868
|
-
if (turnId && !operation)
|
|
869
|
-
return false;
|
|
870
|
-
if (turnId && operation)
|
|
871
|
-
return true;
|
|
872
|
-
if (!session || !threadId)
|
|
873
|
-
return false;
|
|
874
|
-
return true;
|
|
875
|
-
}
|
|
876
|
-
function linkedPayload(params, session) {
|
|
877
|
-
const payload = asRecord(params);
|
|
914
|
+
function linkedPayload(params, session, subAgent) {
|
|
915
|
+
const payload = { ...asRecord(params) };
|
|
878
916
|
delete payload.workspaceId;
|
|
917
|
+
delete payload.sessionId;
|
|
918
|
+
delete payload.subAgent;
|
|
879
919
|
if (typeof session?.workspaceId === 'string')
|
|
880
920
|
payload.workspaceId = session.workspaceId;
|
|
881
921
|
if (typeof session?.sessionId === 'string')
|
|
882
922
|
payload.sessionId = session.sessionId;
|
|
923
|
+
if (subAgent)
|
|
924
|
+
payload.subAgent = subAgent;
|
|
883
925
|
return payload;
|
|
884
926
|
}
|
|
885
927
|
function nativeIds(params) {
|
|
@@ -889,7 +931,7 @@ function nativeIds(params) {
|
|
|
889
931
|
return {
|
|
890
932
|
threadId: optionalString(record.threadId) || optionalString(thread.id),
|
|
891
933
|
turnId: optionalString(record.turnId) || optionalString(turn.id),
|
|
892
|
-
itemId: optionalString(record.itemId) || optionalString(record.approvalId),
|
|
934
|
+
itemId: optionalString(record.itemId) || optionalString(record.approvalId) || optionalString(asRecord(record.item).id),
|
|
893
935
|
};
|
|
894
936
|
}
|
|
895
937
|
function terminalTurn(method, payload) {
|
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { lstat, mkdir } from 'node:fs/promises';
|
|
2
3
|
import { createCodexAppServerAdapter } from '../codex/app-server.js';
|
|
3
4
|
import { verifyProductionLease } from './storage.js';
|
|
4
5
|
import { processBirth, stopProductionGroup } from './processes.js';
|
|
6
|
+
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
5
7
|
export async function runProductionNative(state, input, root, record, track) {
|
|
6
8
|
if (!input.model)
|
|
7
9
|
throw new Error('production_model_configuration_required');
|
|
10
|
+
const temporaryRoot = join(root, '.playcraft-temp');
|
|
11
|
+
const existing = await lstat(temporaryRoot).catch(error => {
|
|
12
|
+
if (error.code === 'ENOENT')
|
|
13
|
+
return undefined;
|
|
14
|
+
throw error;
|
|
15
|
+
});
|
|
16
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
|
|
17
|
+
throw new Error('production_source_path_invalid');
|
|
18
|
+
await mkdir(temporaryRoot, { recursive: true });
|
|
19
|
+
await openDirectoryToDevelopment(temporaryRoot, (await lstat(root)).gid);
|
|
8
20
|
const groups = [];
|
|
9
21
|
const adapter = createCodexAppServerAdapter({ config: { ...state.config, workspaceMode: 'single', workspaceRoot: root },
|
|
10
22
|
command: process.env.PLAYCRAFT_PRODUCTION_CODEX_COMMAND,
|
|
11
23
|
homeDir: process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
12
24
|
? `/var/lib/playcraft/codex/production-${input.task.attemptId}` : join(state.config.stateDir, 'production-native', input.task.attemptId),
|
|
13
25
|
model: input.model.model, processGroup: true,
|
|
26
|
+
fixedExecution: { cwd: root, approvalPolicy: 'never', sandboxPolicy: { type: 'workspaceWrite', writableRoots: [root],
|
|
27
|
+
networkAccess: true, excludeTmpdirEnvVar: true, excludeSlashTmp: true } },
|
|
14
28
|
fixedThreadConfig: { model: input.model.model, modelProvider: 'playcraft_production', config: {
|
|
15
|
-
approval_policy: 'never', sandbox_mode: '
|
|
29
|
+
approval_policy: 'never', sandbox_mode: 'workspace-write',
|
|
30
|
+
'sandbox_workspace_write.writable_roots': [root],
|
|
31
|
+
'sandbox_workspace_write.network_access': true,
|
|
32
|
+
'sandbox_workspace_write.exclude_tmpdir_env_var': true,
|
|
33
|
+
'sandbox_workspace_write.exclude_slash_tmp': true,
|
|
34
|
+
'shell_environment_policy.set': { PLAYCRAFT_TOOL_REQUEST_DIR: join(temporaryRoot, 'tool-requests'), PLAYCRAFT_SKIP_UPDATE_CHECK: '1',
|
|
35
|
+
TMPDIR: temporaryRoot, npm_config_cache: join(temporaryRoot, 'npm-cache'), npm_config_store_dir: join(temporaryRoot, 'pnpm-store') },
|
|
16
36
|
'model_providers.playcraft_production': { name: 'PlayCraft Production', base_url: input.model.endpoint,
|
|
17
37
|
wire_api: 'responses', requires_openai_auth: false, http_headers: { Authorization: `Bearer ${input.model.token}` } },
|
|
18
38
|
} },
|
|
@@ -14,7 +14,11 @@ if [ -n "$remote" ]; then
|
|
|
14
14
|
git -c core.hooksPath=/dev/null -c core.fsmonitor=false reset --soft FETCH_HEAD
|
|
15
15
|
fi
|
|
16
16
|
git -c core.hooksPath=/dev/null -c core.fsmonitor=false add -A .
|
|
17
|
+
if [ -n "$(git ls-files -- .playcraft.json playcraft.config.json ':(glob)**/.playcraft.json' ':(glob)**/playcraft.config.json')" ]; then
|
|
18
|
+
printf 'production_git_private_path\\n' >&2
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
17
21
|
git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save generated engineering"
|
|
18
|
-
git -c protocol.version=0 -c core.hooksPath=/dev/null
|
|
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"
|
|
19
23
|
git rev-parse HEAD`;
|
|
20
24
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { excludeFromGit } from '../workspaces/dependencies.js';
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
const privateNames = new Set(['.git', 'node_modules', '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store',
|
|
10
|
+
'.config', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json', 'credentials', 'secrets',
|
|
11
|
+
'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json', '.playcraft-temp', '.playcraft-preview-out']);
|
|
12
|
+
const engineering = (path) => !path.split('/').some(name => privateNames.has(name)
|
|
13
|
+
|| /^\.env(?:$|\.)/.test(name) || /\.(?:pem|key|p12|pfx)$/.test(name)) && !path.startsWith('.playcraft/skills/');
|
|
14
|
+
const inside = (root, path) => { const rel = relative(root, path); return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel); };
|
|
15
|
+
const git = async (root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=',
|
|
16
|
+
'-c', 'core.fsmonitor=false', '-C', root, ...args], { timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
|
|
17
|
+
env: { PATH: process.env.PATH, HOME: dirname(root), GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } })).stdout;
|
|
18
|
+
async function sourceFiles(root) {
|
|
19
|
+
const paths = new Set((await git(root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z')).split('\0').filter(Boolean));
|
|
20
|
+
const artifacts = await readdir(join(root, '.playcraft/skill-artifacts')).catch(error => {
|
|
21
|
+
if (error.code === 'ENOENT')
|
|
22
|
+
return [];
|
|
23
|
+
throw error;
|
|
24
|
+
});
|
|
25
|
+
for (const name of artifacts)
|
|
26
|
+
if (/^[a-f0-9]{64}\.tar\.gz$/.test(name))
|
|
27
|
+
paths.add(`.playcraft/skill-artifacts/${name}`);
|
|
28
|
+
const files = new Map();
|
|
29
|
+
for (const path of [...paths].sort()) {
|
|
30
|
+
if (!engineering(path))
|
|
31
|
+
continue;
|
|
32
|
+
const full = join(root, path), stat = await lstat(full).catch(error => {
|
|
33
|
+
if (error.code === 'ENOENT')
|
|
34
|
+
return undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
});
|
|
37
|
+
if (!stat)
|
|
38
|
+
continue;
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !inside(root, await realpath(full)))
|
|
40
|
+
throw new Error('production_source_path_invalid');
|
|
41
|
+
const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
42
|
+
try {
|
|
43
|
+
files.set(path, { body: await handle.readFile(), mode: stat.mode & 0o777 });
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await handle.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
function digest(files) {
|
|
52
|
+
const hash = createHash('sha256');
|
|
53
|
+
for (const [path, file] of files)
|
|
54
|
+
hash.update(path).update('\0').update(String(file.mode)).update('\0').update(file.body).update('\0');
|
|
55
|
+
return hash.digest('hex');
|
|
56
|
+
}
|
|
57
|
+
/** Capture the selected working tree into a private branch; the source index, head and configuration are read only. */
|
|
58
|
+
export async function copyProductionSource(input) {
|
|
59
|
+
const source = await realpath(input.source);
|
|
60
|
+
if (await lstat(input.destination).catch(() => undefined))
|
|
61
|
+
throw new Error('production_source_destination_exists');
|
|
62
|
+
const sourceHead = await git(source, 'rev-parse', '--verify', 'HEAD').catch(() => '');
|
|
63
|
+
const files = await sourceFiles(source), sourceDigest = digest(files);
|
|
64
|
+
const staging = join(dirname(input.destination), `.production-source-${randomUUID()}`);
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(staging);
|
|
67
|
+
// An independent root commit also excludes credentials that existed only in source history.
|
|
68
|
+
await git(staging, '-c', 'init.templateDir=', 'init', '--initial-branch', input.branchName);
|
|
69
|
+
for (const [path, file] of files) {
|
|
70
|
+
await mkdir(dirname(join(staging, path)), { recursive: true });
|
|
71
|
+
await writeFile(join(staging, path), file.body, { mode: file.mode, flag: 'wx' });
|
|
72
|
+
}
|
|
73
|
+
if (sourceHead !== await git(source, 'rev-parse', '--verify', 'HEAD').catch(() => '') || sourceDigest !== digest(await sourceFiles(source)))
|
|
74
|
+
throw new Error('production_source_changed');
|
|
75
|
+
await git(staging, 'remote', 'add', 'origin', input.repositoryUrl);
|
|
76
|
+
await git(staging, 'config', 'user.name', 'PlayCraft');
|
|
77
|
+
await git(staging, 'config', 'user.email', 'playcraft@localhost');
|
|
78
|
+
await excludeFromGit(staging);
|
|
79
|
+
await excludeFromGit(staging, '/.env*');
|
|
80
|
+
for (const entry of ['/.playcraft.json', '/playcraft.config.json', '/.playcraft/skill-artifacts/', '/.playcraft/skills/', '/.playcraft-temp/'])
|
|
81
|
+
await excludeFromGit(staging, entry);
|
|
82
|
+
await git(staging, 'add', '-A', '.');
|
|
83
|
+
await git(staging, 'commit', '--allow-empty', '-m', 'Capture batch source in independent remix');
|
|
84
|
+
const commitHash = (await git(staging, 'rev-parse', 'HEAD')).trim();
|
|
85
|
+
await rename(staging, input.destination);
|
|
86
|
+
return { commitHash, sourceDigest };
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await rm(staging, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -5,9 +5,9 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { batchBaselineSnapshotSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { checkoutBatchCommit, freezeBatchCommit } from '../workspaces/batch-commit.js';
|
|
8
|
-
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
8
|
+
import { grantWorkspaceTraversal, openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
9
9
|
import { registerWorkspaceDirectory } from '../workspaces/directories.js';
|
|
10
|
-
import { workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
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
13
|
import { productionSaveCommand } from './save-command.js';
|
|
@@ -15,6 +15,9 @@ import { productionOutputStore, verifyProductionLease } from './storage.js';
|
|
|
15
15
|
import { runProductionCommand } from './processes.js';
|
|
16
16
|
import { runProductionNative } from './native.js';
|
|
17
17
|
import { classifyPreviewOutput, findExternalLocalReferences } from '../previews/single-file.js';
|
|
18
|
+
import { copyProductionSource } from './source-copy.js';
|
|
19
|
+
import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
|
|
20
|
+
import { ensureWorkspaceSkills } from '../workspaces/skills.js';
|
|
18
21
|
const execute = promisify(execFile);
|
|
19
22
|
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
20
23
|
const hash = (body) => createHash('sha256').update(body).digest('hex');
|
|
@@ -25,6 +28,10 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
25
28
|
const stage = input.stage;
|
|
26
29
|
const workspaceId = input.task.workspaceId;
|
|
27
30
|
const resultId = String(input.payload.resultVersionId ?? input.task.payload.resultVersionId ?? input.task.attemptId);
|
|
31
|
+
const sourceDirectoryName = String(input.payload.sourceDirectoryName ?? input.task.payload.sourceDirectoryName ?? 'basic');
|
|
32
|
+
if (input.task.payload.sourceDirectoryName !== undefined && input.payload.sourceDirectoryName !== undefined
|
|
33
|
+
&& input.task.payload.sourceDirectoryName !== input.payload.sourceDirectoryName)
|
|
34
|
+
throw new Error('production_source_directory_mismatch');
|
|
28
35
|
const previous = (name) => input.stages?.find(value => value.stage === name && value.status === 'completed')?.result;
|
|
29
36
|
const run = async (cwd, command, env, trusted = false) => {
|
|
30
37
|
const outcome = await runProductionCommand(state, { processId: `production-${input.task.attemptId}-${stage}`, cwd, command, env, trusted,
|
|
@@ -38,7 +45,34 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
38
45
|
const git = async (root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
|
|
39
46
|
timeout: 20_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' },
|
|
40
47
|
})).stdout.trim();
|
|
41
|
-
if (stage === '
|
|
48
|
+
if (stage === 'capture-source') {
|
|
49
|
+
if (input.directoryName === 'basic' || input.directoryName === sourceDirectoryName)
|
|
50
|
+
throw new Error('production_source_directory_mismatch');
|
|
51
|
+
const authority = record(input.payload.workspace);
|
|
52
|
+
if (authority.directoryName !== input.directoryName || authority.workspaceId !== workspaceId
|
|
53
|
+
|| typeof authority.remixId !== 'string' || typeof authority.branchName !== 'string' || typeof authority.repositoryUrl !== 'string')
|
|
54
|
+
throw new Error('production_source_directory_mismatch');
|
|
55
|
+
const { remixId, branchName, repositoryUrl } = authority;
|
|
56
|
+
await settleDependencyInstalls(state);
|
|
57
|
+
return state.journal.exclusive(`directory:${workspaceId}:${sourceDirectoryName}`, async () => {
|
|
58
|
+
if (await workspaceDirectoryOccupied(state, workspaceId, sourceDirectoryName)
|
|
59
|
+
|| workspaceSessions(state, workspaceId, sourceDirectoryName).some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
|
|
60
|
+
throw new Error('production_source_busy');
|
|
61
|
+
const source = await workspaceRoot(state, workspaceId, sourceDirectoryName);
|
|
62
|
+
const container = await workspaceContainerRoot(state, workspaceId);
|
|
63
|
+
const destination = join(container, input.directoryName);
|
|
64
|
+
const developmentGroup = (await lstat(state.config.workspaceRoot)).gid;
|
|
65
|
+
const snapshot = await copyProductionSource({ source, destination, branchName, repositoryUrl });
|
|
66
|
+
await ensureWorkspaceSkills(destination, join(state.config.stateDir, 'skill-artifacts'));
|
|
67
|
+
await openDirectoryToDevelopment(destination, developmentGroup);
|
|
68
|
+
await grantWorkspaceTraversal(container, developmentGroup);
|
|
69
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
70
|
+
await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(value => value.directoryName !== input.directoryName),
|
|
71
|
+
{ workspaceId, directoryName: input.directoryName, remixId, branchName, sourceCommit: snapshot.commitHash, status: 'ready' }] });
|
|
72
|
+
return { sourceSnapshot: snapshot, commitHash: snapshot.commitHash };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (stage === 'prepare' && input.directoryName !== 'basic' && (input.payload.baseline ?? input.task.payload.baseline)) {
|
|
42
76
|
const baseline = record(input.payload.baseline ?? input.task.payload.baseline);
|
|
43
77
|
const authority = record(input.payload.workspace);
|
|
44
78
|
const sourceCommit = baseline.commitHash;
|
|
@@ -46,28 +80,21 @@ export async function executeProductionStage(state, input, processRecord, track)
|
|
|
46
80
|
throw new Error('production_approved_commit_required');
|
|
47
81
|
const directory = { workspaceId, directoryName: input.directoryName,
|
|
48
82
|
remixId: String(authority.remixId), branchName: String(authority.branchName), sourceCommit };
|
|
49
|
-
await registerWorkspaceDirectory(state, directory, state.acceptedLeaseFence);
|
|
83
|
+
await registerWorkspaceDirectory(state, directory, state.acceptedLeaseFence, sourceDirectoryName);
|
|
50
84
|
}
|
|
51
85
|
const root = await workspaceRoot(state, workspaceId, input.directoryName);
|
|
52
|
-
if (stage === 'capture-source') {
|
|
53
|
-
if (input.directoryName !== 'basic')
|
|
54
|
-
throw new Error('production_source_directory_mismatch');
|
|
55
|
-
if (workspaceSessions(state, workspaceId, 'basic').some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
|
|
56
|
-
throw new Error('production_source_busy');
|
|
57
|
-
await settleDependencyInstalls(state);
|
|
58
|
-
await resolveProductionDependencies(root);
|
|
59
|
-
await excludeFromGit(root);
|
|
60
|
-
await excludeFromGit(root, '/.env*');
|
|
61
|
-
await git(root, 'config', 'user.name', 'PlayCraft');
|
|
62
|
-
await git(root, 'config', 'user.email', 'playcraft@localhost');
|
|
63
|
-
await run(root, 'git -c core.hooksPath=/dev/null add -A . && (git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Preserve basic before common repair")', undefined, true);
|
|
64
|
-
const commitHash = await git(root, 'rev-parse', 'HEAD');
|
|
65
|
-
const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-capture-source` });
|
|
66
|
-
return { sourceSnapshot: snapshot, commitHash };
|
|
67
|
-
}
|
|
68
86
|
if (stage === 'prepare') {
|
|
69
87
|
await settleDependencyInstalls(state);
|
|
70
|
-
|
|
88
|
+
if (!(await lstat(join(root, 'package.json')).catch(() => undefined)))
|
|
89
|
+
return { prepared: true, directoryName: input.directoryName, emptySource: true };
|
|
90
|
+
const policy = await resolveProductionDependencies(root).catch(error => {
|
|
91
|
+
if (!(input.payload.baseline ?? input.task.payload.baseline) && error instanceof Error
|
|
92
|
+
&& error.message === 'production_dependency_lock_required')
|
|
93
|
+
return undefined;
|
|
94
|
+
throw error;
|
|
95
|
+
});
|
|
96
|
+
if (!policy)
|
|
97
|
+
return { prepared: true, directoryName: input.directoryName, dependencyRepairRequired: true };
|
|
71
98
|
await run(root, process.env.PLAYCRAFT_PRODUCTION_INSTALL_COMMAND ?? 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
|
|
72
99
|
await verifyProductionDependencyLocks(root, policy);
|
|
73
100
|
return { prepared: true, directoryName: input.directoryName };
|
|
@@ -19,6 +19,7 @@ import { recoverInterruptedPreviews } from './previews/publish.js';
|
|
|
19
19
|
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
|
+
import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
22
23
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
23
24
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
24
25
|
const negotiated = negotiateProtocol(config);
|
|
@@ -63,6 +64,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
63
64
|
gitCredentials = state.gitCredentials = new WorkspaceGitCredentialBroker(state);
|
|
64
65
|
await gitCredentials.start();
|
|
65
66
|
}
|
|
67
|
+
const stopArtifactObserver = observeWorkspaceArtifacts(state);
|
|
66
68
|
attachNativeCollector(state);
|
|
67
69
|
native?.onUnexpectedExit?.((event) => {
|
|
68
70
|
void handleExecutionGroupCrash(state, event.pid);
|
|
@@ -134,6 +136,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
134
136
|
/* adapter shutdown is best-effort */
|
|
135
137
|
}
|
|
136
138
|
await serverClosed;
|
|
139
|
+
await stopArtifactObserver();
|
|
137
140
|
await journal?.close();
|
|
138
141
|
lock.release();
|
|
139
142
|
}
|