@margay/ccl-core 1.5.0-alpha.4 → 1.5.1-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +69586 -64065
- package/dist/sdk/background.d.ts +48 -0
- package/dist/sdk/background.js +87 -0
- package/dist/sdk/backgroundRunTypes.d.ts +101 -0
- package/dist/sdk/backgroundRunTypes.js +1 -0
- package/dist/sdk/commandAdapter.d.ts +77 -0
- package/dist/sdk/commandAdapter.js +180 -0
- package/dist/sdk/commandProcess.d.ts +27 -0
- package/dist/sdk/commandProcess.js +130 -0
- package/dist/sdk/executionGuidance.d.ts +7 -0
- package/dist/sdk/executionGuidance.js +37 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/sdk/index.js +9 -0
- package/dist/sdk/mcpServer.d.ts +9 -5
- package/dist/sdk/mcpServer.js +51 -9
- package/dist/sdk/operations/command.d.ts +24 -0
- package/dist/sdk/operations/command.js +48 -0
- package/dist/sdk/operations/host.d.ts +3 -0
- package/dist/sdk/operations/host.js +376 -0
- package/dist/sdk/operations/json.d.ts +6 -0
- package/dist/sdk/operations/json.js +24 -0
- package/dist/sdk/operations/reference.d.ts +15 -0
- package/dist/sdk/operations/reference.js +19 -0
- package/dist/sdk/operations/store.d.ts +26 -0
- package/dist/sdk/operations/store.js +300 -0
- package/dist/sdk/operations/types.d.ts +171 -0
- package/dist/sdk/operations/types.js +8 -0
- package/dist/sdk/pendingApprovalIdentity.d.ts +35 -0
- package/dist/sdk/pendingApprovalIdentity.js +27 -0
- package/dist/sdk/permissionDelegation.d.ts +19 -0
- package/dist/sdk/permissionDelegation.js +11 -0
- package/dist/sdk/query.d.ts +2 -1
- package/dist/sdk/query.js +133 -30
- package/dist/sdk/queryLifecycle.d.ts +19 -0
- package/dist/sdk/queryLifecycle.js +93 -0
- package/dist/sdk/queryLifecycleTypes.d.ts +160 -0
- package/dist/sdk/queryLifecycleTypes.js +47 -0
- package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
- package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
- package/dist/sdk/types.d.ts +17 -2
- package/docs/background-runs.md +229 -0
- package/docs/command-tool-recovery.md +62 -0
- package/docs/dev-quickstart.md +29 -7
- package/docs/error-recovery-usage.md +18 -0
- package/docs/hook-input-provenance.md +77 -0
- package/docs/index.md +2 -0
- package/docs/mcp-tool-recovery.md +82 -0
- package/docs/releases/1.5.0-alpha.5.md +26 -0
- package/docs/releases/1.5.0-alpha.6.md +15 -0
- package/docs/releases/1.5.1-alpha.1.md +11 -0
- package/docs/releases/1.5.1-alpha.2.md +17 -0
- package/docs/releases/1.5.1-alpha.md +22 -0
- package/docs/runtime-resources.md +6 -3
- package/examples/background-runs/agents/background-reviewer.md +6 -0
- package/examples/background-runs/agents/background-worker.md +6 -0
- package/examples/background-runs/worker-reviewer.mjs +42 -0
- package/examples/command-recovery/durable-server.mjs +44 -0
- package/examples/command-recovery/receipt-command.mjs +40 -0
- package/package.json +16 -2
- package/sdk/API.md +146 -0
- package/sdk/README.md +6 -0
package/dist/sdk/query.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { approvalInputDigest, pendingApprovalIdentity, pendingApprovalSuspensionResponseSchema, pendingApprovalIdentitySchema, pendingApprovalConflictSchema, samePendingApproval } from './pendingApprovalIdentity.js';
|
|
3
|
+
import { QueryLifecycleObservationSchema } from './queryLifecycleTypes.js';
|
|
4
|
+
import { readOperationReference } from './operations/reference.js';
|
|
1
5
|
import { sanitizeRequestProgress } from './requestProgress.js';
|
|
2
6
|
/**
|
|
3
7
|
* ccl-sdk query() — the programmatic headless entry (issue P0-1) with the
|
|
@@ -73,8 +77,17 @@ export function buildCliArgs(options) {
|
|
|
73
77
|
args.push('--max-turns', String(options.maxTurns));
|
|
74
78
|
if (options.maxBudgetUsd !== undefined)
|
|
75
79
|
args.push('--max-budget-usd', String(options.maxBudgetUsd));
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
const permissionMode = options.permissionDelegation?.mode ?? options.permissionMode;
|
|
81
|
+
if (permissionMode)
|
|
82
|
+
args.push('--permission-mode', permissionMode);
|
|
83
|
+
if (options.permissionCallbackMode)
|
|
84
|
+
args.push('--permission-callback-mode', options.permissionCallbackMode);
|
|
85
|
+
if (options.permissionDelegation)
|
|
86
|
+
args.push('--require-permission-delegation');
|
|
87
|
+
if (options.pendingDecision)
|
|
88
|
+
args.push('--resume-pending-approval');
|
|
89
|
+
if (options.pendingDecision?.expectedApproval)
|
|
90
|
+
args.push('--strict-pending-resume');
|
|
78
91
|
for (const dir of options.pluginDirs ?? [])
|
|
79
92
|
args.push('--plugin-dir', dir);
|
|
80
93
|
if (options.addDirs?.length)
|
|
@@ -282,6 +295,9 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
282
295
|
let turn = 0;
|
|
283
296
|
let sawResult = false;
|
|
284
297
|
let sessionEnded = false;
|
|
298
|
+
let suspension;
|
|
299
|
+
let suspensionTimer;
|
|
300
|
+
let suspensionTimedOut = false;
|
|
285
301
|
const streamingInput = isAsyncInput(prompt);
|
|
286
302
|
const inputState = { finished: false };
|
|
287
303
|
let resolveProvenanceSupport;
|
|
@@ -310,11 +326,27 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
310
326
|
if (!options.resume) {
|
|
311
327
|
throw new Error('ccl-sdk: pendingDecision requires options.resume');
|
|
312
328
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
329
|
+
try {
|
|
330
|
+
pendingApproval = await store.loadPendingApproval(options.resume);
|
|
331
|
+
if (!pendingApproval)
|
|
332
|
+
throw new Error(`ccl-sdk: no pending approval recorded for session ${options.resume}`);
|
|
333
|
+
if (pendingApproval.suspension && pendingApproval.suspension !== 'confirmed')
|
|
334
|
+
throw new Error('permission_suspension_unconfirmed: the CLI did not confirm a durable suspension; execution status must be checked before resuming');
|
|
335
|
+
if (pendingDecision.expectedApproval) {
|
|
336
|
+
const expected = pendingApprovalIdentitySchema.parse(pendingDecision.expectedApproval);
|
|
337
|
+
if (!samePendingApproval(expected, pendingApproval) || expected.sessionId !== options.resume)
|
|
338
|
+
throw new Error('resume_approval_mismatch: saved approval does not match the decision identity');
|
|
339
|
+
if (pendingDecision.behavior === 'allow' && pendingDecision.updatedInput && approvalInputDigest(pendingDecision.updatedInput) !== expected.inputDigest)
|
|
340
|
+
throw new Error('resume_approval_mismatch: strict approval cannot substitute different input');
|
|
341
|
+
pendingDecision = { ...pendingDecision, expectedApproval: expected };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
await transport.close();
|
|
346
|
+
throw error;
|
|
316
347
|
}
|
|
317
348
|
}
|
|
349
|
+
const strictApprovalEvidence = pendingDecision?.expectedApproval ? pendingApproval : null;
|
|
318
350
|
const onAbort = () => { resolveProvenanceSupport(false); void transport.close(); };
|
|
319
351
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
320
352
|
if (options.signal?.aborted)
|
|
@@ -342,6 +374,9 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
342
374
|
request_id: initId,
|
|
343
375
|
request: {
|
|
344
376
|
subtype: 'initialize',
|
|
377
|
+
...(pendingApproval ? { pendingResumeApproval: { approval: pendingApprovalIdentity(pendingApproval), confirmed: pendingApproval.suspension === 'confirmed' } } : {}),
|
|
378
|
+
...(options.permissionDelegation ? { permissionDelegation: options.permissionDelegation } : {}),
|
|
379
|
+
...(pendingDecision?.expectedApproval ? { strictPendingApproval: pendingDecision.expectedApproval } : {}),
|
|
345
380
|
...(options.systemPrompt !== undefined && {
|
|
346
381
|
systemPrompt: options.systemPrompt,
|
|
347
382
|
}),
|
|
@@ -385,7 +420,7 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
385
420
|
await runHook(hooks.onUserPromptSubmit && (() => hooks.onUserPromptSubmit({ prompt: text, sessionId: lastSessionId })));
|
|
386
421
|
})
|
|
387
422
|
: transport.read();
|
|
388
|
-
for await (const rawMessage of messages) {
|
|
423
|
+
permissionWire: for await (const rawMessage of messages) {
|
|
389
424
|
let attribution = rawMessage.input_uuid === null
|
|
390
425
|
? { origin: 'background' } : typeof rawMessage.input_uuid === 'string'
|
|
391
426
|
? { origin: 'user', turnId: rawMessage.input_uuid }
|
|
@@ -416,6 +451,13 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
416
451
|
yield { ...attribution, type: 'raw', message };
|
|
417
452
|
switch (message.type) {
|
|
418
453
|
case 'system': {
|
|
454
|
+
if (message.subtype === 'query_lifecycle') {
|
|
455
|
+
const parsed = QueryLifecycleObservationSchema.safeParse(message.lifecycle);
|
|
456
|
+
if (parsed.success && parsed.data.record.session_id === message.session_id && parsed.data.record.event_id === message.uuid)
|
|
457
|
+
yield {
|
|
458
|
+
...attribution, type: 'query_lifecycle', lifecycle: parsed.data, sessionId: parsed.data.record.session_id,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
419
461
|
if (message.subtype === 'request_progress' && progress)
|
|
420
462
|
yield { ...attribution,
|
|
421
463
|
type: 'request_progress', progress,
|
|
@@ -463,6 +505,7 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
463
505
|
for (const block of contentBlocks(message.message)) {
|
|
464
506
|
if (block.type === 'tool_result') {
|
|
465
507
|
const toolUseId = String(block.tool_use_id ?? '');
|
|
508
|
+
const operationReference = readOperationReference(message.operation_reference, toolUseId);
|
|
466
509
|
const isError = block.is_error === true;
|
|
467
510
|
const resultContent = isError
|
|
468
511
|
? typeof block.content === 'string' ? sanitizeErrorText(block.content)
|
|
@@ -473,6 +516,7 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
473
516
|
type: 'tool_result',
|
|
474
517
|
toolUseId,
|
|
475
518
|
content: resultContent,
|
|
519
|
+
...(operationReference ? { operationReference } : {}),
|
|
476
520
|
isError,
|
|
477
521
|
sessionId,
|
|
478
522
|
};
|
|
@@ -481,6 +525,7 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
481
525
|
toolUseId,
|
|
482
526
|
toolName: toolNamesById.get(toolUseId),
|
|
483
527
|
content: resultContent,
|
|
528
|
+
...(operationReference ? { operationReference } : {}),
|
|
484
529
|
isError,
|
|
485
530
|
sessionId,
|
|
486
531
|
})));
|
|
@@ -491,6 +536,10 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
491
536
|
break;
|
|
492
537
|
}
|
|
493
538
|
case 'control_request': {
|
|
539
|
+
// Suspension stops the whole query; never answer another concurrent
|
|
540
|
+
// tool while waiting for the targeted CLI acknowledgement.
|
|
541
|
+
if (suspension)
|
|
542
|
+
break;
|
|
494
543
|
const requestId = String(message.request_id ?? '');
|
|
495
544
|
const request = (message.request ?? {});
|
|
496
545
|
// slice sdk-in-process-mcp-server: route MCP tool calls to host in-process servers.
|
|
@@ -535,9 +584,14 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
535
584
|
agentId,
|
|
536
585
|
};
|
|
537
586
|
// Resume path: settle the recorded approval on its matching call.
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
587
|
+
// Strict callers require the same tool and structured input. Legacy
|
|
588
|
+
// callers retain name-only matching and recorded-input substitution.
|
|
589
|
+
if (pendingDecision?.expectedApproval && (toolName !== pendingDecision.expectedApproval.toolName || approvalInputDigest(input) !== pendingDecision.expectedApproval.inputDigest)) {
|
|
590
|
+
yield { ...attribution, type: 'error', code: 'resume_approval_mismatch', fatal: true, message: 'Regenerated tool/input differs from the original approved invocation' };
|
|
591
|
+
// Unwind the read iterator before finally awaits close; otherwise
|
|
592
|
+
// unread stdout can keep the child's close event waiting forever.
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
541
595
|
if (pendingDecision && pendingApproval?.toolName === toolName) {
|
|
542
596
|
const decision = pendingDecision.behavior === 'allow'
|
|
543
597
|
? {
|
|
@@ -620,28 +674,16 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
620
674
|
toolName,
|
|
621
675
|
input,
|
|
622
676
|
createdAt: new Date().toISOString(),
|
|
677
|
+
suspension: 'requested',
|
|
623
678
|
};
|
|
624
679
|
await store.savePendingApproval(record);
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
requestId,
|
|
633
|
-
toolUseId,
|
|
634
|
-
toolName,
|
|
635
|
-
input,
|
|
636
|
-
};
|
|
637
|
-
sessionEnded = true;
|
|
638
|
-
await runHook(hooks.onSessionEnd &&
|
|
639
|
-
(() => hooks.onSessionEnd({
|
|
640
|
-
sessionId,
|
|
641
|
-
subtype: 'suspended',
|
|
642
|
-
isError: false,
|
|
643
|
-
})));
|
|
644
|
-
return;
|
|
680
|
+
// A saved SDK record alone does not prove the CLI stopped without
|
|
681
|
+
// converting EOF into a denial. Ask it to freeze and persist first.
|
|
682
|
+
suspension = { requestId: randomUUID(), record, attribution, confirmed: false };
|
|
683
|
+
suspensionTimer = setTimeout(() => { suspensionTimedOut = true; void transport.close(); }, 10000);
|
|
684
|
+
await transport.write({ type: 'control_request', request_id: suspension.requestId,
|
|
685
|
+
request: { subtype: 'suspend_permission', approval: pendingApprovalIdentity(record) } });
|
|
686
|
+
break;
|
|
645
687
|
}
|
|
646
688
|
await transport.write({
|
|
647
689
|
type: 'control_response',
|
|
@@ -667,6 +709,20 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
667
709
|
case 'control_response': {
|
|
668
710
|
const response = (message.response ?? {});
|
|
669
711
|
const requestId = String(response.request_id ?? '');
|
|
712
|
+
if (suspension?.requestId === requestId) {
|
|
713
|
+
const ack = pendingApprovalSuspensionResponseSchema.safeParse(response.response);
|
|
714
|
+
if (response.subtype !== 'success' || !ack.success || !samePendingApproval(ack.data.approval, suspension.record)) {
|
|
715
|
+
yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
|
|
716
|
+
message: `CLI did not confirm pending approval suspension: ${String(response.error ?? 'invalid acknowledgement')}` };
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
suspension.record = { ...suspension.record, suspension: 'confirmed' };
|
|
720
|
+
await store.savePendingApproval(suspension.record);
|
|
721
|
+
suspension.confirmed = true;
|
|
722
|
+
if (suspensionTimer)
|
|
723
|
+
clearTimeout(suspensionTimer);
|
|
724
|
+
break permissionWire;
|
|
725
|
+
}
|
|
670
726
|
if (ownRequestIds.has(requestId)) {
|
|
671
727
|
ownRequestIds.delete(requestId);
|
|
672
728
|
const capability = response.response?.input_provenance;
|
|
@@ -683,6 +739,34 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
683
739
|
break;
|
|
684
740
|
}
|
|
685
741
|
case 'result': {
|
|
742
|
+
if (suspension && !suspension.confirmed) {
|
|
743
|
+
yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
|
|
744
|
+
message: 'CLI ended its turn before confirming permission suspension; inspect execution status' };
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const conflict = pendingApprovalConflictSchema.safeParse(message.permission_resume_conflict);
|
|
748
|
+
if (strictApprovalEvidence && conflict.success && samePendingApproval(conflict.data.approval, strictApprovalEvidence) && message.session_id === strictApprovalEvidence.sessionId) {
|
|
749
|
+
if (conflict.data.originalNotExecuted) {
|
|
750
|
+
// The CLI guard proves this invocation never reached execution. Restore
|
|
751
|
+
// evidence removed on response; never overwrite a newer pending request.
|
|
752
|
+
const current = await store.loadPendingApproval(strictApprovalEvidence.sessionId);
|
|
753
|
+
if (!current)
|
|
754
|
+
await store.savePendingApproval(strictApprovalEvidence);
|
|
755
|
+
}
|
|
756
|
+
yield { ...attribution, type: 'error', code: conflict.data.code, fatal: true,
|
|
757
|
+
message: errorDetailsList(message.errors).join('; ') || 'Strict approval conflict before execution' };
|
|
758
|
+
// Unwind the read iterator before finally awaits close; otherwise
|
|
759
|
+
// unread stdout can keep the child's close event waiting forever.
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (pendingDecision?.expectedApproval) {
|
|
763
|
+
yield { ...attribution, type: 'error', code: 'resume_approval_mismatch', fatal: true,
|
|
764
|
+
message: 'Strict resume ended without settling the original approval',
|
|
765
|
+
};
|
|
766
|
+
// Unwind the read iterator before finally awaits close; otherwise
|
|
767
|
+
// unread stdout can keep the child's close event waiting forever.
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
686
770
|
sawResult = true;
|
|
687
771
|
const sessionId = message.session_id;
|
|
688
772
|
const usage = message.usage;
|
|
@@ -756,6 +840,22 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
756
840
|
if (sawResult && !streamingInput)
|
|
757
841
|
break;
|
|
758
842
|
}
|
|
843
|
+
if (suspension) {
|
|
844
|
+
if (!suspension.confirmed) {
|
|
845
|
+
yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
|
|
846
|
+
message: suspensionTimedOut ? 'Timed out waiting for CLI permission suspension acknowledgement' : 'CLI exited without confirming permission suspension' };
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
// The read iterator has unwound. Stop the original process before exposing
|
|
850
|
+
// a resumable event, so a consumer can immediately resume in a fresh CLI.
|
|
851
|
+
await transport.close();
|
|
852
|
+
const record = suspension.record;
|
|
853
|
+
yield { ...suspension.attribution, type: 'suspended', sessionId: record.sessionId,
|
|
854
|
+
requestId: record.requestId, toolUseId: record.toolUseId, toolName: record.toolName, input: record.input };
|
|
855
|
+
sessionEnded = true;
|
|
856
|
+
await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({ sessionId: record.sessionId, subtype: 'suspended', isError: false })));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
759
859
|
if (streamingInput && inputState.finished) {
|
|
760
860
|
await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({
|
|
761
861
|
sessionId: lastSessionId, subtype: 'input_complete', isError: false,
|
|
@@ -783,6 +883,8 @@ export async function* queryWithTransport(prompt, options, transport) {
|
|
|
783
883
|
}
|
|
784
884
|
}
|
|
785
885
|
finally {
|
|
886
|
+
if (suspensionTimer)
|
|
887
|
+
clearTimeout(suspensionTimer);
|
|
786
888
|
resolveProvenanceSupport(false);
|
|
787
889
|
options.signal?.removeEventListener('abort', onAbort);
|
|
788
890
|
await sdkMcpRegistry?.close();
|
|
@@ -858,7 +960,8 @@ export function query(prompt, options = {}) {
|
|
|
858
960
|
*
|
|
859
961
|
* On allow, the suspended tool executes with exactly the recorded input (or
|
|
860
962
|
* decision.updatedInput if the owner amended it); on deny the model receives
|
|
861
|
-
* the denial and the session continues.
|
|
963
|
+
* the denial and the session continues. With expectedApproval, both the saved
|
|
964
|
+
* identity and regenerated input must match; substitution is not permitted.
|
|
862
965
|
*/
|
|
863
966
|
export function resumeWithDecision(sessionId, decision, options = {}) {
|
|
864
967
|
const merged = {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type QueryLifecycleRecord } from './queryLifecycleTypes.js';
|
|
2
|
+
export type QueryLifecycleRead = {
|
|
3
|
+
state: 'found';
|
|
4
|
+
completeness: 'complete' | 'incomplete';
|
|
5
|
+
records: QueryLifecycleRecord[];
|
|
6
|
+
terminal?: QueryLifecycleRecord;
|
|
7
|
+
} | {
|
|
8
|
+
state: 'not_found';
|
|
9
|
+
} | {
|
|
10
|
+
state: 'unavailable';
|
|
11
|
+
reason: 'file_unavailable' | 'invalid_record' | 'truncated_tail' | 'record_too_large' | 'conflicting_events';
|
|
12
|
+
};
|
|
13
|
+
/** Read one caller-specified file. Absence is never proof that a tool did not run.
|
|
14
|
+
* No session discovery, model invocation, daemon startup or repair is performed. */
|
|
15
|
+
export declare function readQueryLifecycle(input: {
|
|
16
|
+
transcriptPath: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
queryId: string;
|
|
19
|
+
}): Promise<QueryLifecycleRead>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { QueryLifecycleSchema } from './queryLifecycleTypes.js';
|
|
3
|
+
/** Read one caller-specified file. Absence is never proof that a tool did not run.
|
|
4
|
+
* No session discovery, model invocation, daemon startup or repair is performed. */
|
|
5
|
+
export async function readQueryLifecycle(input) {
|
|
6
|
+
const records = [];
|
|
7
|
+
const events = new Map();
|
|
8
|
+
let failure;
|
|
9
|
+
let pending = '';
|
|
10
|
+
const stream = createReadStream(input.transcriptPath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
|
11
|
+
const consume = (line) => {
|
|
12
|
+
if (!line.trim())
|
|
13
|
+
return;
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = JSON.parse(line);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
failure = { state: 'unavailable', reason: 'invalid_record' };
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
23
|
+
failure = { state: 'unavailable', reason: 'invalid_record' };
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const entry = raw;
|
|
27
|
+
if (entry.type !== 'query_lifecycle')
|
|
28
|
+
return;
|
|
29
|
+
if (entry.record?.session_id !== input.sessionId || entry.record?.query_id !== input.queryId)
|
|
30
|
+
return;
|
|
31
|
+
const parsed = QueryLifecycleSchema.safeParse(entry.record);
|
|
32
|
+
if (!parsed.success) {
|
|
33
|
+
failure = { state: 'unavailable', reason: 'invalid_record' };
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const record = parsed.data;
|
|
37
|
+
const serialized = JSON.stringify(record);
|
|
38
|
+
const duplicate = events.get(record.event_id);
|
|
39
|
+
if (duplicate !== undefined) {
|
|
40
|
+
if (duplicate !== serialized)
|
|
41
|
+
failure = { state: 'unavailable', reason: 'conflicting_events' };
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
events.set(record.event_id, serialized);
|
|
45
|
+
records.push(record);
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
for await (const chunk of stream) {
|
|
49
|
+
pending += chunk;
|
|
50
|
+
let index;
|
|
51
|
+
while ((index = pending.indexOf('\n')) !== -1) {
|
|
52
|
+
if (index > 32 * 1024 * 1024)
|
|
53
|
+
return { state: 'unavailable', reason: 'record_too_large' };
|
|
54
|
+
consume(pending.slice(0, index));
|
|
55
|
+
if (failure)
|
|
56
|
+
return failure;
|
|
57
|
+
pending = pending.slice(index + 1);
|
|
58
|
+
}
|
|
59
|
+
// Protect against an unbounded unterminated line, including ordinary
|
|
60
|
+
// conversation records. Large files remain streamed rather than loaded.
|
|
61
|
+
if (pending.length > 32 * 1024 * 1024)
|
|
62
|
+
return { state: 'unavailable', reason: 'record_too_large' };
|
|
63
|
+
}
|
|
64
|
+
if (pending.trim())
|
|
65
|
+
return { state: 'unavailable', reason: 'truncated_tail' };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { state: 'unavailable', reason: 'file_unavailable' };
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
stream.destroy();
|
|
72
|
+
}
|
|
73
|
+
if (!records.length)
|
|
74
|
+
return { state: 'not_found' };
|
|
75
|
+
let previous;
|
|
76
|
+
let completeSequence = records[0].phase === 'start';
|
|
77
|
+
for (const record of records) {
|
|
78
|
+
if (previous) {
|
|
79
|
+
if (previous.phase === 'terminal' || record.sequence <= previous.sequence || record.phase === 'start'
|
|
80
|
+
|| record.scope !== previous.scope || record.agent_id !== previous.agent_id
|
|
81
|
+
|| previous.accepted_inputs.some(p => !record.accepted_inputs.some(n => n.message_id === p.message_id && JSON.stringify(n) === JSON.stringify(p)))
|
|
82
|
+
|| previous.input_message_ids.some(id => !record.input_message_ids.includes(id))) {
|
|
83
|
+
return { state: 'unavailable', reason: 'conflicting_events' };
|
|
84
|
+
}
|
|
85
|
+
if (record.sequence !== previous.sequence + 1)
|
|
86
|
+
completeSequence = false;
|
|
87
|
+
}
|
|
88
|
+
previous = record;
|
|
89
|
+
}
|
|
90
|
+
const terminal = previous?.phase === 'terminal' ? previous : undefined;
|
|
91
|
+
return { state: 'found', completeness: terminal && completeSequence ? 'complete' : 'incomplete', records,
|
|
92
|
+
...(terminal && completeSequence ? { terminal } : {}) };
|
|
93
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
/** Host observations of one query loop. Neither an authorization nor a business result. */
|
|
3
|
+
export declare const QueryLifecycleSchema: z.ZodObject<{
|
|
4
|
+
version: z.ZodLiteral<1>;
|
|
5
|
+
event_id: z.ZodString;
|
|
6
|
+
query_id: z.ZodString;
|
|
7
|
+
session_id: z.ZodString;
|
|
8
|
+
sequence: z.ZodNumber;
|
|
9
|
+
timestamp: z.ZodString;
|
|
10
|
+
scope: z.ZodEnum<{
|
|
11
|
+
main: "main";
|
|
12
|
+
subagent: "subagent";
|
|
13
|
+
auxiliary: "auxiliary";
|
|
14
|
+
}>;
|
|
15
|
+
agent_id: z.ZodNullable<z.ZodString>;
|
|
16
|
+
phase: z.ZodEnum<{
|
|
17
|
+
start: "start";
|
|
18
|
+
stop_attempt: "stop_attempt";
|
|
19
|
+
stop_failure: "stop_failure";
|
|
20
|
+
terminal: "terminal";
|
|
21
|
+
}>;
|
|
22
|
+
attempt: z.ZodNumber;
|
|
23
|
+
accepted_inputs: z.ZodArray<z.ZodObject<{
|
|
24
|
+
message_id: z.ZodString;
|
|
25
|
+
origin: z.ZodString;
|
|
26
|
+
provenance: z.ZodOptional<z.ZodObject<{
|
|
27
|
+
version: z.ZodLiteral<1>;
|
|
28
|
+
actor: z.ZodEnum<{
|
|
29
|
+
unknown: "unknown";
|
|
30
|
+
agent: "agent";
|
|
31
|
+
human: "human";
|
|
32
|
+
host: "host";
|
|
33
|
+
}>;
|
|
34
|
+
source: z.ZodString;
|
|
35
|
+
session_id: z.ZodOptional<z.ZodString>;
|
|
36
|
+
request_id: z.ZodOptional<z.ZodString>;
|
|
37
|
+
}, z.core.$strip>>;
|
|
38
|
+
}, z.core.$strip>>;
|
|
39
|
+
input_message_ids: z.ZodArray<z.ZodString>;
|
|
40
|
+
input_association: z.ZodEnum<{
|
|
41
|
+
unknown: "unknown";
|
|
42
|
+
known: "known";
|
|
43
|
+
}>;
|
|
44
|
+
last_assistant_message_id: z.ZodNullable<z.ZodString>;
|
|
45
|
+
status: z.ZodNullable<z.ZodEnum<{
|
|
46
|
+
completed: "completed";
|
|
47
|
+
failed: "failed";
|
|
48
|
+
cancelled: "cancelled";
|
|
49
|
+
interrupted: "interrupted";
|
|
50
|
+
stopped: "stopped";
|
|
51
|
+
}>>;
|
|
52
|
+
terminal_reason: z.ZodNullable<z.ZodString>;
|
|
53
|
+
}, z.core.$strip>;
|
|
54
|
+
export type QueryLifecycleRecord = z.infer<typeof QueryLifecycleSchema>;
|
|
55
|
+
export type QueryLifecycleWrite = {
|
|
56
|
+
state: 'saved';
|
|
57
|
+
} | {
|
|
58
|
+
state: 'unavailable';
|
|
59
|
+
reason: 'persistence_disabled' | 'no_transcript';
|
|
60
|
+
} | {
|
|
61
|
+
state: 'failed';
|
|
62
|
+
reason: 'write_failed';
|
|
63
|
+
} | {
|
|
64
|
+
state: 'unknown';
|
|
65
|
+
reason: 'write_timeout';
|
|
66
|
+
};
|
|
67
|
+
export declare const QueryLifecycleWriteSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
68
|
+
state: z.ZodLiteral<"saved">;
|
|
69
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
70
|
+
state: z.ZodLiteral<"unavailable">;
|
|
71
|
+
reason: z.ZodEnum<{
|
|
72
|
+
persistence_disabled: "persistence_disabled";
|
|
73
|
+
no_transcript: "no_transcript";
|
|
74
|
+
}>;
|
|
75
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
76
|
+
state: z.ZodLiteral<"failed">;
|
|
77
|
+
reason: z.ZodLiteral<"write_failed">;
|
|
78
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
79
|
+
state: z.ZodLiteral<"unknown">;
|
|
80
|
+
reason: z.ZodLiteral<"write_timeout">;
|
|
81
|
+
}, z.core.$strip>]>;
|
|
82
|
+
export type QueryLifecycleObservation = {
|
|
83
|
+
record: QueryLifecycleRecord;
|
|
84
|
+
persistence: QueryLifecycleWrite;
|
|
85
|
+
transcript_path: string | null;
|
|
86
|
+
};
|
|
87
|
+
export declare const QueryLifecycleObservationSchema: z.ZodObject<{
|
|
88
|
+
record: z.ZodObject<{
|
|
89
|
+
version: z.ZodLiteral<1>;
|
|
90
|
+
event_id: z.ZodString;
|
|
91
|
+
query_id: z.ZodString;
|
|
92
|
+
session_id: z.ZodString;
|
|
93
|
+
sequence: z.ZodNumber;
|
|
94
|
+
timestamp: z.ZodString;
|
|
95
|
+
scope: z.ZodEnum<{
|
|
96
|
+
main: "main";
|
|
97
|
+
subagent: "subagent";
|
|
98
|
+
auxiliary: "auxiliary";
|
|
99
|
+
}>;
|
|
100
|
+
agent_id: z.ZodNullable<z.ZodString>;
|
|
101
|
+
phase: z.ZodEnum<{
|
|
102
|
+
start: "start";
|
|
103
|
+
stop_attempt: "stop_attempt";
|
|
104
|
+
stop_failure: "stop_failure";
|
|
105
|
+
terminal: "terminal";
|
|
106
|
+
}>;
|
|
107
|
+
attempt: z.ZodNumber;
|
|
108
|
+
accepted_inputs: z.ZodArray<z.ZodObject<{
|
|
109
|
+
message_id: z.ZodString;
|
|
110
|
+
origin: z.ZodString;
|
|
111
|
+
provenance: z.ZodOptional<z.ZodObject<{
|
|
112
|
+
version: z.ZodLiteral<1>;
|
|
113
|
+
actor: z.ZodEnum<{
|
|
114
|
+
unknown: "unknown";
|
|
115
|
+
agent: "agent";
|
|
116
|
+
human: "human";
|
|
117
|
+
host: "host";
|
|
118
|
+
}>;
|
|
119
|
+
source: z.ZodString;
|
|
120
|
+
session_id: z.ZodOptional<z.ZodString>;
|
|
121
|
+
request_id: z.ZodOptional<z.ZodString>;
|
|
122
|
+
}, z.core.$strip>>;
|
|
123
|
+
}, z.core.$strip>>;
|
|
124
|
+
input_message_ids: z.ZodArray<z.ZodString>;
|
|
125
|
+
input_association: z.ZodEnum<{
|
|
126
|
+
unknown: "unknown";
|
|
127
|
+
known: "known";
|
|
128
|
+
}>;
|
|
129
|
+
last_assistant_message_id: z.ZodNullable<z.ZodString>;
|
|
130
|
+
status: z.ZodNullable<z.ZodEnum<{
|
|
131
|
+
completed: "completed";
|
|
132
|
+
failed: "failed";
|
|
133
|
+
cancelled: "cancelled";
|
|
134
|
+
interrupted: "interrupted";
|
|
135
|
+
stopped: "stopped";
|
|
136
|
+
}>>;
|
|
137
|
+
terminal_reason: z.ZodNullable<z.ZodString>;
|
|
138
|
+
}, z.core.$strip>;
|
|
139
|
+
persistence: z.ZodUnion<readonly [z.ZodObject<{
|
|
140
|
+
state: z.ZodLiteral<"saved">;
|
|
141
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
142
|
+
state: z.ZodLiteral<"unavailable">;
|
|
143
|
+
reason: z.ZodEnum<{
|
|
144
|
+
persistence_disabled: "persistence_disabled";
|
|
145
|
+
no_transcript: "no_transcript";
|
|
146
|
+
}>;
|
|
147
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
148
|
+
state: z.ZodLiteral<"failed">;
|
|
149
|
+
reason: z.ZodLiteral<"write_failed">;
|
|
150
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
151
|
+
state: z.ZodLiteral<"unknown">;
|
|
152
|
+
reason: z.ZodLiteral<"write_timeout">;
|
|
153
|
+
}, z.core.$strip>]>;
|
|
154
|
+
transcript_path: z.ZodNullable<z.ZodString>;
|
|
155
|
+
}, z.core.$strip>;
|
|
156
|
+
/** Metadata entry deliberately has no conversation uuid/parentUuid chain fields. */
|
|
157
|
+
export type QueryLifecycleEntry = {
|
|
158
|
+
type: 'query_lifecycle';
|
|
159
|
+
record: QueryLifecycleRecord;
|
|
160
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
const identity = z.string().min(1).max(256);
|
|
3
|
+
const provenance = z.object({
|
|
4
|
+
version: z.literal(1), actor: z.enum(['human', 'host', 'agent', 'unknown']),
|
|
5
|
+
source: z.string(), session_id: z.string().optional(), request_id: z.string().optional(),
|
|
6
|
+
});
|
|
7
|
+
/** Host observations of one query loop. Neither an authorization nor a business result. */
|
|
8
|
+
export const QueryLifecycleSchema = z.object({
|
|
9
|
+
version: z.literal(1), event_id: identity, query_id: identity, session_id: identity,
|
|
10
|
+
sequence: z.number().int().nonnegative(), timestamp: z.string(),
|
|
11
|
+
scope: z.enum(['main', 'subagent', 'auxiliary']), agent_id: identity.nullable(),
|
|
12
|
+
phase: z.enum(['start', 'stop_attempt', 'stop_failure', 'terminal']),
|
|
13
|
+
attempt: z.number().int().nonnegative(),
|
|
14
|
+
accepted_inputs: z.array(z.object({ message_id: identity, origin: z.string(), provenance: provenance.optional() })),
|
|
15
|
+
// Handed to the host model adapter, not proof of server receipt or understanding.
|
|
16
|
+
input_message_ids: z.array(identity), input_association: z.enum(['known', 'unknown']),
|
|
17
|
+
last_assistant_message_id: identity.nullable(),
|
|
18
|
+
status: z.enum(['completed', 'failed', 'cancelled', 'stopped', 'interrupted']).nullable(),
|
|
19
|
+
terminal_reason: z.string().nullable(),
|
|
20
|
+
}).superRefine((event, ctx) => {
|
|
21
|
+
const invalid = (message) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
22
|
+
if ((event.phase === 'terminal') !== (event.status !== null))
|
|
23
|
+
invalid('Only terminal events have a status');
|
|
24
|
+
if (event.phase !== 'terminal' && event.terminal_reason !== null)
|
|
25
|
+
invalid('Only terminal events have a terminal reason');
|
|
26
|
+
const accepted = event.accepted_inputs.map(input => input.message_id);
|
|
27
|
+
if (new Set(accepted).size !== accepted.length)
|
|
28
|
+
invalid('Duplicate accepted input');
|
|
29
|
+
if (new Set(event.input_message_ids).size !== event.input_message_ids.length)
|
|
30
|
+
invalid('Duplicate dispatched input');
|
|
31
|
+
if (event.input_message_ids.some(id => !accepted.includes(id)))
|
|
32
|
+
invalid('Dispatched input was not accepted');
|
|
33
|
+
if (event.phase === 'start' && (event.sequence !== 0 || event.attempt !== 0 || event.input_message_ids.length !== 0))
|
|
34
|
+
invalid('Invalid query start');
|
|
35
|
+
if (event.scope === 'subagent' && event.agent_id === null)
|
|
36
|
+
invalid('Subagent identity missing');
|
|
37
|
+
});
|
|
38
|
+
export const QueryLifecycleWriteSchema = z.union([
|
|
39
|
+
z.object({ state: z.literal('saved') }),
|
|
40
|
+
z.object({ state: z.literal('unavailable'), reason: z.enum(['persistence_disabled', 'no_transcript']) }),
|
|
41
|
+
z.object({ state: z.literal('failed'), reason: z.literal('write_failed') }),
|
|
42
|
+
z.object({ state: z.literal('unknown'), reason: z.literal('write_timeout') }),
|
|
43
|
+
]);
|
|
44
|
+
export const QueryLifecycleObservationSchema = z.object({
|
|
45
|
+
record: QueryLifecycleSchema, persistence: QueryLifecycleWriteSchema,
|
|
46
|
+
transcript_path: z.string().nullable(),
|
|
47
|
+
});
|