@jitsusama/agentic-harness.core 0.4.0 → 0.6.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/dist/observability/ledger/store.js +24 -4
- package/dist/observability/ledger/types.d.ts +12 -0
- package/dist/observability/recorder.d.ts +7 -0
- package/dist/observability/recorder.js +2 -0
- package/dist/observability/store.js +43 -4
- package/dist/observability/types.d.ts +16 -0
- package/package.json +1 -1
- package/dist/observability/ledger/scan.d.ts +0 -11
- package/dist/observability/ledger/scan.js +0 -159
- package/dist/observability/ledger/session.d.ts +0 -38
- package/dist/observability/ledger/session.js +0 -89
|
@@ -52,6 +52,8 @@ CREATE TABLE IF NOT EXISTS tool_calls (
|
|
|
52
52
|
CREATE INDEX IF NOT EXISTS tool_calls_verifier ON tool_calls (verifier_kind);
|
|
53
53
|
CREATE INDEX IF NOT EXISTS tool_calls_args ON tool_calls (session_id, args_digest);
|
|
54
54
|
CREATE INDEX IF NOT EXISTS tool_calls_path ON tool_calls (path);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS tool_calls_session_verifier
|
|
56
|
+
ON tool_calls (session_id, timestamp) WHERE verifier_kind IS NOT NULL;
|
|
55
57
|
CREATE TABLE IF NOT EXISTS dropped_calls (
|
|
56
58
|
digest TEXT PRIMARY KEY,
|
|
57
59
|
session_id TEXT NOT NULL,
|
|
@@ -301,18 +303,32 @@ class SqliteTurnStore {
|
|
|
301
303
|
const changed = writtenBetween("o", "o.previous_at", "o.timestamp", scope.writers);
|
|
302
304
|
const rows = await this.db.all(`WITH ordered AS (
|
|
303
305
|
SELECT c.session_id, c.args_digest, c.name, c.path,
|
|
304
|
-
c.timestamp, c.result_chars,
|
|
306
|
+
c.timestamp, c.result_chars, c.verifier_kind,
|
|
305
307
|
LAG(c.timestamp) OVER (
|
|
306
308
|
PARTITION BY c.session_id, c.args_digest
|
|
307
309
|
ORDER BY c.timestamp, c.digest
|
|
308
310
|
) AS previous_at
|
|
309
311
|
FROM tool_calls AS c
|
|
310
312
|
WHERE ${retrieval.sql}
|
|
313
|
+
),
|
|
314
|
+
repeats AS (
|
|
315
|
+
SELECT o.session_id, o.args_digest, o.name, o.result_chars,
|
|
316
|
+
(o.verifier_kind IS NOT NULL OR EXISTS (
|
|
317
|
+
SELECT 1 FROM tool_calls AS v
|
|
318
|
+
WHERE v.session_id = o.session_id
|
|
319
|
+
AND v.verifier_kind IS NOT NULL
|
|
320
|
+
AND v.timestamp > o.previous_at
|
|
321
|
+
AND v.timestamp < o.timestamp
|
|
322
|
+
)) AS is_appraisal
|
|
323
|
+
FROM ordered AS o
|
|
324
|
+
WHERE o.previous_at IS NOT NULL AND NOT (${changed.sql})
|
|
311
325
|
)
|
|
312
326
|
SELECT args_digest, name, COUNT(*) AS repeated,
|
|
313
|
-
SUM(COALESCE(result_chars, 0)) AS repeated_chars
|
|
314
|
-
|
|
315
|
-
|
|
327
|
+
SUM(COALESCE(result_chars, 0)) AS repeated_chars,
|
|
328
|
+
SUM(is_appraisal) AS appraisal,
|
|
329
|
+
SUM(CASE WHEN is_appraisal THEN COALESCE(result_chars, 0) ELSE 0 END)
|
|
330
|
+
AS appraisal_chars
|
|
331
|
+
FROM repeats
|
|
316
332
|
GROUP BY session_id, args_digest
|
|
317
333
|
ORDER BY repeated_chars DESC`, [...retrieval.params, ...changed.params]);
|
|
318
334
|
return rows.map((row) => ({
|
|
@@ -321,6 +337,10 @@ class SqliteTurnStore {
|
|
|
321
337
|
asked: row.repeated + 1,
|
|
322
338
|
repeated: row.repeated,
|
|
323
339
|
repeatedChars: row.repeated_chars ?? 0,
|
|
340
|
+
rework: row.repeated - row.appraisal,
|
|
341
|
+
reworkChars: (row.repeated_chars ?? 0) - (row.appraisal_chars ?? 0),
|
|
342
|
+
appraisal: row.appraisal,
|
|
343
|
+
appraisalChars: row.appraisal_chars ?? 0,
|
|
324
344
|
}));
|
|
325
345
|
}
|
|
326
346
|
async recordDropped(dropped) {
|
|
@@ -181,6 +181,18 @@ export interface RepeatedCall {
|
|
|
181
181
|
readonly repeated: number;
|
|
182
182
|
/** Characters the repeats re-admitted to the context. */
|
|
183
183
|
readonly repeatedChars: number;
|
|
184
|
+
/** Repeats with no verifier since the last ask: the waste bucket. */
|
|
185
|
+
readonly rework: number;
|
|
186
|
+
/** Characters the rework repeats re-admitted. */
|
|
187
|
+
readonly reworkChars: number;
|
|
188
|
+
/**
|
|
189
|
+
* Repeats that are themselves a verifier, or that follow one run
|
|
190
|
+
* since the last ask: the model checking its work, which is cost of
|
|
191
|
+
* quality rather than waste.
|
|
192
|
+
*/
|
|
193
|
+
readonly appraisal: number;
|
|
194
|
+
/** Characters the appraisal repeats re-admitted. */
|
|
195
|
+
readonly appraisalChars: number;
|
|
184
196
|
}
|
|
185
197
|
/**
|
|
186
198
|
* What a session log says about itself.
|
|
@@ -13,9 +13,16 @@ export interface RunRecordInput {
|
|
|
13
13
|
readonly kind: string;
|
|
14
14
|
readonly model: string;
|
|
15
15
|
readonly persona: string;
|
|
16
|
+
/** The thinking level the run was launched at, null when unknown. */
|
|
17
|
+
readonly thinkingLevel: string | null;
|
|
16
18
|
readonly startedAt: number;
|
|
17
19
|
readonly result: {
|
|
18
20
|
readonly exitCode: number;
|
|
21
|
+
/**
|
|
22
|
+
* The session ids the run's child processes announced, in order.
|
|
23
|
+
* Empty when there was no child process, null when not known.
|
|
24
|
+
*/
|
|
25
|
+
readonly sessionIds: readonly string[] | null;
|
|
19
26
|
readonly warnings: readonly string[];
|
|
20
27
|
readonly usage?: {
|
|
21
28
|
readonly tokens: RunTokens;
|
|
@@ -34,6 +34,8 @@ export function runRecordFrom(input) {
|
|
|
34
34
|
tokens: result.usage?.tokens ?? null,
|
|
35
35
|
cost: result.usage?.cost ?? null,
|
|
36
36
|
startedAt: input.startedAt,
|
|
37
|
+
thinkingLevel: input.thinkingLevel,
|
|
38
|
+
subagentSessionIds: result.sessionIds,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
// Process-global so a producer extension's recordRunEverywhere
|
|
@@ -77,7 +77,7 @@ async function migrate(db) {
|
|
|
77
77
|
// a later metered run cannot be reclassified.
|
|
78
78
|
await db.exec("UPDATE runs SET metered = 0 WHERE tokens_total = 0 AND cost_total = 0");
|
|
79
79
|
}
|
|
80
|
-
for (const [name, type] of ATTRIBUTION_COLUMNS) {
|
|
80
|
+
for (const [name, type] of [...ATTRIBUTION_COLUMNS, ...LAUNCH_COLUMNS]) {
|
|
81
81
|
if (!columns.has(name)) {
|
|
82
82
|
await db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
83
83
|
}
|
|
@@ -110,6 +110,15 @@ const ATTRIBUTION_COLUMNS = [
|
|
|
110
110
|
["repo", "TEXT"],
|
|
111
111
|
["ended_at", "INTEGER"],
|
|
112
112
|
];
|
|
113
|
+
/**
|
|
114
|
+
* How a run was launched, nullable for the same reason: rows written
|
|
115
|
+
* before these existed never said.
|
|
116
|
+
*/
|
|
117
|
+
const LAUNCH_COLUMNS = [
|
|
118
|
+
["thinking_level", "TEXT"],
|
|
119
|
+
// A JSON array of session ids; null when not known.
|
|
120
|
+
["subagent_session_ids", "TEXT"],
|
|
121
|
+
];
|
|
113
122
|
class SqliteRunStore {
|
|
114
123
|
db;
|
|
115
124
|
constructor(db) {
|
|
@@ -121,8 +130,9 @@ class SqliteRunStore {
|
|
|
121
130
|
retries_to_valid, warning_count, exit_code,
|
|
122
131
|
tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, tokens_total,
|
|
123
132
|
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total,
|
|
124
|
-
started_at, metered, session_id, cwd, repo, ended_at
|
|
125
|
-
|
|
133
|
+
started_at, metered, session_id, cwd, repo, ended_at,
|
|
134
|
+
thinking_level, subagent_session_ids
|
|
135
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
126
136
|
ON CONFLICT (run_id, subagent_id) DO UPDATE SET
|
|
127
137
|
kind = excluded.kind, model = excluded.model,
|
|
128
138
|
persona = excluded.persona, verify_outcome = excluded.verify_outcome,
|
|
@@ -141,7 +151,9 @@ class SqliteRunStore {
|
|
|
141
151
|
cost_total = excluded.cost_total,
|
|
142
152
|
started_at = excluded.started_at, metered = excluded.metered,
|
|
143
153
|
session_id = excluded.session_id, cwd = excluded.cwd,
|
|
144
|
-
repo = excluded.repo, ended_at = excluded.ended_at
|
|
154
|
+
repo = excluded.repo, ended_at = excluded.ended_at,
|
|
155
|
+
thinking_level = excluded.thinking_level,
|
|
156
|
+
subagent_session_ids = excluded.subagent_session_ids`, [
|
|
145
157
|
record.runId,
|
|
146
158
|
record.subagentId,
|
|
147
159
|
record.kind,
|
|
@@ -170,6 +182,10 @@ class SqliteRunStore {
|
|
|
170
182
|
record.cwd ?? null,
|
|
171
183
|
record.repo ?? null,
|
|
172
184
|
record.endedAt ?? null,
|
|
185
|
+
record.thinkingLevel ?? null,
|
|
186
|
+
record.subagentSessionIds
|
|
187
|
+
? JSON.stringify(record.subagentSessionIds)
|
|
188
|
+
: null,
|
|
173
189
|
]);
|
|
174
190
|
}
|
|
175
191
|
async queryRuns(filter = {}) {
|
|
@@ -272,6 +288,27 @@ class SqliteRunStore {
|
|
|
272
288
|
await this.db.close();
|
|
273
289
|
}
|
|
274
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Read the stored session list back. Anything that is not an array of
|
|
293
|
+
* strings reads as unknown rather than as a partial list, since a list
|
|
294
|
+
* with a session missing would join a job to less of its bill without
|
|
295
|
+
* saying so.
|
|
296
|
+
*/
|
|
297
|
+
function sessionIdsFrom(stored) {
|
|
298
|
+
if (stored === null)
|
|
299
|
+
return null;
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(stored);
|
|
302
|
+
return Array.isArray(parsed) &&
|
|
303
|
+
parsed.every((id) => typeof id === "string")
|
|
304
|
+
? parsed
|
|
305
|
+
: null;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Not JSON, so not something this store wrote: unknown.
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
275
312
|
function rowToRecord(row) {
|
|
276
313
|
return {
|
|
277
314
|
runId: row.run_id,
|
|
@@ -306,5 +343,7 @@ function rowToRecord(row) {
|
|
|
306
343
|
cwd: row.cwd ?? null,
|
|
307
344
|
repo: row.repo ?? null,
|
|
308
345
|
endedAt: row.ended_at ?? null,
|
|
346
|
+
thinkingLevel: row.thinking_level ?? null,
|
|
347
|
+
subagentSessionIds: sessionIdsFrom(row.subagent_session_ids),
|
|
309
348
|
};
|
|
310
349
|
}
|
|
@@ -59,6 +59,22 @@ export interface RunRecord {
|
|
|
59
59
|
readonly cost: RunCost | null;
|
|
60
60
|
/** When the run started, epoch milliseconds. */
|
|
61
61
|
readonly startedAt: number;
|
|
62
|
+
/**
|
|
63
|
+
* The thinking level the run was launched at, or null when the
|
|
64
|
+
* launch did not name one and the level it inherited is not known.
|
|
65
|
+
* Required so a producer has to say, since a level left out reads
|
|
66
|
+
* the same as one nobody knew.
|
|
67
|
+
*/
|
|
68
|
+
readonly thinkingLevel: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Every pi session the run's own processes announced, in order,
|
|
71
|
+
* which are the ids billing rows carry. Usually one; a stopped
|
|
72
|
+
* reviewer asked for its findings is a second process with a second
|
|
73
|
+
* session. Distinct from {@link sessionId}, the parent that
|
|
74
|
+
* dispatched the run. Empty when the run had no child process, as an
|
|
75
|
+
* in-process run does not; null when nobody knows.
|
|
76
|
+
*/
|
|
77
|
+
readonly subagentSessionIds: readonly string[] | null;
|
|
62
78
|
/**
|
|
63
79
|
* The parent session that dispatched the run, the directory it was
|
|
64
80
|
* working in and the repo that directory belongs to. Stamped by the
|
package/package.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { LedgerScan } from "./types.js";
|
|
2
|
-
/**
|
|
3
|
-
* Read billable turns out of a session log's lines.
|
|
4
|
-
*
|
|
5
|
-
* Every line is offered to the parser and every outcome is counted, so a
|
|
6
|
-
* malformed line costs one entry rather than the remainder of the file.
|
|
7
|
-
* Both places a turn can carry usage are read: assistant turns hold it
|
|
8
|
-
* under `message`, and compactions hold it at the top level beside
|
|
9
|
-
* `type`.
|
|
10
|
-
*/
|
|
11
|
-
export declare function readTurns(sessionId: string, lines: Iterable<string>): LedgerScan;
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { SessionCollector } from "./session.js";
|
|
3
|
-
/** Width of a stored content address. 96 bits is ample for a corpus of
|
|
4
|
-
* a few million turns and keeps the index small. */
|
|
5
|
-
const DIGEST_CHARS = 24;
|
|
6
|
-
/**
|
|
7
|
-
* Read billable turns out of a session log's lines.
|
|
8
|
-
*
|
|
9
|
-
* Every line is offered to the parser and every outcome is counted, so a
|
|
10
|
-
* malformed line costs one entry rather than the remainder of the file.
|
|
11
|
-
* Both places a turn can carry usage are read: assistant turns hold it
|
|
12
|
-
* under `message`, and compactions hold it at the top level beside
|
|
13
|
-
* `type`.
|
|
14
|
-
*/
|
|
15
|
-
export function readTurns(sessionId, lines) {
|
|
16
|
-
const turns = [];
|
|
17
|
-
const session = new SessionCollector(sessionId);
|
|
18
|
-
let count = 0;
|
|
19
|
-
let parsed = 0;
|
|
20
|
-
let unparseable = 0;
|
|
21
|
-
let billable = 0;
|
|
22
|
-
let unmetered = 0;
|
|
23
|
-
for (const line of lines) {
|
|
24
|
-
count += 1;
|
|
25
|
-
if (!line.trim())
|
|
26
|
-
continue;
|
|
27
|
-
let entry;
|
|
28
|
-
try {
|
|
29
|
-
const value = JSON.parse(line);
|
|
30
|
-
if (typeof value !== "object" || value === null) {
|
|
31
|
-
unparseable += 1;
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
entry = value;
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// A truncated or interleaved write. Counted, never fatal: one
|
|
38
|
-
// unreadable line once reduced a corpus-wide total to a tenth
|
|
39
|
-
// of the truth by aborting the pipeline that met it.
|
|
40
|
-
unparseable += 1;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
parsed += 1;
|
|
44
|
-
if (entry.type === "session") {
|
|
45
|
-
session.observeHeader(entry);
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (entry.customType === "quest-workflow") {
|
|
49
|
-
const data = asRecord(entry.data);
|
|
50
|
-
if (data)
|
|
51
|
-
session.observeWorkflow(data);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
const turn = turnFrom(sessionId, entry);
|
|
55
|
-
if (!turn)
|
|
56
|
-
continue;
|
|
57
|
-
session.observeTurn(turn.timestamp);
|
|
58
|
-
turns.push(turn);
|
|
59
|
-
if (turn.cost)
|
|
60
|
-
billable += 1;
|
|
61
|
-
else
|
|
62
|
-
unmetered += 1;
|
|
63
|
-
}
|
|
64
|
-
return {
|
|
65
|
-
turns,
|
|
66
|
-
coverage: { lines: count, parsed, unparseable, billable, unmetered },
|
|
67
|
-
session: session.record(),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
function turnFrom(sessionId, entry) {
|
|
71
|
-
const kind = kindOf(entry);
|
|
72
|
-
if (!kind)
|
|
73
|
-
return null;
|
|
74
|
-
const message = asRecord(entry.message);
|
|
75
|
-
const usage = asRecord(kind === "compaction" ? entry.usage : message?.usage);
|
|
76
|
-
const entryId = typeof entry.id === "string" ? entry.id : "";
|
|
77
|
-
const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : "";
|
|
78
|
-
const model = typeof message?.model === "string" ? message.model : "";
|
|
79
|
-
return {
|
|
80
|
-
entryId,
|
|
81
|
-
sessionId,
|
|
82
|
-
timestamp,
|
|
83
|
-
kind,
|
|
84
|
-
model,
|
|
85
|
-
tokens: tokensFrom(usage),
|
|
86
|
-
cost: costFrom(usage),
|
|
87
|
-
cacheWrite1h: usage?.cacheWrite1h ?? 0,
|
|
88
|
-
droppedBefore: kind === "compaction" && typeof entry.tokensBefore === "number"
|
|
89
|
-
? entry.tokensBefore
|
|
90
|
-
: null,
|
|
91
|
-
firstKeptEntryId: typeof entry.firstKeptEntryId === "string"
|
|
92
|
-
? entry.firstKeptEntryId
|
|
93
|
-
: null,
|
|
94
|
-
digest: digestOf(entryId, timestamp, kind, usage),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Which turns are billable at all. An assistant turn and a compaction
|
|
99
|
-
* both cost money; a user message, a tool result and a state change do
|
|
100
|
-
* not. Enumerated rather than filtered, so a new entry type is ignored
|
|
101
|
-
* by omission instead of silently swept into a total.
|
|
102
|
-
*/
|
|
103
|
-
function kindOf(entry) {
|
|
104
|
-
if (entry.type === "compaction")
|
|
105
|
-
return "compaction";
|
|
106
|
-
const message = asRecord(entry.message);
|
|
107
|
-
if (message?.role === "assistant")
|
|
108
|
-
return "assistant";
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
function tokensFrom(usage) {
|
|
112
|
-
const input = usage?.input ?? 0;
|
|
113
|
-
const output = usage?.output ?? 0;
|
|
114
|
-
const cacheRead = usage?.cacheRead ?? 0;
|
|
115
|
-
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
116
|
-
return {
|
|
117
|
-
input,
|
|
118
|
-
output,
|
|
119
|
-
cacheRead,
|
|
120
|
-
cacheWrite,
|
|
121
|
-
total: usage?.totalTokens ?? input + output + cacheRead + cacheWrite,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
/** Null when the entry reported no cost, which is not the same as free. */
|
|
125
|
-
function costFrom(usage) {
|
|
126
|
-
const cost = usage?.cost;
|
|
127
|
-
if (!cost || typeof cost.total !== "number")
|
|
128
|
-
return null;
|
|
129
|
-
return {
|
|
130
|
-
input: cost.input ?? 0,
|
|
131
|
-
output: cost.output ?? 0,
|
|
132
|
-
cacheRead: cost.cacheRead ?? 0,
|
|
133
|
-
cacheWrite: cost.cacheWrite ?? 0,
|
|
134
|
-
total: cost.total,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Address a turn by what it is rather than where it was found. Forking a
|
|
139
|
-
* session copies entries verbatim, ids included, so the same turn appears
|
|
140
|
-
* in several files and a naive count bills it more than once.
|
|
141
|
-
*/
|
|
142
|
-
function digestOf(entryId, timestamp, kind, usage) {
|
|
143
|
-
const canonical = JSON.stringify([
|
|
144
|
-
entryId,
|
|
145
|
-
timestamp,
|
|
146
|
-
kind,
|
|
147
|
-
usage?.cost?.total ?? null,
|
|
148
|
-
usage?.totalTokens ?? null,
|
|
149
|
-
]);
|
|
150
|
-
return createHash("sha256")
|
|
151
|
-
.update(canonical)
|
|
152
|
-
.digest("hex")
|
|
153
|
-
.slice(0, DIGEST_CHARS);
|
|
154
|
-
}
|
|
155
|
-
function asRecord(value) {
|
|
156
|
-
return typeof value === "object" && value !== null
|
|
157
|
-
? value
|
|
158
|
-
: null;
|
|
159
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { SessionRecord } from "./types.js";
|
|
2
|
-
/**
|
|
3
|
-
* Name the repo a working directory belongs to, or nothing when the path
|
|
4
|
-
* names none.
|
|
5
|
-
*
|
|
6
|
-
* A monorepo zone is named by the zone rather than the worktree it was
|
|
7
|
-
* cut into, because two trees of the same zone are the same subject and
|
|
8
|
-
* naming the tree would split one zone's spend across every tree ever
|
|
9
|
-
* cut for it.
|
|
10
|
-
*/
|
|
11
|
-
export declare function repoOf(cwd: string | null): string | null;
|
|
12
|
-
/**
|
|
13
|
-
* Accumulates what a log says about its session as the log is read, so
|
|
14
|
-
* one pass serves both the turns and their attribution.
|
|
15
|
-
*/
|
|
16
|
-
export declare class SessionCollector {
|
|
17
|
-
private readonly sessionId;
|
|
18
|
-
private cwd;
|
|
19
|
-
private quest;
|
|
20
|
-
private first;
|
|
21
|
-
private last;
|
|
22
|
-
constructor(sessionId: string);
|
|
23
|
-
/**
|
|
24
|
-
* Take the working directory from a session's header entry. Every log
|
|
25
|
-
* opens with one, which is what makes attribution complete rather than
|
|
26
|
-
* limited to the quarter of sessions that also name a quest.
|
|
27
|
-
*/
|
|
28
|
-
observeHeader(entry: Record<string, unknown>): void;
|
|
29
|
-
/**
|
|
30
|
-
* Take the working directory and quest a workflow entry names. The
|
|
31
|
-
* last one wins, because a session can be re-pointed at another quest
|
|
32
|
-
* part way through and the later statement is the current one.
|
|
33
|
-
*/
|
|
34
|
-
observeWorkflow(data: Record<string, unknown>): void;
|
|
35
|
-
/** Widen the span to include a billed turn. */
|
|
36
|
-
observeTurn(timestamp: string): void;
|
|
37
|
-
record(): SessionRecord;
|
|
38
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/** Where a per-host checkout tree begins, as `src/{host}/{owner}/{repo}`. */
|
|
2
|
-
const CHECKOUT_MARKER = "/src/";
|
|
3
|
-
/** Where a monorepo worktree begins, as `world/trees/{tree}/src/{zone}`. */
|
|
4
|
-
const MONOREPO_MARKER = "/world/trees/";
|
|
5
|
-
/** Segments naming a repo under the checkout marker: host, owner, name. */
|
|
6
|
-
const REPO_SEGMENTS = 3;
|
|
7
|
-
/**
|
|
8
|
-
* Name the repo a working directory belongs to, or nothing when the path
|
|
9
|
-
* names none.
|
|
10
|
-
*
|
|
11
|
-
* A monorepo zone is named by the zone rather than the worktree it was
|
|
12
|
-
* cut into, because two trees of the same zone are the same subject and
|
|
13
|
-
* naming the tree would split one zone's spend across every tree ever
|
|
14
|
-
* cut for it.
|
|
15
|
-
*/
|
|
16
|
-
export function repoOf(cwd) {
|
|
17
|
-
if (!cwd)
|
|
18
|
-
return null;
|
|
19
|
-
const monorepo = cwd.indexOf(MONOREPO_MARKER);
|
|
20
|
-
if (monorepo >= 0) {
|
|
21
|
-
const tail = cwd.slice(monorepo + MONOREPO_MARKER.length);
|
|
22
|
-
const zone = tail.split("/src/")[1];
|
|
23
|
-
return zone ? `world/${zone}` : null;
|
|
24
|
-
}
|
|
25
|
-
const checkout = cwd.indexOf(CHECKOUT_MARKER);
|
|
26
|
-
if (checkout >= 0) {
|
|
27
|
-
const parts = cwd
|
|
28
|
-
.slice(checkout + CHECKOUT_MARKER.length)
|
|
29
|
-
.split("/")
|
|
30
|
-
.filter(Boolean);
|
|
31
|
-
if (parts.length >= REPO_SEGMENTS) {
|
|
32
|
-
return parts.slice(0, REPO_SEGMENTS).join("/");
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Accumulates what a log says about its session as the log is read, so
|
|
39
|
-
* one pass serves both the turns and their attribution.
|
|
40
|
-
*/
|
|
41
|
-
export class SessionCollector {
|
|
42
|
-
sessionId;
|
|
43
|
-
cwd = null;
|
|
44
|
-
quest = null;
|
|
45
|
-
first = null;
|
|
46
|
-
last = null;
|
|
47
|
-
constructor(sessionId) {
|
|
48
|
-
this.sessionId = sessionId;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Take the working directory from a session's header entry. Every log
|
|
52
|
-
* opens with one, which is what makes attribution complete rather than
|
|
53
|
-
* limited to the quarter of sessions that also name a quest.
|
|
54
|
-
*/
|
|
55
|
-
observeHeader(entry) {
|
|
56
|
-
if (typeof entry.cwd === "string")
|
|
57
|
-
this.cwd = entry.cwd;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Take the working directory and quest a workflow entry names. The
|
|
61
|
-
* last one wins, because a session can be re-pointed at another quest
|
|
62
|
-
* part way through and the later statement is the current one.
|
|
63
|
-
*/
|
|
64
|
-
observeWorkflow(data) {
|
|
65
|
-
if (typeof data.cwd === "string")
|
|
66
|
-
this.cwd = data.cwd;
|
|
67
|
-
if (typeof data.questId === "string")
|
|
68
|
-
this.quest = data.questId;
|
|
69
|
-
}
|
|
70
|
-
/** Widen the span to include a billed turn. */
|
|
71
|
-
observeTurn(timestamp) {
|
|
72
|
-
if (!timestamp)
|
|
73
|
-
return;
|
|
74
|
-
if (!this.first || timestamp < this.first)
|
|
75
|
-
this.first = timestamp;
|
|
76
|
-
if (!this.last || timestamp > this.last)
|
|
77
|
-
this.last = timestamp;
|
|
78
|
-
}
|
|
79
|
-
record() {
|
|
80
|
-
return {
|
|
81
|
-
sessionId: this.sessionId,
|
|
82
|
-
cwd: this.cwd,
|
|
83
|
-
repo: repoOf(this.cwd),
|
|
84
|
-
quest: this.quest,
|
|
85
|
-
firstSeen: this.first,
|
|
86
|
-
lastSeen: this.last,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
}
|