@agentic-kit/run-log 0.2.0
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/LICENSE +23 -0
- package/README.md +169 -0
- package/esm/file-store.d.ts +33 -0
- package/esm/file-store.js +85 -0
- package/esm/follow.d.ts +36 -0
- package/esm/follow.js +65 -0
- package/esm/index.d.ts +16 -0
- package/esm/index.js +16 -0
- package/esm/pi-entry.d.ts +157 -0
- package/esm/pi-entry.js +61 -0
- package/esm/projectors/parts.d.ts +75 -0
- package/esm/projectors/parts.js +135 -0
- package/esm/projectors/session.d.ts +34 -0
- package/esm/projectors/session.js +64 -0
- package/esm/projectors/tool-state.d.ts +77 -0
- package/esm/projectors/tool-state.js +127 -0
- package/esm/projectors/usage.d.ts +35 -0
- package/esm/projectors/usage.js +76 -0
- package/esm/record.d.ts +48 -0
- package/esm/record.js +83 -0
- package/esm/store.d.ts +46 -0
- package/esm/store.js +51 -0
- package/file-store.d.ts +33 -0
- package/file-store.js +90 -0
- package/follow.d.ts +36 -0
- package/follow.js +69 -0
- package/index.d.ts +16 -0
- package/index.js +49 -0
- package/package.json +39 -0
- package/pi-entry.d.ts +157 -0
- package/pi-entry.js +73 -0
- package/projectors/parts.d.ts +75 -0
- package/projectors/parts.js +138 -0
- package/projectors/session.d.ts +34 -0
- package/projectors/session.js +68 -0
- package/projectors/tool-state.d.ts +77 -0
- package/projectors/tool-state.js +133 -0
- package/projectors/usage.d.ts +35 -0
- package/projectors/usage.js +81 -0
- package/record.d.ts +48 -0
- package/record.js +90 -0
- package/store.d.ts +46 -0
- package/store.js +56 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tool and approval projection: run log records → the state a UI needs to know
|
|
4
|
+
* what is running and what is waiting on a human.
|
|
5
|
+
*
|
|
6
|
+
* Approvals ride in the log as pi `custom` messages rather than a side channel,
|
|
7
|
+
* because "the run is blocked on you" is part of the run's history: a surface
|
|
8
|
+
* that reconnects hours later must be able to see a pending request without
|
|
9
|
+
* having been present when it was raised, and both placements then behave the
|
|
10
|
+
* same — the cloud already treats the conversation as the approval UI.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.approvalResolutionMessage = exports.approvalRequestMessage = exports.APPROVAL_RESOLUTION_TYPE = exports.APPROVAL_REQUEST_TYPE = void 0;
|
|
14
|
+
exports.projectToolState = projectToolState;
|
|
15
|
+
const pi_entry_1 = require("../pi-entry");
|
|
16
|
+
/** `customType` of an approval request written by the gate extension. */
|
|
17
|
+
exports.APPROVAL_REQUEST_TYPE = 'constructive.approval.request';
|
|
18
|
+
/** `customType` of the human's answer to a request. */
|
|
19
|
+
exports.APPROVAL_RESOLUTION_TYPE = 'constructive.approval.resolution';
|
|
20
|
+
const details = (value) => typeof value === 'object' && value !== null ? value : {};
|
|
21
|
+
function projectToolState(records) {
|
|
22
|
+
const tools = {};
|
|
23
|
+
const approvals = new Map();
|
|
24
|
+
for (const { entry, seq } of records) {
|
|
25
|
+
if (!(0, pi_entry_1.isPiMessageEntry)(entry))
|
|
26
|
+
continue;
|
|
27
|
+
const message = entry.message;
|
|
28
|
+
if ((0, pi_entry_1.isAssistantMessage)(message)) {
|
|
29
|
+
for (const call of (0, pi_entry_1.toolCalls)(message)) {
|
|
30
|
+
tools[call.id] = {
|
|
31
|
+
toolCallId: call.id,
|
|
32
|
+
name: call.name,
|
|
33
|
+
arguments: call.arguments ?? {},
|
|
34
|
+
status: 'requested',
|
|
35
|
+
requestedSeq: seq
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if ((0, pi_entry_1.isToolResultMessage)(message)) {
|
|
41
|
+
const state = tools[message.toolCallId];
|
|
42
|
+
const settled = {
|
|
43
|
+
status: message.isError ? 'failed' : 'completed',
|
|
44
|
+
settledSeq: seq,
|
|
45
|
+
output: (0, pi_entry_1.contentText)(message.content)
|
|
46
|
+
};
|
|
47
|
+
tools[message.toolCallId] = state
|
|
48
|
+
? { ...state, ...settled }
|
|
49
|
+
: {
|
|
50
|
+
toolCallId: message.toolCallId,
|
|
51
|
+
name: message.toolName,
|
|
52
|
+
arguments: {},
|
|
53
|
+
requestedSeq: seq,
|
|
54
|
+
status: settled.status,
|
|
55
|
+
settledSeq: seq,
|
|
56
|
+
output: settled.output
|
|
57
|
+
};
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (message.role !== 'custom')
|
|
61
|
+
continue;
|
|
62
|
+
if (message.customType === exports.APPROVAL_REQUEST_TYPE) {
|
|
63
|
+
const info = details(message.details);
|
|
64
|
+
const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : null;
|
|
65
|
+
if (!toolCallId) {
|
|
66
|
+
throw new Error(`approval request at seq ${String(seq)} carries no toolCallId; the run log cannot attribute it`);
|
|
67
|
+
}
|
|
68
|
+
const approval = {
|
|
69
|
+
toolCallId,
|
|
70
|
+
requestedSeq: seq,
|
|
71
|
+
prompt: (0, pi_entry_1.contentText)(message.content)
|
|
72
|
+
};
|
|
73
|
+
approvals.set(toolCallId, approval);
|
|
74
|
+
const state = tools[toolCallId];
|
|
75
|
+
if (state)
|
|
76
|
+
tools[toolCallId] = { ...state, status: 'awaiting-approval', approval };
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (message.customType === exports.APPROVAL_RESOLUTION_TYPE) {
|
|
80
|
+
const info = details(message.details);
|
|
81
|
+
const toolCallId = typeof info.toolCallId === 'string' ? info.toolCallId : null;
|
|
82
|
+
if (!toolCallId) {
|
|
83
|
+
throw new Error(`approval resolution at seq ${String(seq)} carries no toolCallId; the run log cannot attribute it`);
|
|
84
|
+
}
|
|
85
|
+
const approved = info.decision === 'approved';
|
|
86
|
+
const existing = approvals.get(toolCallId);
|
|
87
|
+
const approval = {
|
|
88
|
+
...(existing ?? { toolCallId, requestedSeq: seq, prompt: '' }),
|
|
89
|
+
resolvedSeq: seq,
|
|
90
|
+
decision: approved ? 'approved' : 'rejected',
|
|
91
|
+
...(typeof info.reason === 'string' ? { reason: info.reason } : {}),
|
|
92
|
+
...(typeof info.actorId === 'string' ? { actorId: info.actorId } : {})
|
|
93
|
+
};
|
|
94
|
+
approvals.set(toolCallId, approval);
|
|
95
|
+
const state = tools[toolCallId];
|
|
96
|
+
if (state && state.status === 'awaiting-approval') {
|
|
97
|
+
tools[toolCallId] = { ...state, status: approved ? 'running' : 'rejected', approval };
|
|
98
|
+
}
|
|
99
|
+
else if (state) {
|
|
100
|
+
tools[toolCallId] = { ...state, approval };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const pendingApprovals = Array.from(approvals.values())
|
|
105
|
+
.filter((approval) => approval.resolvedSeq === undefined)
|
|
106
|
+
.sort((a, b) => a.requestedSeq - b.requestedSeq);
|
|
107
|
+
return { tools, pendingApprovals };
|
|
108
|
+
}
|
|
109
|
+
/** Build the pi `custom` message an approval request is carried in. */
|
|
110
|
+
const approvalRequestMessage = (input) => ({
|
|
111
|
+
role: 'custom',
|
|
112
|
+
customType: exports.APPROVAL_REQUEST_TYPE,
|
|
113
|
+
content: input.prompt,
|
|
114
|
+
display: true,
|
|
115
|
+
details: { toolCallId: input.toolCallId },
|
|
116
|
+
timestamp: Date.now()
|
|
117
|
+
});
|
|
118
|
+
exports.approvalRequestMessage = approvalRequestMessage;
|
|
119
|
+
/** Build the pi `custom` message a human's answer is carried in. */
|
|
120
|
+
const approvalResolutionMessage = (input) => ({
|
|
121
|
+
role: 'custom',
|
|
122
|
+
customType: exports.APPROVAL_RESOLUTION_TYPE,
|
|
123
|
+
content: input.reason ?? input.decision,
|
|
124
|
+
display: true,
|
|
125
|
+
details: {
|
|
126
|
+
toolCallId: input.toolCallId,
|
|
127
|
+
decision: input.decision,
|
|
128
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
129
|
+
...(input.actorId ? { actorId: input.actorId } : {})
|
|
130
|
+
},
|
|
131
|
+
timestamp: Date.now()
|
|
132
|
+
});
|
|
133
|
+
exports.approvalResolutionMessage = approvalResolutionMessage;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage projection: run log records → token and cost totals.
|
|
3
|
+
*
|
|
4
|
+
* Every usage-bearing pi entry counts, not just assistant messages: a tool that
|
|
5
|
+
* performed nested LLM work reports `usage` on its result, and compaction and
|
|
6
|
+
* branch summaries are model calls the run paid for. Missing any of those makes
|
|
7
|
+
* a run look cheaper than it was, which is exactly the kind of drift metering
|
|
8
|
+
* exists to prevent.
|
|
9
|
+
*
|
|
10
|
+
* This projection describes what a run *observed*. It is reconciliation input,
|
|
11
|
+
* never the billing authority — a gateway-observed record is (see
|
|
12
|
+
* `@agentic-kit/pi-ext-metered-model`).
|
|
13
|
+
*/
|
|
14
|
+
import type { RunEventRecord } from '../record';
|
|
15
|
+
export interface UsageTotals {
|
|
16
|
+
input: number;
|
|
17
|
+
output: number;
|
|
18
|
+
cacheRead: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
/** Number of usage-bearing entries folded into these totals. */
|
|
23
|
+
calls: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ModelUsage extends UsageTotals {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RunUsage extends UsageTotals {
|
|
30
|
+
/** Per provider+model breakdown, keyed `provider/model`. */
|
|
31
|
+
byModel: Record<string, ModelUsage>;
|
|
32
|
+
}
|
|
33
|
+
export declare const modelKey: (provider: string, model: string) => string;
|
|
34
|
+
/** Fold every usage-bearing entry in the records into run totals. */
|
|
35
|
+
export declare function projectUsage(records: readonly RunEventRecord[]): RunUsage;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Usage projection: run log records → token and cost totals.
|
|
4
|
+
*
|
|
5
|
+
* Every usage-bearing pi entry counts, not just assistant messages: a tool that
|
|
6
|
+
* performed nested LLM work reports `usage` on its result, and compaction and
|
|
7
|
+
* branch summaries are model calls the run paid for. Missing any of those makes
|
|
8
|
+
* a run look cheaper than it was, which is exactly the kind of drift metering
|
|
9
|
+
* exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* This projection describes what a run *observed*. It is reconciliation input,
|
|
12
|
+
* never the billing authority — a gateway-observed record is (see
|
|
13
|
+
* `@agentic-kit/pi-ext-metered-model`).
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.modelKey = void 0;
|
|
17
|
+
exports.projectUsage = projectUsage;
|
|
18
|
+
const pi_entry_1 = require("../pi-entry");
|
|
19
|
+
const empty = () => ({
|
|
20
|
+
input: 0,
|
|
21
|
+
output: 0,
|
|
22
|
+
cacheRead: 0,
|
|
23
|
+
cacheWrite: 0,
|
|
24
|
+
totalTokens: 0,
|
|
25
|
+
cost: 0,
|
|
26
|
+
calls: 0
|
|
27
|
+
});
|
|
28
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
29
|
+
function fold(target, usage) {
|
|
30
|
+
const input = num(usage.input);
|
|
31
|
+
const output = num(usage.output);
|
|
32
|
+
const cacheRead = num(usage.cacheRead);
|
|
33
|
+
const cacheWrite = num(usage.cacheWrite);
|
|
34
|
+
target.input += input;
|
|
35
|
+
target.output += output;
|
|
36
|
+
target.cacheRead += cacheRead;
|
|
37
|
+
target.cacheWrite += cacheWrite;
|
|
38
|
+
// Providers that omit a total still have one: the parts they did report.
|
|
39
|
+
target.totalTokens += usage.totalTokens === undefined
|
|
40
|
+
? input + output + cacheRead + cacheWrite
|
|
41
|
+
: num(usage.totalTokens);
|
|
42
|
+
target.cost += num(usage.cost?.total);
|
|
43
|
+
target.calls += 1;
|
|
44
|
+
}
|
|
45
|
+
const modelKey = (provider, model) => `${provider}/${model}`;
|
|
46
|
+
exports.modelKey = modelKey;
|
|
47
|
+
/** Fold every usage-bearing entry in the records into run totals. */
|
|
48
|
+
function projectUsage(records) {
|
|
49
|
+
const totals = { ...empty(), byModel: {} };
|
|
50
|
+
const add = (usage, provider = 'unknown', model = 'unknown') => {
|
|
51
|
+
if (!usage)
|
|
52
|
+
return;
|
|
53
|
+
fold(totals, usage);
|
|
54
|
+
const key = (0, exports.modelKey)(provider, model);
|
|
55
|
+
const bucket = totals.byModel[key] ?? { ...empty(), provider, model };
|
|
56
|
+
fold(bucket, usage);
|
|
57
|
+
totals.byModel[key] = bucket;
|
|
58
|
+
};
|
|
59
|
+
let lastProvider = 'unknown';
|
|
60
|
+
let lastModel = 'unknown';
|
|
61
|
+
for (const { entry } of records) {
|
|
62
|
+
if ((0, pi_entry_1.isPiMessageEntry)(entry)) {
|
|
63
|
+
const message = entry.message;
|
|
64
|
+
if ((0, pi_entry_1.isAssistantMessage)(message)) {
|
|
65
|
+
lastProvider = message.provider ?? lastProvider;
|
|
66
|
+
lastModel = message.model ?? lastModel;
|
|
67
|
+
add(message.usage, message.provider ?? 'unknown', message.model ?? 'unknown');
|
|
68
|
+
}
|
|
69
|
+
else if ((0, pi_entry_1.isToolResultMessage)(message)) {
|
|
70
|
+
// Nested model work inside a tool: attributed to the run's current model,
|
|
71
|
+
// which is the model that requested the tool.
|
|
72
|
+
add(message.usage, lastProvider, lastModel);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if ((0, pi_entry_1.isPiCompactionEntry)(entry) || (0, pi_entry_1.isPiBranchSummaryEntry)(entry)) {
|
|
77
|
+
add(entry.usage, lastProvider, lastModel);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return totals;
|
|
81
|
+
}
|
package/record.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run log record: four platform-owned fields around a verbatim pi entry.
|
|
3
|
+
*
|
|
4
|
+
* The wrapper exists to give an entry identity (which run) and order (which
|
|
5
|
+
* position) across hosts — nothing else. It deliberately does not re-encode pi's
|
|
6
|
+
* semantics, because pi already versions and migrates its own session format;
|
|
7
|
+
* `piSessionVersion` records which version an entry was written under so a
|
|
8
|
+
* reader can hand old entries to pi's migrations instead of guessing.
|
|
9
|
+
*/
|
|
10
|
+
import { type PiSessionEntry } from './pi-entry';
|
|
11
|
+
/** Version of the wrapper itself — bumped only if these four fields change. */
|
|
12
|
+
export declare const RUN_LOG_WRAPPER_VERSION = 1;
|
|
13
|
+
/** The pi session format version this package projects without migration. */
|
|
14
|
+
export declare const SUPPORTED_PI_SESSION_VERSION = 3;
|
|
15
|
+
export interface RunEventRecord {
|
|
16
|
+
/** The run this entry belongs to. */
|
|
17
|
+
runId: string;
|
|
18
|
+
/** 1-based position within the run. Gapless and strictly increasing. */
|
|
19
|
+
seq: number;
|
|
20
|
+
/** When the platform durably recorded the entry (ISO 8601). */
|
|
21
|
+
recordedAt: string;
|
|
22
|
+
/** pi's session format version at write time. */
|
|
23
|
+
piSessionVersion: number;
|
|
24
|
+
/** The pi session entry, byte-for-byte as pi produced it. */
|
|
25
|
+
entry: PiSessionEntry;
|
|
26
|
+
}
|
|
27
|
+
export interface WrapEntryOptions {
|
|
28
|
+
runId: string;
|
|
29
|
+
seq: number;
|
|
30
|
+
entry: PiSessionEntry;
|
|
31
|
+
recordedAt?: string;
|
|
32
|
+
piSessionVersion?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function wrapEntry(options: WrapEntryOptions): RunEventRecord;
|
|
35
|
+
/**
|
|
36
|
+
* Narrow an untrusted record (database row, HTTP body). Throws on anything
|
|
37
|
+
* unreadable — a log that cannot be parsed must fail loudly, never silently
|
|
38
|
+
* render as an empty conversation.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertRunEventRecord(value: unknown): RunEventRecord;
|
|
41
|
+
/**
|
|
42
|
+
* The de-duplication key for an append. pi entry ids are unique within a
|
|
43
|
+
* session, so a retried append (a Job restart re-emitting its tail, a
|
|
44
|
+
* reconnecting writer) is recognised rather than duplicated.
|
|
45
|
+
*/
|
|
46
|
+
export declare function idempotencyKey(runId: string, entry: PiSessionEntry): string;
|
|
47
|
+
/** Records must be contiguous and in order before anything projects them. */
|
|
48
|
+
export declare function assertOrdered(records: readonly RunEventRecord[]): void;
|
package/record.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The run log record: four platform-owned fields around a verbatim pi entry.
|
|
4
|
+
*
|
|
5
|
+
* The wrapper exists to give an entry identity (which run) and order (which
|
|
6
|
+
* position) across hosts — nothing else. It deliberately does not re-encode pi's
|
|
7
|
+
* semantics, because pi already versions and migrates its own session format;
|
|
8
|
+
* `piSessionVersion` records which version an entry was written under so a
|
|
9
|
+
* reader can hand old entries to pi's migrations instead of guessing.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.SUPPORTED_PI_SESSION_VERSION = exports.RUN_LOG_WRAPPER_VERSION = void 0;
|
|
13
|
+
exports.wrapEntry = wrapEntry;
|
|
14
|
+
exports.assertRunEventRecord = assertRunEventRecord;
|
|
15
|
+
exports.idempotencyKey = idempotencyKey;
|
|
16
|
+
exports.assertOrdered = assertOrdered;
|
|
17
|
+
const pi_entry_1 = require("./pi-entry");
|
|
18
|
+
/** Version of the wrapper itself — bumped only if these four fields change. */
|
|
19
|
+
exports.RUN_LOG_WRAPPER_VERSION = 1;
|
|
20
|
+
/** The pi session format version this package projects without migration. */
|
|
21
|
+
exports.SUPPORTED_PI_SESSION_VERSION = 3;
|
|
22
|
+
function wrapEntry(options) {
|
|
23
|
+
if (!options.runId)
|
|
24
|
+
throw new TypeError('a run log record needs a runId');
|
|
25
|
+
if (!Number.isInteger(options.seq) || options.seq < 1) {
|
|
26
|
+
throw new TypeError(`run log seq must be a positive integer, received ${String(options.seq)}`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
runId: options.runId,
|
|
30
|
+
seq: options.seq,
|
|
31
|
+
recordedAt: options.recordedAt ?? new Date().toISOString(),
|
|
32
|
+
piSessionVersion: options.piSessionVersion ?? exports.SUPPORTED_PI_SESSION_VERSION,
|
|
33
|
+
entry: (0, pi_entry_1.assertPiSessionEntry)(options.entry)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Narrow an untrusted record (database row, HTTP body). Throws on anything
|
|
38
|
+
* unreadable — a log that cannot be parsed must fail loudly, never silently
|
|
39
|
+
* render as an empty conversation.
|
|
40
|
+
*/
|
|
41
|
+
function assertRunEventRecord(value) {
|
|
42
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
43
|
+
throw new TypeError(`run log record must be an object, received ${typeof value}`);
|
|
44
|
+
}
|
|
45
|
+
const record = value;
|
|
46
|
+
if (typeof record.runId !== 'string' || record.runId.length === 0) {
|
|
47
|
+
throw new TypeError('run log record must carry a non-empty runId');
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isInteger(record.seq) || record.seq < 1) {
|
|
50
|
+
throw new TypeError(`run log record ${record.runId} has an invalid seq: ${String(record.seq)}`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof record.recordedAt !== 'string') {
|
|
53
|
+
throw new TypeError(`run log record ${record.runId}#${String(record.seq)} must carry recordedAt`);
|
|
54
|
+
}
|
|
55
|
+
if (!Number.isInteger(record.piSessionVersion)) {
|
|
56
|
+
throw new TypeError(`run log record ${record.runId}#${String(record.seq)} must carry an integer piSessionVersion`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
runId: record.runId,
|
|
60
|
+
seq: record.seq,
|
|
61
|
+
recordedAt: record.recordedAt,
|
|
62
|
+
piSessionVersion: record.piSessionVersion,
|
|
63
|
+
entry: (0, pi_entry_1.assertPiSessionEntry)(record.entry)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The de-duplication key for an append. pi entry ids are unique within a
|
|
68
|
+
* session, so a retried append (a Job restart re-emitting its tail, a
|
|
69
|
+
* reconnecting writer) is recognised rather than duplicated.
|
|
70
|
+
*/
|
|
71
|
+
function idempotencyKey(runId, entry) {
|
|
72
|
+
const id = typeof entry.id === 'string' ? entry.id : null;
|
|
73
|
+
if (id)
|
|
74
|
+
return `${runId}:${entry.type}:${id}`;
|
|
75
|
+
// Session headers carry no tree id; there is exactly one per session file.
|
|
76
|
+
return `${runId}:${entry.type}:${String(entry.id ?? 'header')}`;
|
|
77
|
+
}
|
|
78
|
+
/** Records must be contiguous and in order before anything projects them. */
|
|
79
|
+
function assertOrdered(records) {
|
|
80
|
+
for (let i = 1; i < records.length; i += 1) {
|
|
81
|
+
const previous = records[i - 1];
|
|
82
|
+
const current = records[i];
|
|
83
|
+
if (current.runId !== previous.runId) {
|
|
84
|
+
throw new Error(`run log records mix runs: ${previous.runId} then ${current.runId} at index ${String(i)}`);
|
|
85
|
+
}
|
|
86
|
+
if (current.seq <= previous.seq) {
|
|
87
|
+
throw new Error(`run log records out of order in ${current.runId}: seq ${String(previous.seq)} followed by ${String(current.seq)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/store.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage contract. A run log is append-only and read by cursor, so the
|
|
3
|
+
* interfaces are deliberately two: a writer (the agent host) and a reader
|
|
4
|
+
* (every UI surface, the resume path, the usage rollup). Concrete stores live
|
|
5
|
+
* with their storage — Postgres in constructive-db, JSONL in `./file-store`,
|
|
6
|
+
* memory here for tests and for a run that has not been persisted yet.
|
|
7
|
+
*/
|
|
8
|
+
import type { PiSessionEntry } from './pi-entry';
|
|
9
|
+
import { type RunEventRecord } from './record';
|
|
10
|
+
/** Read position: `afterSeq` is exclusive, so `0` means "from the start". */
|
|
11
|
+
export interface RunLogCursor {
|
|
12
|
+
afterSeq: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const START: RunLogCursor;
|
|
15
|
+
export declare const cursorAfter: (records: readonly RunEventRecord[], from?: RunLogCursor) => RunLogCursor;
|
|
16
|
+
export interface RunLogPage {
|
|
17
|
+
records: RunEventRecord[];
|
|
18
|
+
cursor: RunLogCursor;
|
|
19
|
+
}
|
|
20
|
+
export interface AppendOptions {
|
|
21
|
+
/** pi session format version the entries were produced under. */
|
|
22
|
+
piSessionVersion?: number;
|
|
23
|
+
recordedAt?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RunLogAppendStore {
|
|
26
|
+
/**
|
|
27
|
+
* Append entries to a run, returning the records actually written. Entries
|
|
28
|
+
* already present (matched by pi entry id) are skipped, which makes an append
|
|
29
|
+
* safe to retry.
|
|
30
|
+
*/
|
|
31
|
+
append(runId: string, entries: readonly PiSessionEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
|
|
32
|
+
}
|
|
33
|
+
export interface RunLogReadStore {
|
|
34
|
+
read(runId: string, cursor?: RunLogCursor, limit?: number): Promise<RunLogPage>;
|
|
35
|
+
}
|
|
36
|
+
export type RunLogStore = RunLogAppendStore & RunLogReadStore;
|
|
37
|
+
/** In-memory store: the reference implementation and the test double. */
|
|
38
|
+
export declare class MemoryRunLogStore implements RunLogStore {
|
|
39
|
+
private readonly runs;
|
|
40
|
+
private readonly seen;
|
|
41
|
+
append(runId: string, entries: readonly PiSessionEntry[], options?: AppendOptions): Promise<RunEventRecord[]>;
|
|
42
|
+
read(runId: string, cursor?: RunLogCursor, limit?: number): Promise<RunLogPage>;
|
|
43
|
+
/** Test/debug helper: every record of a run, ignoring cursors. */
|
|
44
|
+
snapshot(runId: string): RunEventRecord[];
|
|
45
|
+
runIds(): string[];
|
|
46
|
+
}
|
package/store.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The storage contract. A run log is append-only and read by cursor, so the
|
|
4
|
+
* interfaces are deliberately two: a writer (the agent host) and a reader
|
|
5
|
+
* (every UI surface, the resume path, the usage rollup). Concrete stores live
|
|
6
|
+
* with their storage — Postgres in constructive-db, JSONL in `./file-store`,
|
|
7
|
+
* memory here for tests and for a run that has not been persisted yet.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.MemoryRunLogStore = exports.cursorAfter = exports.START = void 0;
|
|
11
|
+
const record_1 = require("./record");
|
|
12
|
+
exports.START = { afterSeq: 0 };
|
|
13
|
+
const cursorAfter = (records, from = exports.START) => records.length === 0 ? from : { afterSeq: records[records.length - 1].seq };
|
|
14
|
+
exports.cursorAfter = cursorAfter;
|
|
15
|
+
/** In-memory store: the reference implementation and the test double. */
|
|
16
|
+
class MemoryRunLogStore {
|
|
17
|
+
runs = new Map();
|
|
18
|
+
seen = new Map();
|
|
19
|
+
async append(runId, entries, options = {}) {
|
|
20
|
+
const records = this.runs.get(runId) ?? [];
|
|
21
|
+
const seen = this.seen.get(runId) ?? new Set();
|
|
22
|
+
const written = [];
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const key = (0, record_1.idempotencyKey)(runId, entry);
|
|
25
|
+
if (seen.has(key))
|
|
26
|
+
continue;
|
|
27
|
+
const record = (0, record_1.wrapEntry)({
|
|
28
|
+
runId,
|
|
29
|
+
seq: records.length + written.length + 1,
|
|
30
|
+
entry,
|
|
31
|
+
...(options.recordedAt ? { recordedAt: options.recordedAt } : {}),
|
|
32
|
+
piSessionVersion: options.piSessionVersion ?? record_1.SUPPORTED_PI_SESSION_VERSION
|
|
33
|
+
});
|
|
34
|
+
written.push(record);
|
|
35
|
+
seen.add(key);
|
|
36
|
+
}
|
|
37
|
+
this.runs.set(runId, records.concat(written));
|
|
38
|
+
this.seen.set(runId, seen);
|
|
39
|
+
return written;
|
|
40
|
+
}
|
|
41
|
+
async read(runId, cursor = exports.START, limit) {
|
|
42
|
+
const all = this.runs.get(runId) ?? [];
|
|
43
|
+
const after = all.filter((record) => record.seq > cursor.afterSeq);
|
|
44
|
+
const records = typeof limit === 'number' ? after.slice(0, limit) : after;
|
|
45
|
+
(0, record_1.assertOrdered)(records);
|
|
46
|
+
return { records, cursor: (0, exports.cursorAfter)(records, cursor) };
|
|
47
|
+
}
|
|
48
|
+
/** Test/debug helper: every record of a run, ignoring cursors. */
|
|
49
|
+
snapshot(runId) {
|
|
50
|
+
return (this.runs.get(runId) ?? []).slice();
|
|
51
|
+
}
|
|
52
|
+
runIds() {
|
|
53
|
+
return Array.from(this.runs.keys());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.MemoryRunLogStore = MemoryRunLogStore;
|