@xingular/harness 0.0.0-stage → 0.1.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.
Files changed (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,59 @@
1
+ export const MAX_REASONING_SUMMARY_CHARS = 4_000
2
+
3
+ export const REASONING_SUMMARY_APIS = new Set([
4
+ 'openai-responses',
5
+ 'azure-openai-responses',
6
+ 'openai-codex-responses'
7
+ ])
8
+
9
+ type RecordValue = Record<string, unknown>
10
+
11
+ function isRecord(value: unknown): value is RecordValue {
12
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
13
+ }
14
+
15
+ function cleanSummary(value: string): string | undefined {
16
+ let text = ''
17
+ for (const character of value.trim()) {
18
+ if (text.length + character.length > MAX_REASONING_SUMMARY_CHARS) break
19
+ text += character
20
+ }
21
+ return text.trim() || undefined
22
+ }
23
+
24
+ /** Add the Responses API summary request while preserving the provider-selected effort. */
25
+ export function requestConciseReasoningSummary(payload: unknown): { payload: unknown; requested: boolean } {
26
+ if (!isRecord(payload) || !isRecord(payload.reasoning)) return { payload, requested: false }
27
+ const effort = payload.reasoning.effort
28
+ if (typeof effort !== 'string' || ['none', 'off'].includes(effort.toLowerCase())) return { payload, requested: false }
29
+ return {
30
+ payload: { ...payload, reasoning: { ...payload.reasoning, summary: 'concise' } },
31
+ requested: true
32
+ }
33
+ }
34
+
35
+ /** Pi preserves the provider reasoning item for both WebSocket and SSE replay.
36
+ * Read only its explicit summary: Pi's generic thinking field can also contain raw reasoning.
37
+ */
38
+ export function completedReasoningSummary(message: unknown): string | undefined {
39
+ if (!isRecord(message) || message.role !== 'assistant' || typeof message.api !== 'string' || !REASONING_SUMMARY_APIS.has(message.api) || !Array.isArray(message.content)) return undefined
40
+ const parts: string[] = []
41
+ const seen = new Set<string>()
42
+ for (const block of message.content) {
43
+ if (!isRecord(block) || block.type !== 'thinking' || typeof block.thinkingSignature !== 'string') continue
44
+ try {
45
+ const item: unknown = JSON.parse(block.thinkingSignature)
46
+ if (!isRecord(item) || item.type !== 'reasoning' || !Array.isArray(item.summary)) continue
47
+ if (typeof item.id === 'string') {
48
+ if (seen.has(item.id)) continue
49
+ seen.add(item.id)
50
+ }
51
+ for (const part of item.summary) {
52
+ if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string') parts.push(part.text)
53
+ }
54
+ } catch {
55
+ // Signatures of other formats, or incomplete items, are not summary evidence.
56
+ }
57
+ }
58
+ return cleanSummary(parts.join('\n\n'))
59
+ }
@@ -0,0 +1,294 @@
1
+ // Live runs. Assigns per-run sequence numbers, validates every event against the contract,
2
+ // persists it, hands it to listeners, and tracks interrupt, approval, and question handlers.
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ import { log } from '../core/bus'
6
+ import { HarnessError } from '../core/errors'
7
+ import type { AutomaticReason } from './approvals'
8
+ import { isActiveStatus, runEventSchema, type QuestionOption, type RunEvent, type RunEventType, type RunRecord } from './contracts'
9
+ import { findRun, persistEvent, readEvents, runFamily } from './runStore'
10
+
11
+ export type ApprovalDecision = 'approved' | 'denied' | 'native'
12
+
13
+ export interface ApprovalInput {
14
+ requestId?: string
15
+ operation?: { tool: string; args: Record<string, unknown> }
16
+ target: string
17
+ action: string
18
+ screenshotRef?: string
19
+ /** A tool the user may choose to always allow for this session. */
20
+ rememberable?: boolean
21
+ /** `switch`: only the "always approve" switch may grant it. `none`: a person always decides. */
22
+ standing?: 'full' | 'switch' | 'none'
23
+ /** How long to wait for a decision before the request counts as denied. */
24
+ timeoutMs?: number
25
+ }
26
+
27
+ export interface QuestionInput {
28
+ requestId?: string
29
+ userOnly?: boolean
30
+ question: string
31
+ options: QuestionOption[]
32
+ multiSelect: boolean
33
+ allowText: boolean
34
+ }
35
+
36
+ export interface QuestionAnswer {
37
+ selected: string[]
38
+ text?: string
39
+ }
40
+
41
+ export interface ActiveRun {
42
+ runId: string
43
+ sessionId: string
44
+ emit(type: RunEventType, payload: unknown, native?: unknown): void
45
+ onInterrupt(handler: () => Promise<void> | void): void
46
+ /** Resolves when the user decides; only the approval with this exact id can be resolved by it. */
47
+ requestApproval(input: ApprovalInput): Promise<ApprovalDecision>
48
+ /** Asks the user a question and resolves with their answer. */
49
+ requestAnswer(input: QuestionInput): Promise<QuestionAnswer>
50
+ finish(outcome: 'completed' | 'failed' | 'interrupted', failure?: { code: string; message: string }): void
51
+ readonly finished: boolean
52
+ }
53
+
54
+ /** Called with every persisted event, and the run's record as it is after that event. */
55
+ export type RunListener = (event: RunEvent, run: RunRecord | null) => void
56
+
57
+ export interface RegistryOptions {
58
+ root: () => string
59
+ /** Why this approval can be granted without asking, if it can. */
60
+ automatic?: (run: RunRecord, target: string, operation: ApprovalInput['operation'], standing: 'full' | 'switch') => Promise<AutomaticReason | null>
61
+ /** Called when a run stops being live, whatever its outcome. */
62
+ ended?: (runId: string, sessionId: string) => void
63
+ /** Removes secrets from an event before it is stored or shown. */
64
+ redact?: (event: RunEvent) => void
65
+ }
66
+
67
+ type LiveRun = ActiveRun & { interrupt: () => Promise<void> }
68
+
69
+ export class RunRegistry {
70
+ private readonly runs = new Map<string, LiveRun>()
71
+ private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
72
+ private readonly questions = new Map<string, { runId: string; input: QuestionInput; resolve: (answer: QuestionAnswer) => void }>()
73
+ private readonly nativeCleared = new Map<string, string>()
74
+ private readonly stopping = new Set<string>()
75
+ private readonly listeners: RunListener[] = []
76
+
77
+ constructor(private readonly options: RegistryOptions) {}
78
+
79
+ /** Observers see events after they are persisted, in order. */
80
+ listen(listener: RunListener): void {
81
+ this.listeners.push(listener)
82
+ }
83
+
84
+ private deliver(event: RunEvent): boolean {
85
+ const root = this.options.root()
86
+ try {
87
+ this.options.redact?.(event)
88
+ if (!persistEvent(root, event)) return false
89
+ } catch (error) {
90
+ log(`could not persist event for ${event.runId}: ${error instanceof Error ? error.message : String(error)}`)
91
+ return false
92
+ }
93
+ const run = findRun(root, event.runId)
94
+ for (const listener of this.listeners) {
95
+ try {
96
+ listener(event, run)
97
+ } catch (error) {
98
+ log('a run listener failed', error instanceof Error ? error.message : String(error))
99
+ }
100
+ }
101
+ return true
102
+ }
103
+
104
+ /** Starts tracking a run whose record is already saved. */
105
+ start(sessionId: string, runId: string = randomUUID()): ActiveRun {
106
+ let sequence = 0
107
+ try {
108
+ sequence = readEvents(this.options.root(), runId).reduce((highest, event) => Math.max(highest, event.sequence + 1), 0)
109
+ } catch {
110
+ sequence = 0
111
+ }
112
+ let finished = false
113
+ const interruptHandlers: Array<() => Promise<void> | void> = []
114
+ const waitingStatus = (): string =>
115
+ [...this.approvals.values()].some((request) => request.runId === runId) ? 'waiting_for_approval' : [...this.questions.values()].some((request) => request.runId === runId) ? 'waiting_for_input' : 'running'
116
+
117
+ const emit = (type: RunEventType, payload: unknown, native?: unknown): void => {
118
+ if (finished) return
119
+ const parsed = runEventSchema.safeParse({ id: randomUUID(), runId, sessionId, sequence, timestamp: new Date().toISOString(), type, payload, ...(native === undefined ? {} : { native }) })
120
+ if (!parsed.success) {
121
+ log(`dropped invalid ${type} event for run ${runId}`)
122
+ return
123
+ }
124
+ sequence += 1
125
+ this.deliver(parsed.data)
126
+ }
127
+
128
+ const run: LiveRun = {
129
+ runId,
130
+ sessionId,
131
+ emit,
132
+ onInterrupt: (handler) => void interruptHandlers.push(handler),
133
+ requestApproval: async (input) => {
134
+ const { requestId, standing = 'full', timeoutMs, ...payload } = input
135
+ const approvalId = requestId ?? randomUUID()
136
+ // What the user already allowed is known here; if it cannot be read, ask as usual.
137
+ const record = findRun(this.options.root(), runId)
138
+ const automatic = record && this.options.automatic && standing !== 'none' ? await this.options.automatic(record, input.target, input.operation, standing).catch(() => null) : null
139
+ if (finished) return 'denied'
140
+ if (this.nativeCleared.delete(approvalId)) return 'native'
141
+ if (automatic) {
142
+ // Marked on the request too, so nothing treats it as waiting on someone.
143
+ emit('approval.requested', { approvalId, ...payload, automatic })
144
+ emit('approval.resolved', { approvalId, decision: 'approved', automatic })
145
+ return 'approved'
146
+ }
147
+ emit('approval.requested', { approvalId, ...payload })
148
+ emit('status.changed', { status: 'waiting_for_approval' })
149
+ return new Promise<ApprovalDecision>((resolve) => {
150
+ const timer = timeoutMs ? setTimeout(() => this.approvals.get(approvalId)?.resolve('denied'), timeoutMs) : null
151
+ this.approvals.set(approvalId, {
152
+ runId,
153
+ resolve: (decision) => {
154
+ if (timer) clearTimeout(timer)
155
+ this.approvals.delete(approvalId)
156
+ emit('approval.resolved', { approvalId, decision })
157
+ emit('status.changed', { status: waitingStatus() })
158
+ resolve(decision)
159
+ }
160
+ })
161
+ })
162
+ },
163
+ requestAnswer: (input) => {
164
+ const { requestId, ...payload } = input
165
+ const questionId = requestId ?? randomUUID()
166
+ if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
167
+ emit('question.requested', { questionId, ...payload })
168
+ emit('status.changed', { status: 'waiting_for_input' })
169
+ return new Promise<QuestionAnswer>((resolve) => {
170
+ this.questions.set(questionId, {
171
+ runId,
172
+ input,
173
+ resolve: (answer) => {
174
+ this.questions.delete(questionId)
175
+ emit('question.answered', { questionId, ...answer })
176
+ emit('status.changed', { status: waitingStatus() })
177
+ resolve(answer)
178
+ }
179
+ })
180
+ })
181
+ },
182
+ finish: (outcome, failure) => {
183
+ if (finished) return
184
+ if (outcome === 'failed') emit('failed', failure ?? { code: 'failed', message: 'The run failed.' })
185
+ else if (outcome === 'interrupted') emit('status.changed', { status: 'interrupted' })
186
+ else emit('completed', {})
187
+ finished = true
188
+ this.runs.delete(runId)
189
+ for (const [id, owner] of this.nativeCleared) if (owner === runId) this.nativeCleared.delete(id)
190
+ for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
191
+ for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
192
+ try {
193
+ this.options.ended?.(runId, sessionId)
194
+ } catch (error) {
195
+ log('a run could not be closed cleanly', error instanceof Error ? error.message : String(error))
196
+ }
197
+ },
198
+ get finished() {
199
+ return finished
200
+ },
201
+ interrupt: async () => {
202
+ for (const handler of interruptHandlers) {
203
+ try {
204
+ await handler()
205
+ } catch (error) {
206
+ log(`an interrupt handler failed for run ${runId}`, error instanceof Error ? error.message : String(error))
207
+ }
208
+ }
209
+ run.finish('interrupted')
210
+ }
211
+ }
212
+
213
+ this.runs.set(runId, run)
214
+ emit('started', {})
215
+ emit('status.changed', { status: 'running' })
216
+ const record = findRun(this.options.root(), runId)
217
+ if (record?.parentRunId && this.hasStoppedAncestor(record.parentRunId)) run.finish('interrupted')
218
+ return run
219
+ }
220
+
221
+ private hasStoppedAncestor(runId: string): boolean {
222
+ const visited = new Set<string>()
223
+ let currentId: string | null = runId
224
+ while (currentId && !visited.has(currentId)) {
225
+ if (this.stopping.has(currentId)) return true
226
+ visited.add(currentId)
227
+ const record = findRun(this.options.root(), currentId)
228
+ if (!record) return false
229
+ if (!isActiveStatus(record.status)) return true
230
+ currentId = record.parentRunId
231
+ }
232
+ return false
233
+ }
234
+
235
+ /** The live run with this id, for a tool or broker that reports into it. */
236
+ active(runId: string): ActiveRun | null {
237
+ return this.runs.get(runId) ?? null
238
+ }
239
+
240
+ /** The live run of a session, when it has one. */
241
+ activeForSession(sessionId: string): ActiveRun | null {
242
+ for (const run of this.runs.values()) if (run.sessionId === sessionId) return run
243
+ return null
244
+ }
245
+
246
+ async interrupt(runId: string): Promise<void> {
247
+ const run = this.runs.get(runId)
248
+ if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
249
+ if (this.stopping.has(runId)) return
250
+ const family = runFamily(this.options.root(), runId)
251
+ const active = family.runs.map(({ run: member }) => member.id).filter((id) => this.runs.has(id))
252
+ for (const id of active) this.stopping.add(id)
253
+ try {
254
+ await Promise.allSettled(active.map((id) => this.runs.get(id)?.interrupt()))
255
+ } finally {
256
+ for (const id of active) this.stopping.delete(id)
257
+ }
258
+ }
259
+
260
+ /** Interrupts every live run, giving each a moment to stop cleanly. */
261
+ async interruptAll(timeoutMs = 3000): Promise<void> {
262
+ await Promise.race([Promise.allSettled(this.activeRunIds().map((id) => this.interrupt(id))), new Promise((resolve) => setTimeout(resolve, timeoutMs))])
263
+ }
264
+
265
+ resolveApproval(runId: string, approvalId: string, decision: ApprovalDecision): void {
266
+ if (decision !== 'approved' && decision !== 'denied' && decision !== 'native') throw new HarnessError('invalid_request', 'Unknown decision.')
267
+ const approval = this.approvals.get(approvalId)
268
+ if (!approval || approval.runId !== runId) throw new HarnessError('approval_not_pending', 'This approval is not pending for this run.')
269
+ approval.resolve(decision)
270
+ }
271
+
272
+ answerQuestion(runId: string, questionId: string, answer: QuestionAnswer): void {
273
+ const question = this.questions.get(questionId)
274
+ if (!question || question.runId !== runId) throw new HarnessError('question_not_pending', 'This question is not waiting for an answer.')
275
+ if ((!question.input.multiSelect && answer.selected.length > 1) || answer.selected.some((label) => !question.input.options.some((option) => option.label === label)) || (answer.text && !question.input.allowText)) {
276
+ throw new HarnessError('invalid_answer', 'Choose the offered options or an allowed text answer.')
277
+ }
278
+ question.resolve(answer)
279
+ }
280
+
281
+ activeRunIds(): string[] {
282
+ return [...this.runs.keys()]
283
+ }
284
+
285
+ /** A request the session's own interface already settled: nothing here is waiting on it any more. */
286
+ nativeResolved(runId: string, requestId: string): void {
287
+ if (!this.runs.has(runId)) return
288
+ const approval = this.approvals.get(requestId)
289
+ if (approval?.runId === runId) return approval.resolve('native')
290
+ const question = this.questions.get(requestId)
291
+ if (question?.runId === runId) return question.resolve({ selected: [] })
292
+ this.nativeCleared.set(requestId, runId)
293
+ }
294
+ }
@@ -0,0 +1,99 @@
1
+ // Run metadata in the index: which runs a session, an agent, or an account has, without
2
+ // reading every run folder.
3
+ import type { DatabaseSync } from 'node:sqlite'
4
+ import { join } from 'node:path'
5
+
6
+ import { readJson } from '../core/fs'
7
+ import { all, run as execute, runFolders, transaction, withIndex } from '../core/index'
8
+ import { runRecordSchema, type RunRecord } from './contracts'
9
+
10
+ export function upsertRun(database: DatabaseSync, record: RunRecord): void {
11
+ execute(
12
+ database,
13
+ `INSERT OR REPLACE INTO runs (id, project_id, mode, harness, session_type, session_id, status, started_at, ended_at, account_id, agent_id)
14
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`,
15
+ record.id,
16
+ record.projectId,
17
+ record.mode,
18
+ record.harness,
19
+ record.sessionType,
20
+ record.sessionId,
21
+ record.status,
22
+ record.startedAt,
23
+ record.endedAt,
24
+ record.accountId,
25
+ record.agentId
26
+ )
27
+ }
28
+
29
+ /** Index failures never block a run. */
30
+ export function recordRun(root: string, record: RunRecord): void {
31
+ try {
32
+ withIndex(root, (database) => upsertRun(database, record))
33
+ } catch {
34
+ // The index is rebuilt from run files when it is next repaired.
35
+ }
36
+ }
37
+
38
+ /** Every readable run record under `<data root>/runs`. */
39
+ export function readAllRuns(root: string): RunRecord[] {
40
+ const records: RunRecord[] = []
41
+ for (const name of runFolders(root)) {
42
+ try {
43
+ const parsed = runRecordSchema.safeParse(readJson(join(root, 'runs', name, 'run.json')))
44
+ if (parsed.success) records.push(parsed.data)
45
+ } catch {
46
+ // Not a run folder.
47
+ }
48
+ }
49
+ return records
50
+ }
51
+
52
+ /**
53
+ * Adds the runs the table is missing, and those whose status changed, from the run files. A run
54
+ * recorded while the index was unavailable would otherwise be absent from session history.
55
+ */
56
+ export function syncRuns(root: string): number {
57
+ const known = new Map<string, { status: string | null; agentId: string | null }>()
58
+ withIndex(root, (database) => {
59
+ for (const row of all<{ id: string; status: string | null; agent_id: string | null }>(database, 'SELECT id, status, agent_id FROM runs')) known.set(row.id, { status: row.status, agentId: row.agent_id })
60
+ })
61
+ // A row is rewritten when its status changed, or when it predates the agent column.
62
+ const pending = readAllRuns(root).filter((record) => {
63
+ const row = known.get(record.id)
64
+ return !row || row.status !== record.status || (row.agentId === null && record.agentId !== null)
65
+ })
66
+ if (!pending.length) return 0
67
+ withIndex(root, (database) => transaction(database, () => pending.forEach((record) => upsertRun(database, record))))
68
+ return pending.length
69
+ }
70
+
71
+ export function runsForSession(root: string, sessionId: string, limit: number): string[] {
72
+ return withIndex(root, (database) => all<{ id: string }>(database, 'SELECT id FROM runs WHERE session_id = ?1 ORDER BY started_at DESC LIMIT ?2', sessionId, limit).map((row) => row.id))
73
+ }
74
+
75
+ /** Which runs to read: one session's, one agent's wherever it replied, one account's, or all. */
76
+ export interface RunFilter {
77
+ session?: string | null
78
+ agent?: string | null
79
+ account?: string | null
80
+ }
81
+
82
+ /** Runs matching `filter` started at or after `since` (an RFC 3339 instant), oldest first. */
83
+ export function runsSince(root: string, filter: RunFilter, since: string | null): string[] {
84
+ return withIndex(root, (database) =>
85
+ // A chat run recorded before the agent column belongs to the agent whose own session it ran in.
86
+ all<{ id: string }>(
87
+ database,
88
+ `SELECT id FROM runs WHERE started_at >= ?1
89
+ AND (?2 IS NULL OR session_id = ?2)
90
+ AND (?3 IS NULL OR agent_id = ?3 OR (agent_id IS NULL AND mode = 'chat' AND session_id = ?3))
91
+ AND (?4 IS NULL OR account_id = ?4)
92
+ ORDER BY started_at`,
93
+ since ?? '',
94
+ filter.session ?? null,
95
+ filter.agent ?? null,
96
+ filter.account ?? null
97
+ ).map((row) => row.id)
98
+ )
99
+ }
@@ -0,0 +1,152 @@
1
+ import { appendFileSync, mkdtempSync, readFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { expect, test } from 'vitest'
5
+
6
+ import { now } from '../core/fs'
7
+ import { closeIndex } from '../core/index'
8
+ import { isActiveStatus, runEventPayloadSchemas, runRecordSchema, type RunEvent, type RunEventType } from './contracts'
9
+ import { appendEvent, executionEvents, loadRun, markInterruptedRuns, pendingDecisions, persistEvent, readEvents, runDir, saveRun, updateRun } from './runStore'
10
+
11
+ const temp = () => mkdtempSync(join(tmpdir(), 'harness-runs-'))
12
+ const valid = (type: RunEventType, payload: unknown): boolean => runEventPayloadSchemas[type].safeParse(payload).success
13
+
14
+ function fixtureRun(root: string, status: string) {
15
+ const run = runRecordSchema.parse({ id: 'run-1', sessionId: 'agent-1', projectId: null, mode: 'chat', harness: 'pi', sessionType: 'structured', nativeSessionId: null, workingDirectory: null, worktreeId: null, goal: null, status, startedAt: 'start', endedAt: null })
16
+ saveRun(root, run)
17
+ return run
18
+ }
19
+
20
+ const fixtureEvent = (sequence: number, type: string, payload: unknown): RunEvent => ({ id: `e${sequence}`, runId: 'run-1', sessionId: 'agent-1', sequence, timestamp: `time-${sequence}`, type: type as RunEventType, payload })
21
+
22
+ test('execution prefix includes first terminal and excludes ignored evidence', () => {
23
+ for (const status of ['completed', 'failed', 'interrupted']) {
24
+ const ignored = { ...fixtureEvent(1, 'usage.updated', { outputTokens: 99 }), native: { hostIgnored: 'run_already_ended' } }
25
+ const events = [fixtureEvent(0, 'started', {}), ignored, fixtureEvent(2, 'status.changed', { status }), fixtureEvent(3, 'message.completed', { text: 'late' })]
26
+ expect(executionEvents(events).map((event) => event.sequence)).toEqual([0, 2])
27
+ }
28
+ })
29
+
30
+ test('trajectory evidence event payloads are validated', () => {
31
+ expect(valid('context.snapshot', { systemPrompt: 'app prompt', contextUpdate: 'changed context', tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }], contextUpdateTruncated: false })).toBe(true)
32
+ expect(valid('context.snapshot', { systemPrompt: 'prompt', tools: [{ name: 'read' }] })).toBe(false)
33
+ expect(valid('tool.started', { toolCallId: 't', name: 'read', input: '{}', inputTruncated: false })).toBe(true)
34
+ expect(valid('tool.completed', { toolCallId: 't', name: 'read', ok: true, output: 'text', outputTruncated: true })).toBe(true)
35
+ expect(valid('retry.started', { attempt: 1, maxAttempts: 3, delayMs: 250, error: 'temporary' })).toBe(true)
36
+ expect(valid('retry.ended', { attempt: 1, success: true })).toBe(true)
37
+ expect(valid('compaction.started', { reason: 'threshold' })).toBe(true)
38
+ expect(valid('compaction.ended', { reason: 'threshold', aborted: false, willRetry: true, summary: 'kept recent messages' })).toBe(true)
39
+ expect(valid('retry.ended', { attempt: 1, success: 'yes' })).toBe(false)
40
+ })
41
+
42
+ test('pending decisions are the requests nothing answered', () => {
43
+ const events = [
44
+ fixtureEvent(0, 'approval.requested', { approvalId: 'a1', target: 'shell', action: 'ls' }),
45
+ fixtureEvent(1, 'question.requested', { questionId: 'q1', question: 'Which?', options: [], multiSelect: false, allowText: true }),
46
+ fixtureEvent(2, 'approval.requested', { approvalId: 'a2', target: 'shell', action: 'pwd' }),
47
+ fixtureEvent(3, 'approval.resolved', { approvalId: 'a1', decision: 'denied' }),
48
+ // An answer to another kind of request with the same id settles nothing.
49
+ fixtureEvent(4, 'question.answered', { questionId: 'a2', selected: [] })
50
+ ]
51
+ expect(pendingDecisions(events).map((decision) => [decision.kind, decision.id, decision.eventId])).toEqual([['question', 'q1', 'e1'], ['approval', 'a2', 'e2']])
52
+ expect(valid('approval.requested', { approvalId: 'a3', target: 'shell', action: 'ls', automatic: 'scope' })).toBe(true)
53
+ expect(valid('approval.resolved', { approvalId: 'a3', decision: 'approved', automatic: 'scope' })).toBe(true)
54
+ expect(valid('approval.resolved', { approvalId: 'a3', decision: 'approved', automatic: 'whenever' })).toBe(false)
55
+ })
56
+
57
+ test('interruption is not overwritten by late tools, messages or completion', () => {
58
+ const root = temp()
59
+ fixtureRun(root, 'running')
60
+ expect(persistEvent(root, fixtureEvent(0, 'status.changed', { status: 'interrupted', reason: 'App stopped' }))).toBe(true)
61
+ const late: [number, string, unknown][] = [
62
+ [1, 'tool.completed', { toolCallId: 't1', name: 'bash', ok: false }],
63
+ [2, 'message.completed', { messageId: 'm1', role: 'assistant', text: 'Saved checkpoint' }],
64
+ [3, 'completed', {}],
65
+ [4, 'status.changed', { status: 'running' }]
66
+ ]
67
+ for (const [index, type, payload] of late) expect(persistEvent(root, fixtureEvent(index, type, payload))).toBe(false)
68
+ updateRun(root, 'run-1', (run) => {
69
+ run.status = 'completed'
70
+ run.nativeSessionId = 'native-1'
71
+ })
72
+ const run = loadRun(root, 'run-1')
73
+ expect([run.status, run.endedAt, run.nativeSessionId, run.terminalReason]).toEqual(['interrupted', 'time-0', 'native-1', 'App stopped'])
74
+ const events = readEvents(root, 'run-1')
75
+ expect(events).toHaveLength(5)
76
+ expect((events[2].payload as { text: string }).text).toBe('Saved checkpoint')
77
+ expect((events[3].native as { hostIgnored: string }).hostIgnored).toBe('run_already_ended')
78
+ closeIndex(root)
79
+ })
80
+
81
+ test('recovery repairs legacy outcomes once without erasing evidence', () => {
82
+ const root = temp()
83
+ fixtureRun(root, 'completed')
84
+ appendEvent(root, fixtureEvent(0, 'status.changed', { status: 'interrupted' }))
85
+ appendEvent(root, fixtureEvent(1, 'message.completed', { role: 'assistant', messageId: 'm', text: 'Partial answer' }))
86
+ appendEvent(root, fixtureEvent(2, 'completed', {}))
87
+ markInterruptedRuns(root)
88
+ const backup = join(runDir(root, 'run-1'), 'run.json.before-outcome-repair')
89
+ const original = readFileSync(backup, 'utf8')
90
+ expect(loadRun(root, 'run-1').status).toBe('interrupted')
91
+ expect(readEvents(root, 'run-1')).toHaveLength(3)
92
+ markInterruptedRuns(root)
93
+ expect(readFileSync(backup, 'utf8')).toBe(original)
94
+ expect(readEvents(root, 'run-1')).toHaveLength(3)
95
+ closeIndex(root)
96
+ })
97
+
98
+ test('a run that never settled is marked interrupted at startup', () => {
99
+ const root = temp()
100
+ fixtureRun(root, 'running')
101
+ appendEvent(root, fixtureEvent(0, 'started', {}))
102
+ expect(markInterruptedRuns(root)).toBe(1)
103
+ expect(loadRun(root, 'run-1').status).toBe('interrupted')
104
+ expect(readEvents(root, 'run-1').at(-1)).toMatchObject({ sequence: 1, type: 'status.changed' })
105
+ expect(markInterruptedRuns(root)).toBe(0)
106
+ closeIndex(root)
107
+ })
108
+
109
+ test('normal completion and failure are terminal too', () => {
110
+ const cases: [string, string, unknown][] = [['completed', 'completed', {}], ['failed', 'failed', { message: 'No quota', code: 'provider_error' }]]
111
+ for (const [type, expected, payload] of cases) {
112
+ const root = temp()
113
+ fixtureRun(root, 'running')
114
+ expect(persistEvent(root, fixtureEvent(0, type, payload))).toBe(true)
115
+ expect(persistEvent(root, fixtureEvent(1, 'status.changed', { status: 'running' }))).toBe(false)
116
+ expect(loadRun(root, 'run-1').status).toBe(expected)
117
+ closeIndex(root)
118
+ }
119
+ })
120
+
121
+ test('active run filter includes only work in progress', () => {
122
+ expect(['queued', 'running', 'waiting_for_approval'].every(isActiveStatus)).toBe(true)
123
+ expect(['completed', 'interrupted', 'failed'].some(isActiveStatus)).toBe(false)
124
+ })
125
+
126
+ test('validates payloads by type', () => {
127
+ expect(valid('message.delta', { messageId: 'm', text: 'hi' })).toBe(true)
128
+ expect(valid('message.delta', { text: 'hi' })).toBe(false)
129
+ expect(valid('usage.updated', { inputTokens: 3, outputTokens: 1 })).toBe(true)
130
+ expect(valid('usage.updated', { inputTokens: 3, cacheReadTokens: 9, reasoningTokens: 1, costUsd: 0.02, requests: 2, contextTokens: 12, contextWindow: 1000, model: 'm', provider: 'p' })).toBe(true)
131
+ expect(valid('usage.updated', { inputTokens: 3, costUsd: '0.02' })).toBe(false)
132
+ expect(valid('usage.updated', { provider: 4 })).toBe(false)
133
+ expect(valid('usage.updated', { request: { index: 1, reasoningSummary: 'The provider summary.' } })).toBe(true)
134
+ expect(valid('usage.updated', { request: { index: 1, reasoningSummary: 'x'.repeat(4_001) } })).toBe(false)
135
+ expect(valid('approval.resolved', { approvalId: 'a', decision: 'denied' })).toBe(true)
136
+ expect(valid('approval.resolved', { approvalId: 'a', decision: 'maybe' })).toBe(false)
137
+ expect(valid('question.requested', { questionId: 'q', question: 'Which?', options: [{ label: 'A' }], multiSelect: false, allowText: true })).toBe(true)
138
+ expect(valid('question.requested', { questionId: 'q', question: 'Which?', options: [{}], multiSelect: false, allowText: true })).toBe(false)
139
+ expect(valid('question.answered', { questionId: 'q', selected: ['A'], text: 'also B' })).toBe(true)
140
+ expect(valid('reaction.added', { emoji: '👍' })).toBe(true)
141
+ expect(valid('reaction.added', { emoji: 'lol' })).toBe(false)
142
+ expect(valid('agent.message', { direction: 'to', agentId: 'a', agentName: 'A', text: 'hi' })).toBe(true)
143
+ expect(valid('status.changed', { status: 'waiting_for_input' })).toBe(true)
144
+ expect(valid('status.changed', { status: 'done' })).toBe(false)
145
+ })
146
+
147
+ test('event log skips torn lines', () => {
148
+ const root = temp()
149
+ appendEvent(root, { id: 'e', runId: 'run-1', sessionId: 's', sequence: 0, timestamp: now(), type: 'started', payload: {} })
150
+ appendFileSync(join(runDir(root, 'run-1'), 'events.jsonl'), '{"id":"torn')
151
+ expect(readEvents(root, 'run-1')).toHaveLength(1)
152
+ })