@nifrajs/coding-agent 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/EXTENSIONS.md +25 -0
- package/PERFORMANCE.md +19 -0
- package/README.md +69 -0
- package/REGISTRY.md +15 -0
- package/SECURITY.md +32 -0
- package/dist/approvals.d.ts +70 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +174 -0
- package/dist/approvals.js.map +1 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +49 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +380 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +66 -0
- package/dist/context.js.map +1 -0
- package/dist/deployment-adapters.d.ts +40 -0
- package/dist/deployment-adapters.d.ts.map +1 -0
- package/dist/deployment-adapters.js +147 -0
- package/dist/deployment-adapters.js.map +1 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +61 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +104 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +284 -0
- package/dist/extensions.js.map +1 -0
- package/dist/handoffs.d.ts +97 -0
- package/dist/handoffs.d.ts.map +1 -0
- package/dist/handoffs.js +199 -0
- package/dist/handoffs.js.map +1 -0
- package/dist/healing.d.ts +56 -0
- package/dist/healing.d.ts.map +1 -0
- package/dist/healing.js +54 -0
- package/dist/healing.js.map +1 -0
- package/dist/host.d.ts +61 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +369 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/isolated-worker.d.ts +2 -0
- package/dist/isolated-worker.d.ts.map +1 -0
- package/dist/isolated-worker.js +155 -0
- package/dist/isolated-worker.js.map +1 -0
- package/dist/isolated.d.ts +52 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +293 -0
- package/dist/isolated.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +339 -0
- package/dist/native.js.map +1 -0
- package/dist/orchestration/artifact-port.d.ts +30 -0
- package/dist/orchestration/artifact-port.d.ts.map +1 -0
- package/dist/orchestration/artifact-port.js +59 -0
- package/dist/orchestration/artifact-port.js.map +1 -0
- package/dist/orchestration/catalog.d.ts +71 -0
- package/dist/orchestration/catalog.d.ts.map +1 -0
- package/dist/orchestration/catalog.js +46 -0
- package/dist/orchestration/catalog.js.map +1 -0
- package/dist/orchestration/compile.d.ts +59 -0
- package/dist/orchestration/compile.d.ts.map +1 -0
- package/dist/orchestration/compile.js +268 -0
- package/dist/orchestration/compile.js.map +1 -0
- package/dist/orchestration/dispatch.d.ts +115 -0
- package/dist/orchestration/dispatch.d.ts.map +1 -0
- package/dist/orchestration/dispatch.js +220 -0
- package/dist/orchestration/dispatch.js.map +1 -0
- package/dist/orchestration/durable-jobs.d.ts +35 -0
- package/dist/orchestration/durable-jobs.d.ts.map +1 -0
- package/dist/orchestration/durable-jobs.js +273 -0
- package/dist/orchestration/durable-jobs.js.map +1 -0
- package/dist/orchestration/effect-key.d.ts +23 -0
- package/dist/orchestration/effect-key.d.ts.map +1 -0
- package/dist/orchestration/effect-key.js +24 -0
- package/dist/orchestration/effect-key.js.map +1 -0
- package/dist/orchestration/evidence-store.d.ts +75 -0
- package/dist/orchestration/evidence-store.d.ts.map +1 -0
- package/dist/orchestration/evidence-store.js +183 -0
- package/dist/orchestration/evidence-store.js.map +1 -0
- package/dist/orchestration/hash.d.ts +12 -0
- package/dist/orchestration/hash.d.ts.map +1 -0
- package/dist/orchestration/hash.js +43 -0
- package/dist/orchestration/hash.js.map +1 -0
- package/dist/orchestration/host.d.ts +121 -0
- package/dist/orchestration/host.d.ts.map +1 -0
- package/dist/orchestration/host.js +334 -0
- package/dist/orchestration/host.js.map +1 -0
- package/dist/orchestration/index.d.ts +20 -0
- package/dist/orchestration/index.d.ts.map +1 -0
- package/dist/orchestration/index.js +19 -0
- package/dist/orchestration/index.js.map +1 -0
- package/dist/orchestration/policy.d.ts +63 -0
- package/dist/orchestration/policy.d.ts.map +1 -0
- package/dist/orchestration/policy.js +80 -0
- package/dist/orchestration/policy.js.map +1 -0
- package/dist/orchestration/recovery.d.ts +87 -0
- package/dist/orchestration/recovery.d.ts.map +1 -0
- package/dist/orchestration/recovery.js +197 -0
- package/dist/orchestration/recovery.js.map +1 -0
- package/dist/orchestration/tracer.d.ts +31 -0
- package/dist/orchestration/tracer.d.ts.map +1 -0
- package/dist/orchestration/tracer.js +141 -0
- package/dist/orchestration/tracer.js.map +1 -0
- package/dist/plans.d.ts +35 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +63 -0
- package/dist/plans.js.map +1 -0
- package/dist/presets.d.ts +10 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +70 -0
- package/dist/presets.js.map +1 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +42 -0
- package/dist/process.js.map +1 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/replay.d.ts +24 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +154 -0
- package/dist/replay.js.map +1 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +412 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session-migration.d.ts +59 -0
- package/dist/session-migration.d.ts.map +1 -0
- package/dist/session-migration.js +315 -0
- package/dist/session-migration.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +222 -0
- package/dist/sessions.js.map +1 -0
- package/dist/subagents.d.ts +51 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +141 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.d.ts +42 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +93 -0
- package/dist/ui.js.map +1 -0
- package/dist/verification.d.ts +34 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +91 -0
- package/dist/verification.js.map +1 -0
- package/dist/workflows.d.ts +98 -0
- package/dist/workflows.d.ts.map +1 -0
- package/dist/workflows.js +174 -0
- package/dist/workflows.js.map +1 -0
- package/package.json +84 -0
- package/src/approvals.ts +249 -0
- package/src/capabilities.ts +74 -0
- package/src/cli.ts +405 -0
- package/src/context.ts +81 -0
- package/src/deployment-adapters.ts +181 -0
- package/src/diff.ts +80 -0
- package/src/errors.ts +46 -0
- package/src/extensions.ts +402 -0
- package/src/handoffs.ts +299 -0
- package/src/healing.ts +93 -0
- package/src/host.ts +465 -0
- package/src/index.ts +165 -0
- package/src/isolated-worker.ts +177 -0
- package/src/isolated.ts +344 -0
- package/src/native.ts +478 -0
- package/src/orchestration/artifact-port.ts +75 -0
- package/src/orchestration/catalog.ts +102 -0
- package/src/orchestration/compile.ts +373 -0
- package/src/orchestration/dispatch.ts +383 -0
- package/src/orchestration/durable-jobs.ts +374 -0
- package/src/orchestration/effect-key.ts +35 -0
- package/src/orchestration/evidence-store.ts +233 -0
- package/src/orchestration/hash.ts +43 -0
- package/src/orchestration/host.ts +445 -0
- package/src/orchestration/index.ts +131 -0
- package/src/orchestration/policy.ts +116 -0
- package/src/orchestration/recovery.ts +271 -0
- package/src/orchestration/tracer.ts +177 -0
- package/src/plans.ts +105 -0
- package/src/presets.ts +90 -0
- package/src/process.ts +47 -0
- package/src/registry.ts +80 -0
- package/src/replay.ts +190 -0
- package/src/rpc.ts +598 -0
- package/src/session-migration.ts +401 -0
- package/src/sessions.ts +303 -0
- package/src/subagents.ts +210 -0
- package/src/tools.ts +52 -0
- package/src/ui.ts +137 -0
- package/src/verification.ts +133 -0
- package/src/workflows.ts +279 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery state machine for at-least-once node dispatch.
|
|
3
|
+
*
|
|
4
|
+
* The machine treats every effect invocation as potentially side-effecting. A normal retry is
|
|
5
|
+
* allowed only for an explicit pre-effect rejection. Once an effect has been invoked, recovery
|
|
6
|
+
* requires a matching idempotency proof; without one it dead-letters the dispatch instead of
|
|
7
|
+
* guessing that a duplicate is safe. This is deliberately conservative and never claims exactly
|
|
8
|
+
* once delivery or hostile-code isolation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type DispatchResultCode,
|
|
13
|
+
type InjectedClock,
|
|
14
|
+
type RunDispatchStore,
|
|
15
|
+
type RunLease,
|
|
16
|
+
systemClock,
|
|
17
|
+
} from "./dispatch.ts"
|
|
18
|
+
|
|
19
|
+
export interface IdempotencyProofStore {
|
|
20
|
+
has(key: string): boolean | Promise<boolean>
|
|
21
|
+
record(key: string): void | Promise<void>
|
|
22
|
+
}
|
|
23
|
+
/** Disposable proof reference for tests and one-process local runs. */
|
|
24
|
+
export class MemoryIdempotencyProofStore implements IdempotencyProofStore {
|
|
25
|
+
private readonly keys = new Set<string>()
|
|
26
|
+
|
|
27
|
+
has(key: string): boolean {
|
|
28
|
+
return this.keys.has(key)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
record(key: string): void {
|
|
32
|
+
this.keys.add(key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
keysSnapshot(): readonly string[] {
|
|
36
|
+
return Object.freeze([...this.keys].sort())
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A side effect must explicitly confirm that its idempotency proof was durably recorded. */
|
|
41
|
+
export interface CommittedEffect {
|
|
42
|
+
readonly committed: true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RunEffectContext {
|
|
46
|
+
readonly lease: RunLease
|
|
47
|
+
readonly signal: AbortSignal
|
|
48
|
+
/** Persist a content-free safe boundary; a failed write prevents the next unsafe step. */
|
|
49
|
+
readonly checkpoint: (boundary: "before-effect" | "after-effect") => Promise<boolean>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type RunEffect = (context: RunEffectContext) => Promise<CommittedEffect> | CommittedEffect
|
|
53
|
+
|
|
54
|
+
/** Stable pre-effect failure; it may be retried with a new logical attempt. */
|
|
55
|
+
export class EffectRejectedError extends Error {
|
|
56
|
+
readonly code: string
|
|
57
|
+
|
|
58
|
+
constructor(code = "effect_rejected") {
|
|
59
|
+
super("run effect was rejected before the side effect boundary")
|
|
60
|
+
this.name = "EffectRejectedError"
|
|
61
|
+
this.code = code
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Simulates worker loss at a named boundary; the lease is intentionally left unresolved. */
|
|
66
|
+
export class RecoveryCrashError extends Error {
|
|
67
|
+
readonly boundary: "before-effect" | "after-effect" | "after-checkpoint"
|
|
68
|
+
|
|
69
|
+
constructor(boundary: "before-effect" | "after-effect" | "after-checkpoint") {
|
|
70
|
+
super("worker stopped at a recovery boundary")
|
|
71
|
+
this.name = "RecoveryCrashError"
|
|
72
|
+
this.boundary = boundary
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RecoveryMachineOptions {
|
|
77
|
+
readonly store: RunDispatchStore
|
|
78
|
+
readonly clock?: InjectedClock
|
|
79
|
+
readonly proofStore?: IdempotencyProofStore
|
|
80
|
+
readonly leaseMs?: number
|
|
81
|
+
readonly retryBackoff?: (attempt: number) => number
|
|
82
|
+
readonly signal?: AbortSignal
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RecoveryProcessResult {
|
|
86
|
+
readonly leased: number
|
|
87
|
+
readonly completed: number
|
|
88
|
+
readonly retried: number
|
|
89
|
+
readonly deadLettered: number
|
|
90
|
+
readonly cancelled: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DEFAULT_LEASE_MS = 30_000
|
|
94
|
+
const DEFAULT_BACKOFF = (attempt: number): number => Math.min(1_000 * Math.max(1, attempt), 60_000)
|
|
95
|
+
|
|
96
|
+
function stableCode(error: unknown): string {
|
|
97
|
+
if (error instanceof EffectRejectedError) return error.code
|
|
98
|
+
if (error instanceof RecoveryCrashError) return `worker_${error.boundary.replaceAll("-", "_")}`
|
|
99
|
+
return "effect_failed"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isAbort(signal: AbortSignal, error?: unknown): boolean {
|
|
103
|
+
return signal.aborted || (error instanceof DOMException && error.name === "AbortError")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Drives leases through safe checkpoints and terminal transitions. The caller owns the effect and
|
|
108
|
+
* any real idempotency ledger; this class stores only opaque proof keys in the reference adapter.
|
|
109
|
+
*/
|
|
110
|
+
export class RunRecoveryMachine {
|
|
111
|
+
private readonly store: RunDispatchStore
|
|
112
|
+
private readonly clock: InjectedClock
|
|
113
|
+
private readonly proofs: IdempotencyProofStore
|
|
114
|
+
private readonly leaseMs: number
|
|
115
|
+
private readonly retryBackoff: (attempt: number) => number
|
|
116
|
+
private readonly signal: AbortSignal
|
|
117
|
+
|
|
118
|
+
constructor(options: RecoveryMachineOptions) {
|
|
119
|
+
this.store = options.store
|
|
120
|
+
this.clock = options.clock ?? systemClock
|
|
121
|
+
this.proofs = options.proofStore ?? new MemoryIdempotencyProofStore()
|
|
122
|
+
this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS
|
|
123
|
+
this.retryBackoff = options.retryBackoff ?? DEFAULT_BACKOFF
|
|
124
|
+
this.signal = options.signal ?? new AbortController().signal
|
|
125
|
+
if (!Number.isFinite(this.leaseMs) || this.leaseMs <= 0)
|
|
126
|
+
throw new RangeError("recovery leaseMs must be positive")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Cancel all matching run dispatches; every late lease write then fails closed. */
|
|
130
|
+
async cancel(runId: string): Promise<number> {
|
|
131
|
+
return await this.store.cancel(runId, "cancelled")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Process one bounded lease batch. No payload is read or emitted by this coordinator. */
|
|
135
|
+
async process(
|
|
136
|
+
effect: RunEffect,
|
|
137
|
+
options: { readonly limit?: number } = {},
|
|
138
|
+
): Promise<RecoveryProcessResult> {
|
|
139
|
+
const limit = options.limit ?? 1
|
|
140
|
+
const leases = await this.store.lease(this.clock.now(), limit, this.leaseMs)
|
|
141
|
+
const summary = {
|
|
142
|
+
leased: leases.length,
|
|
143
|
+
completed: 0,
|
|
144
|
+
retried: 0,
|
|
145
|
+
deadLettered: 0,
|
|
146
|
+
cancelled: 0,
|
|
147
|
+
}
|
|
148
|
+
for (const lease of leases) {
|
|
149
|
+
const outcome = await this.processLease(lease, effect)
|
|
150
|
+
summary.completed += outcome === "completed" ? 1 : 0
|
|
151
|
+
summary.retried += outcome === "retried" ? 1 : 0
|
|
152
|
+
summary.deadLettered += outcome === "dead-lettered" ? 1 : 0
|
|
153
|
+
summary.cancelled += outcome === "cancelled" ? 1 : 0
|
|
154
|
+
}
|
|
155
|
+
return Object.freeze(summary)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async processLease(
|
|
159
|
+
lease: RunLease,
|
|
160
|
+
effect: RunEffect,
|
|
161
|
+
): Promise<"completed" | "retried" | "dead-lettered" | "cancelled" | "waiting"> {
|
|
162
|
+
const signal = this.signal
|
|
163
|
+
if (signal.aborted) {
|
|
164
|
+
await this.store.cancel(lease.dispatch.runId, "cancelled")
|
|
165
|
+
return "cancelled"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const inspection = await this.store.inspect(lease.dispatch.dispatchId)
|
|
169
|
+
const checkpoint = inspection?.checkpoint
|
|
170
|
+
const proof = await this.proofs.has(lease.idempotencyKey)
|
|
171
|
+
if (proof || checkpoint?.boundary === "after-effect") {
|
|
172
|
+
if (!proof) {
|
|
173
|
+
await this.store.deadLetter(lease, "idempotency_required")
|
|
174
|
+
return "dead-lettered"
|
|
175
|
+
}
|
|
176
|
+
const completed = await this.store.complete(lease)
|
|
177
|
+
return completed.ok ? "completed" : "waiting"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const before = await this.storeCheckpoint(lease, "before-effect")
|
|
181
|
+
if (!before) return "waiting"
|
|
182
|
+
if (signal.aborted) {
|
|
183
|
+
await this.store.cancel(lease.dispatch.runId, "cancelled")
|
|
184
|
+
return "cancelled"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let invoked = false
|
|
188
|
+
try {
|
|
189
|
+
invoked = true
|
|
190
|
+
const committed = await effect({
|
|
191
|
+
lease,
|
|
192
|
+
signal,
|
|
193
|
+
checkpoint: (boundary) => this.storeCheckpoint(lease, boundary),
|
|
194
|
+
})
|
|
195
|
+
if (committed.committed !== true) {
|
|
196
|
+
await this.store.deadLetter(lease, "idempotency_required")
|
|
197
|
+
return "dead-lettered"
|
|
198
|
+
}
|
|
199
|
+
await this.proofs.record(lease.idempotencyKey)
|
|
200
|
+
const after = await this.storeCheckpoint(lease, "after-effect")
|
|
201
|
+
if (!after) return "waiting"
|
|
202
|
+
const completed = await this.store.complete(lease)
|
|
203
|
+
return completed.ok ? "completed" : "waiting"
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof RecoveryCrashError) return "waiting"
|
|
206
|
+
if (isAbort(signal, error)) {
|
|
207
|
+
await this.store.cancel(lease.dispatch.runId, "cancelled")
|
|
208
|
+
return "cancelled"
|
|
209
|
+
}
|
|
210
|
+
const failure = stableCode(error)
|
|
211
|
+
if (invoked && !(error instanceof EffectRejectedError)) {
|
|
212
|
+
// The effect boundary was crossed, so a retry would be an unproven duplicate.
|
|
213
|
+
await this.store.deadLetter(lease, "idempotency_required")
|
|
214
|
+
return "dead-lettered"
|
|
215
|
+
}
|
|
216
|
+
if (lease.attempt >= lease.dispatch.maxAttempts) {
|
|
217
|
+
await this.store.deadLetter(lease, failure)
|
|
218
|
+
return "dead-lettered"
|
|
219
|
+
}
|
|
220
|
+
const delay = this.retryBackoff(lease.attempt)
|
|
221
|
+
if (!Number.isFinite(delay) || delay < 0)
|
|
222
|
+
throw new RangeError("recovery retry backoff is invalid")
|
|
223
|
+
const retried = await this.store.retry(
|
|
224
|
+
lease,
|
|
225
|
+
this.clock.now() + Math.min(delay, 86_400_000),
|
|
226
|
+
failure,
|
|
227
|
+
)
|
|
228
|
+
return retried.ok ? "retried" : "waiting"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async storeCheckpoint(
|
|
233
|
+
lease: RunLease,
|
|
234
|
+
boundary: "before-effect" | "after-effect",
|
|
235
|
+
): Promise<boolean> {
|
|
236
|
+
const outcome = await this.store.checkpoint(lease, {
|
|
237
|
+
version: 1,
|
|
238
|
+
dispatchId: lease.dispatch.dispatchId,
|
|
239
|
+
runId: lease.dispatch.runId,
|
|
240
|
+
nodeId: lease.dispatch.nodeId,
|
|
241
|
+
attempt: lease.attempt,
|
|
242
|
+
generation: lease.generation,
|
|
243
|
+
boundary,
|
|
244
|
+
idempotencyKey: lease.idempotencyKey,
|
|
245
|
+
at: this.clock.now(),
|
|
246
|
+
scheduleToken: lease.scheduleToken,
|
|
247
|
+
})
|
|
248
|
+
return outcome.ok
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A small deterministic clock useful for recovery schedules and tests. */
|
|
253
|
+
export class TestClock implements InjectedClock {
|
|
254
|
+
private current: number
|
|
255
|
+
|
|
256
|
+
constructor(start = 0) {
|
|
257
|
+
if (!Number.isSafeInteger(start) || start < 0)
|
|
258
|
+
throw new RangeError("test clock start is invalid")
|
|
259
|
+
this.current = start
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
now = (): number => this.current
|
|
263
|
+
|
|
264
|
+
advance(milliseconds: number): void {
|
|
265
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0)
|
|
266
|
+
throw new RangeError("test clock advance is invalid")
|
|
267
|
+
this.current += milliseconds
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type { DispatchResultCode }
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The end-to-end tracer: compile a {@link RunPlan}, run it through the {@link WorkflowRunner}, and
|
|
3
|
+
* project each transition into content-free {@link RunEvidence}. Every emitted record is re-parsed
|
|
4
|
+
* through {@link parseRunEvidence}, so the tracer itself proves the stream carries no payload and
|
|
5
|
+
* stays under the size cap. The terminal digest is a deterministic hash of the evidence stream:
|
|
6
|
+
* identical plan + catalog + inputs yield an identical digest, which is the deterministic-eval anchor.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ArtifactPort,
|
|
11
|
+
ArtifactRef,
|
|
12
|
+
NodeEffectKey,
|
|
13
|
+
RunEvidence,
|
|
14
|
+
RunNode,
|
|
15
|
+
RunNodeKind,
|
|
16
|
+
RunPlan,
|
|
17
|
+
} from "@nifrajs/agent-protocol"
|
|
18
|
+
import { parseRunEvidence, parseRunPlan, RUN_PLAN_VERSION } from "@nifrajs/agent-protocol"
|
|
19
|
+
import { type WorkflowEvent, WorkflowRunner } from "../workflows.ts"
|
|
20
|
+
import { noopArtifactPort } from "./artifact-port.ts"
|
|
21
|
+
import type { StepCatalog } from "./catalog.ts"
|
|
22
|
+
import { compileRunPlan, digestRunPlan } from "./compile.ts"
|
|
23
|
+
import { canonicalJson, sha256HexOf } from "./hash.ts"
|
|
24
|
+
|
|
25
|
+
/** A verify/approve node whose output is not a pass. Other kinds never gate. */
|
|
26
|
+
function isGateRejection(kind: RunNodeKind | undefined, output: unknown): boolean {
|
|
27
|
+
if (kind === "verify") {
|
|
28
|
+
if (output === true) return false
|
|
29
|
+
return !(
|
|
30
|
+
typeof output === "object" &&
|
|
31
|
+
output !== null &&
|
|
32
|
+
(output as { ok?: unknown }).ok === true
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
if (kind === "approve") return output !== true
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RunTraceOptions {
|
|
40
|
+
/** Caller-supplied, so a trace is fully reproducible. No clock or randomness is read here. */
|
|
41
|
+
readonly runId: string
|
|
42
|
+
readonly catalog: StepCatalog
|
|
43
|
+
/** Defaults to {@link noopArtifactPort} (hash-and-discard). */
|
|
44
|
+
readonly artifactPort?: ArtifactPort
|
|
45
|
+
readonly signal?: AbortSignal
|
|
46
|
+
readonly maxSteps?: number
|
|
47
|
+
readonly maxDepth?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunTraceResult {
|
|
51
|
+
readonly ok: boolean
|
|
52
|
+
readonly runId: string
|
|
53
|
+
readonly planDigest: string
|
|
54
|
+
readonly evidence: readonly RunEvidence[]
|
|
55
|
+
/** SHA-256 hex of the canonical evidence stream. Deterministic across identical runs. */
|
|
56
|
+
readonly terminalDigest: string
|
|
57
|
+
readonly error?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run a plan and return its content-free evidence stream plus a deterministic terminal digest. */
|
|
61
|
+
export async function runTrace(
|
|
62
|
+
source: RunPlan | unknown,
|
|
63
|
+
options: RunTraceOptions,
|
|
64
|
+
): Promise<RunTraceResult> {
|
|
65
|
+
const plan = parseRunPlan(source)
|
|
66
|
+
const planDigest = await digestRunPlan(plan)
|
|
67
|
+
const artifactPort = options.artifactPort ?? noopArtifactPort()
|
|
68
|
+
|
|
69
|
+
const effectKeys = new Map<string, NodeEffectKey>()
|
|
70
|
+
const artifacts = new Map<string, ArtifactRef[]>()
|
|
71
|
+
const idempotent = new Map<string, boolean>()
|
|
72
|
+
const kindById = new Map<string, RunNodeKind>()
|
|
73
|
+
const nodeIds = new Set<string>()
|
|
74
|
+
const walk = (node: RunNode): void => {
|
|
75
|
+
nodeIds.add(node.id)
|
|
76
|
+
kindById.set(node.id, node.kind)
|
|
77
|
+
const step = "step" in node ? options.catalog.get(node.step) : undefined
|
|
78
|
+
idempotent.set(node.id, step?.selectEffect !== undefined)
|
|
79
|
+
switch (node.kind) {
|
|
80
|
+
case "sequence":
|
|
81
|
+
case "parallel":
|
|
82
|
+
for (const child of node.children) walk(child)
|
|
83
|
+
break
|
|
84
|
+
case "retry":
|
|
85
|
+
walk(node.child)
|
|
86
|
+
break
|
|
87
|
+
case "branch":
|
|
88
|
+
walk(node.then)
|
|
89
|
+
if (node.otherwise !== undefined) walk(node.otherwise)
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const node of plan.nodes) walk(node)
|
|
94
|
+
|
|
95
|
+
const step = compileRunPlan(plan, {
|
|
96
|
+
catalog: options.catalog,
|
|
97
|
+
planDigest,
|
|
98
|
+
artifactPort,
|
|
99
|
+
onNodeEffect: (nodeId, key) => effectKeys.set(nodeId, key),
|
|
100
|
+
collectArtifact: (nodeId, ref) => {
|
|
101
|
+
const list = artifacts.get(nodeId)
|
|
102
|
+
if (list === undefined) artifacts.set(nodeId, [ref])
|
|
103
|
+
else list.push(ref)
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const evidence: RunEvidence[] = []
|
|
108
|
+
let seq = 0
|
|
109
|
+
const push = (record: RunEvidence): void => {
|
|
110
|
+
// Re-parse each record: proves it is content-free and within the size cap before it is kept.
|
|
111
|
+
evidence.push(parseRunEvidence(record))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const onEvent = (event: WorkflowEvent): void => {
|
|
115
|
+
if (!("id" in event) || !nodeIds.has(event.id)) return
|
|
116
|
+
const base = {
|
|
117
|
+
version: RUN_PLAN_VERSION,
|
|
118
|
+
runId: options.runId,
|
|
119
|
+
planDigest,
|
|
120
|
+
nodeId: event.id,
|
|
121
|
+
seq: seq++,
|
|
122
|
+
idempotent: idempotent.get(event.id) ?? false,
|
|
123
|
+
} as const
|
|
124
|
+
if (event.type === "step.started") {
|
|
125
|
+
push({ ...base, status: "started" })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (event.type === "step.completed") {
|
|
129
|
+
const key = effectKeys.get(event.id)
|
|
130
|
+
const refs = artifacts.get(event.id)
|
|
131
|
+
// A verify/approve gate reports pass/fail through its output, then the kernel aborts the run on
|
|
132
|
+
// a non-pass. Project that as a failed record so the evidence reflects the gate's verdict.
|
|
133
|
+
if (isGateRejection(kindById.get(event.id), event.output)) {
|
|
134
|
+
push({
|
|
135
|
+
...base,
|
|
136
|
+
status: "failed",
|
|
137
|
+
errorCode: "GATE_REJECTED",
|
|
138
|
+
...(key !== undefined ? { effectKey: key.digest } : {}),
|
|
139
|
+
})
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
push({
|
|
143
|
+
...base,
|
|
144
|
+
status: "completed",
|
|
145
|
+
...(key !== undefined ? { effectKey: key.digest } : {}),
|
|
146
|
+
...(refs !== undefined && refs.length > 0 ? { artifacts: refs } : {}),
|
|
147
|
+
})
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (event.type === "step.failed") {
|
|
151
|
+
const key = effectKeys.get(event.id)
|
|
152
|
+
push({
|
|
153
|
+
...base,
|
|
154
|
+
status: "failed",
|
|
155
|
+
errorCode: "STEP_FAILED",
|
|
156
|
+
...(key !== undefined ? { effectKey: key.digest } : {}),
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await new WorkflowRunner({
|
|
162
|
+
onEvent,
|
|
163
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
164
|
+
...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}),
|
|
165
|
+
...(options.maxDepth !== undefined ? { maxDepth: options.maxDepth } : {}),
|
|
166
|
+
}).run(step)
|
|
167
|
+
|
|
168
|
+
const terminalDigest = await sha256HexOf(canonicalJson(evidence))
|
|
169
|
+
return {
|
|
170
|
+
ok: result.ok,
|
|
171
|
+
runId: options.runId,
|
|
172
|
+
planDigest,
|
|
173
|
+
evidence,
|
|
174
|
+
terminalDigest,
|
|
175
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/plans.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type WorkflowEvent,
|
|
3
|
+
type WorkflowResult,
|
|
4
|
+
WorkflowRunner,
|
|
5
|
+
type WorkflowStep,
|
|
6
|
+
} from "./workflows.ts"
|
|
7
|
+
|
|
8
|
+
export interface PlanPhase {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly title: string
|
|
11
|
+
readonly step: WorkflowStep
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentPlan {
|
|
15
|
+
readonly id: string
|
|
16
|
+
readonly goal: string
|
|
17
|
+
readonly phases: readonly PlanPhase[]
|
|
18
|
+
readonly maxSteps?: number
|
|
19
|
+
readonly maxDepth?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlanEvent {
|
|
23
|
+
readonly type:
|
|
24
|
+
| "plan.started"
|
|
25
|
+
| "plan.phase.started"
|
|
26
|
+
| "plan.phase.completed"
|
|
27
|
+
| "plan.completed"
|
|
28
|
+
| "plan.failed"
|
|
29
|
+
readonly planId: string
|
|
30
|
+
readonly phaseId?: string
|
|
31
|
+
readonly message?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PlanRunnerOptions {
|
|
35
|
+
readonly signal?: AbortSignal
|
|
36
|
+
readonly onEvent?: (event: PlanEvent | WorkflowEvent) => void | PromiseLike<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PlanResult extends WorkflowResult {
|
|
40
|
+
readonly planId: string
|
|
41
|
+
readonly goal: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** First-party plan mode built on the bounded workflow primitives; no model or UI dependency. */
|
|
45
|
+
export class PlanRunner {
|
|
46
|
+
private readonly options: PlanRunnerOptions
|
|
47
|
+
|
|
48
|
+
constructor(options: PlanRunnerOptions = {}) {
|
|
49
|
+
this.options = options
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async run(plan: AgentPlan): Promise<PlanResult> {
|
|
53
|
+
validatePlan(plan)
|
|
54
|
+
await this.emit({ type: "plan.started", planId: plan.id, message: plan.goal })
|
|
55
|
+
const workflow = new WorkflowRunner({
|
|
56
|
+
...(this.options.signal === undefined ? {} : { signal: this.options.signal }),
|
|
57
|
+
...(plan.maxSteps === undefined ? {} : { maxSteps: plan.maxSteps }),
|
|
58
|
+
...(plan.maxDepth === undefined ? {} : { maxDepth: plan.maxDepth }),
|
|
59
|
+
onEvent: async (event) => this.options.onEvent?.(event),
|
|
60
|
+
})
|
|
61
|
+
const step: WorkflowStep = {
|
|
62
|
+
type: "sequence",
|
|
63
|
+
steps: plan.phases.map((phase) => ({
|
|
64
|
+
type: "task" as const,
|
|
65
|
+
id: phase.id,
|
|
66
|
+
run: async (context) => {
|
|
67
|
+
await this.emit({
|
|
68
|
+
type: "plan.phase.started",
|
|
69
|
+
planId: plan.id,
|
|
70
|
+
phaseId: phase.id,
|
|
71
|
+
message: phase.title,
|
|
72
|
+
})
|
|
73
|
+
const output = await context.run(phase.step)
|
|
74
|
+
await this.emit({ type: "plan.phase.completed", planId: plan.id, phaseId: phase.id })
|
|
75
|
+
return output
|
|
76
|
+
},
|
|
77
|
+
})),
|
|
78
|
+
}
|
|
79
|
+
const result = await workflow.run(step)
|
|
80
|
+
await this.emit({
|
|
81
|
+
type: result.ok ? "plan.completed" : "plan.failed",
|
|
82
|
+
planId: plan.id,
|
|
83
|
+
...(result.error === undefined ? {} : { message: result.error }),
|
|
84
|
+
})
|
|
85
|
+
return { ...result, planId: plan.id, goal: plan.goal }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async emit(event: PlanEvent): Promise<void> {
|
|
89
|
+
await this.options.onEvent?.(event)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validatePlan(plan: AgentPlan): void {
|
|
94
|
+
if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(plan.id)) throw new TypeError("plan: id is invalid")
|
|
95
|
+
if (!plan.goal || plan.goal.length > 4_096) throw new TypeError("plan: goal is empty or too long")
|
|
96
|
+
if (plan.phases.length === 0 || plan.phases.length > 128)
|
|
97
|
+
throw new RangeError("plan: phases must be between 1 and 128")
|
|
98
|
+
const ids = new Set<string>()
|
|
99
|
+
for (const phase of plan.phases) {
|
|
100
|
+
if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(phase.id) || !phase.title || phase.title.length > 256)
|
|
101
|
+
throw new TypeError(`plan: invalid phase ${phase.id}`)
|
|
102
|
+
if (ids.has(phase.id)) throw new TypeError(`plan: duplicate phase ${phase.id}`)
|
|
103
|
+
ids.add(phase.id)
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { SubagentSpec } from "./subagents.ts"
|
|
2
|
+
|
|
3
|
+
export type AgentPresetName =
|
|
4
|
+
| "planner"
|
|
5
|
+
| "implementer"
|
|
6
|
+
| "test-fixer"
|
|
7
|
+
| "reviewer"
|
|
8
|
+
| "security-reviewer"
|
|
9
|
+
| "final-verifier"
|
|
10
|
+
|
|
11
|
+
export interface AgentPreset extends Omit<SubagentSpec, "id" | "prompt"> {
|
|
12
|
+
readonly name: AgentPresetName
|
|
13
|
+
readonly summary: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PRESETS: Readonly<Record<AgentPresetName, AgentPreset>> = Object.freeze({
|
|
17
|
+
planner: {
|
|
18
|
+
name: "planner",
|
|
19
|
+
role: "planner",
|
|
20
|
+
summary: "Break a task into bounded, verifiable phases.",
|
|
21
|
+
capabilities: Object.freeze(["read"]),
|
|
22
|
+
maxDepth: 1,
|
|
23
|
+
timeoutMs: 120_000,
|
|
24
|
+
},
|
|
25
|
+
implementer: {
|
|
26
|
+
name: "implementer",
|
|
27
|
+
role: "implementer",
|
|
28
|
+
summary: "Apply a narrowly scoped implementation and report changed files.",
|
|
29
|
+
capabilities: Object.freeze(["read", "write", "process"]),
|
|
30
|
+
maxDepth: 1,
|
|
31
|
+
timeoutMs: 600_000,
|
|
32
|
+
},
|
|
33
|
+
"test-fixer": {
|
|
34
|
+
name: "test-fixer",
|
|
35
|
+
role: "test-fixer",
|
|
36
|
+
summary: "Reproduce and repair a failing test within the task boundary.",
|
|
37
|
+
capabilities: Object.freeze(["read", "write", "process"]),
|
|
38
|
+
maxDepth: 1,
|
|
39
|
+
timeoutMs: 600_000,
|
|
40
|
+
},
|
|
41
|
+
reviewer: {
|
|
42
|
+
name: "reviewer",
|
|
43
|
+
role: "reviewer",
|
|
44
|
+
summary: "Review a proposed change for correctness and regressions.",
|
|
45
|
+
capabilities: Object.freeze(["read", "process"]),
|
|
46
|
+
maxDepth: 1,
|
|
47
|
+
timeoutMs: 240_000,
|
|
48
|
+
},
|
|
49
|
+
"security-reviewer": {
|
|
50
|
+
name: "security-reviewer",
|
|
51
|
+
role: "security-reviewer",
|
|
52
|
+
summary: "Review capabilities, trust boundaries, secrets, and process execution.",
|
|
53
|
+
capabilities: Object.freeze(["read", "process"]),
|
|
54
|
+
maxDepth: 1,
|
|
55
|
+
timeoutMs: 240_000,
|
|
56
|
+
},
|
|
57
|
+
"final-verifier": {
|
|
58
|
+
name: "final-verifier",
|
|
59
|
+
role: "final-verifier",
|
|
60
|
+
summary: "Run the configured verification gates and return structured evidence.",
|
|
61
|
+
capabilities: Object.freeze(["read", "process"]),
|
|
62
|
+
maxDepth: 1,
|
|
63
|
+
timeoutMs: 300_000,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
export const AGENT_PRESETS = PRESETS
|
|
68
|
+
|
|
69
|
+
export function getAgentPreset(name: AgentPresetName): AgentPreset {
|
|
70
|
+
return PRESETS[name]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createPresetSpec(
|
|
74
|
+
name: AgentPresetName,
|
|
75
|
+
prompt: string,
|
|
76
|
+
id = `${name}-${Date.now().toString(36)}`,
|
|
77
|
+
): SubagentSpec {
|
|
78
|
+
const preset = getAgentPreset(name)
|
|
79
|
+
if (!prompt || prompt.length > 16_384)
|
|
80
|
+
throw new TypeError("agent preset: prompt is empty or too long")
|
|
81
|
+
if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(id)) throw new TypeError("agent preset: id is invalid")
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
role: preset.role,
|
|
85
|
+
prompt,
|
|
86
|
+
...(preset.capabilities === undefined ? {} : { capabilities: preset.capabilities }),
|
|
87
|
+
...(preset.maxDepth === undefined ? {} : { maxDepth: preset.maxDepth }),
|
|
88
|
+
...(preset.timeoutMs === undefined ? {} : { timeoutMs: preset.timeoutMs }),
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/process.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface BoundedText {
|
|
2
|
+
readonly text: string
|
|
3
|
+
readonly truncated: boolean
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function readBoundedText(
|
|
7
|
+
stream: ReadableStream<Uint8Array> | null | undefined | number,
|
|
8
|
+
maxBytes: number,
|
|
9
|
+
): Promise<BoundedText> {
|
|
10
|
+
if (stream === null || stream === undefined || typeof stream === "number")
|
|
11
|
+
return { text: "", truncated: false }
|
|
12
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
13
|
+
throw new RangeError("bounded process output: maxBytes must be positive")
|
|
14
|
+
const reader = stream.getReader()
|
|
15
|
+
const decoder = new TextDecoder()
|
|
16
|
+
const chunks: string[] = []
|
|
17
|
+
let total = 0
|
|
18
|
+
let truncated = false
|
|
19
|
+
try {
|
|
20
|
+
for (;;) {
|
|
21
|
+
const { done, value } = await reader.read()
|
|
22
|
+
if (done) {
|
|
23
|
+
chunks.push(decoder.decode())
|
|
24
|
+
break
|
|
25
|
+
}
|
|
26
|
+
if (value === undefined) continue
|
|
27
|
+
const remaining = maxBytes - total
|
|
28
|
+
if (remaining <= 0) {
|
|
29
|
+
truncated = true
|
|
30
|
+
await reader.cancel()
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
if (value.byteLength > remaining) {
|
|
34
|
+
chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }))
|
|
35
|
+
chunks.push(decoder.decode())
|
|
36
|
+
truncated = true
|
|
37
|
+
await reader.cancel()
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
total += value.byteLength
|
|
41
|
+
chunks.push(decoder.decode(value, { stream: true }))
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
reader.releaseLock()
|
|
45
|
+
}
|
|
46
|
+
return { text: chunks.join(""), truncated }
|
|
47
|
+
}
|