@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.
- package/README.md +269 -2
- package/dist/cli.js +11046 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.ts +204 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +289 -0
- package/src/index.ts +69 -0
- package/src/mcp/connections.ts +441 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +567 -0
- package/src/mcp/types.ts +105 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +287 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +128 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +250 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +44 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +184 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +130 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +308 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +394 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- 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
|
+
})
|