@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
package/src/handoffs.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff coordinator: the typed state machine for a delegated boundary within a run.
|
|
3
|
+
*
|
|
4
|
+
* This sits beside {@link ApprovalManager} rather than inside it. An approval answers a yes/no gate;
|
|
5
|
+
* a handoff moves ownership of a paused node between roles (open, optionally assign an owner, then
|
|
6
|
+
* accept, decline, resolve, expire, or cancel). The coordinator composes an {@link ApprovalManager}
|
|
7
|
+
* when a handoff also needs a yes/no gate, so existing approval semantics are extended, never
|
|
8
|
+
* weakened.
|
|
9
|
+
*
|
|
10
|
+
* Every boundary is addressed by a content-free {@link DecisionCoordinate}. A decision that names a
|
|
11
|
+
* different run, node, capability, or request id fails `identity_mismatch`; one carrying a superseded
|
|
12
|
+
* child vector fails `stale_vector`; one arriving at or past expiry expires the boundary closed and
|
|
13
|
+
* fails `expired`; one whose transition is absent from the {@link nextHandoffState} table fails
|
|
14
|
+
* `illegal_transition`; and one that would let a non-owner act on an assigned boundary fails
|
|
15
|
+
* `authority_expanded`. Nothing here stores or returns a prompt, a free-text reason, tool data, or a
|
|
16
|
+
* payload - only structural coordinates and a state.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
coordinateIsFresh,
|
|
21
|
+
coordinatesMatch,
|
|
22
|
+
type DecisionCoordinate,
|
|
23
|
+
type HandoffLifecycleState,
|
|
24
|
+
nextHandoffState,
|
|
25
|
+
} from "@nifrajs/agent-protocol"
|
|
26
|
+
import type { ApprovalManager } from "./approvals.ts"
|
|
27
|
+
import { ChildVectorTracker } from "./orchestration/policy.ts"
|
|
28
|
+
|
|
29
|
+
const ID_TOKEN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
30
|
+
|
|
31
|
+
/** Stable, content-free reasons the coordinator refuses a handoff request or decision. */
|
|
32
|
+
export type HandoffRejection =
|
|
33
|
+
| "unknown_boundary"
|
|
34
|
+
| "identity_mismatch"
|
|
35
|
+
| "stale_vector"
|
|
36
|
+
| "expired"
|
|
37
|
+
| "illegal_transition"
|
|
38
|
+
| "authority_expanded"
|
|
39
|
+
| "duplicate"
|
|
40
|
+
| "invalid_handoff"
|
|
41
|
+
|
|
42
|
+
/** Thrown on any refused handoff operation. `code` is the machine-addressable reason. */
|
|
43
|
+
export class HandoffError extends Error {
|
|
44
|
+
readonly code: HandoffRejection
|
|
45
|
+
|
|
46
|
+
constructor(code: HandoffRejection) {
|
|
47
|
+
super(`handoff: ${code}`)
|
|
48
|
+
this.code = code
|
|
49
|
+
this.name = "HandoffError"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Content-free projection of one handoff boundary for a list/inspect view. */
|
|
54
|
+
export interface HandoffView {
|
|
55
|
+
readonly requestId: string
|
|
56
|
+
readonly runId: string
|
|
57
|
+
readonly nodeId: string
|
|
58
|
+
readonly capability: string
|
|
59
|
+
readonly vector: number
|
|
60
|
+
readonly expiresAt: number
|
|
61
|
+
readonly state: HandoffLifecycleState
|
|
62
|
+
/** Opaque origin role. */
|
|
63
|
+
readonly from: string
|
|
64
|
+
/** Opaque owner role once assigned. */
|
|
65
|
+
readonly to?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface OpenHandoffInput {
|
|
69
|
+
readonly runId: string
|
|
70
|
+
readonly nodeId: string
|
|
71
|
+
readonly capability: string
|
|
72
|
+
readonly requestId: string
|
|
73
|
+
/** Opaque origin role. */
|
|
74
|
+
readonly from: string
|
|
75
|
+
/** Milliseconds until the boundary expires closed. Falls back to the coordinator default. */
|
|
76
|
+
readonly expiresInMs?: number
|
|
77
|
+
/** When true, a paired approval is opened on the composed {@link ApprovalManager}, if present. */
|
|
78
|
+
readonly requireApproval?: boolean
|
|
79
|
+
readonly sessionId?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A decision against a live boundary. The coordinate must match the boundary exactly. */
|
|
83
|
+
export interface HandoffDecision {
|
|
84
|
+
readonly coordinate: DecisionCoordinate
|
|
85
|
+
/** Owner role to assign (for `assign`) or the actor claiming to own the boundary (accept/resolve). */
|
|
86
|
+
readonly by?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HandoffCoordinatorOptions {
|
|
90
|
+
readonly vectors?: ChildVectorTracker
|
|
91
|
+
readonly approvals?: ApprovalManager
|
|
92
|
+
readonly defaultTtlMs?: number
|
|
93
|
+
readonly maxPending?: number
|
|
94
|
+
/** Injectable clock (epoch ms). Defaults to {@link Date.now}. */
|
|
95
|
+
readonly now?: () => number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface HandoffRecord {
|
|
99
|
+
coordinate: DecisionCoordinate
|
|
100
|
+
state: HandoffLifecycleState
|
|
101
|
+
readonly from: string
|
|
102
|
+
to: string | undefined
|
|
103
|
+
readonly sessionId: string | undefined
|
|
104
|
+
readonly requireApproval: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const DEFAULT_TTL_MS = 5 * 60_000
|
|
108
|
+
const DEFAULT_MAX_PENDING = 64
|
|
109
|
+
|
|
110
|
+
export class HandoffCoordinator {
|
|
111
|
+
private readonly vectors: ChildVectorTracker
|
|
112
|
+
private readonly approvals: ApprovalManager | undefined
|
|
113
|
+
private readonly defaultTtlMs: number
|
|
114
|
+
private readonly maxPending: number
|
|
115
|
+
private readonly now: () => number
|
|
116
|
+
private readonly records = new Map<string, HandoffRecord>()
|
|
117
|
+
|
|
118
|
+
constructor(options: HandoffCoordinatorOptions = {}) {
|
|
119
|
+
this.vectors = options.vectors ?? new ChildVectorTracker()
|
|
120
|
+
this.approvals = options.approvals
|
|
121
|
+
this.defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS
|
|
122
|
+
this.maxPending = options.maxPending ?? DEFAULT_MAX_PENDING
|
|
123
|
+
this.now = options.now ?? Date.now
|
|
124
|
+
if (!Number.isSafeInteger(this.defaultTtlMs) || this.defaultTtlMs < 1)
|
|
125
|
+
throw new RangeError("handoff: defaultTtlMs must be positive")
|
|
126
|
+
if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1)
|
|
127
|
+
throw new RangeError("handoff: maxPending must be positive")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Open a pending handoff. Allocates the run's next monotonic child vector. */
|
|
131
|
+
open(input: OpenHandoffInput): HandoffView {
|
|
132
|
+
if (
|
|
133
|
+
!ID_TOKEN.test(input.runId) ||
|
|
134
|
+
!ID_TOKEN.test(input.nodeId) ||
|
|
135
|
+
!ID_TOKEN.test(input.requestId) ||
|
|
136
|
+
!input.capability ||
|
|
137
|
+
input.capability.length > 128 ||
|
|
138
|
+
!input.from ||
|
|
139
|
+
input.from.length > 128
|
|
140
|
+
)
|
|
141
|
+
throw new HandoffError("invalid_handoff")
|
|
142
|
+
if (this.records.has(input.requestId)) throw new HandoffError("duplicate")
|
|
143
|
+
if (this.activeCount() >= this.maxPending) throw new HandoffError("invalid_handoff")
|
|
144
|
+
|
|
145
|
+
const ttl = input.expiresInMs ?? this.defaultTtlMs
|
|
146
|
+
if (!Number.isSafeInteger(ttl) || ttl < 1) throw new HandoffError("invalid_handoff")
|
|
147
|
+
const coordinate: DecisionCoordinate = Object.freeze({
|
|
148
|
+
runId: input.runId,
|
|
149
|
+
nodeId: input.nodeId,
|
|
150
|
+
capability: input.capability,
|
|
151
|
+
requestId: input.requestId,
|
|
152
|
+
vector: this.vectors.open(input.runId),
|
|
153
|
+
expiresAt: this.now() + ttl,
|
|
154
|
+
})
|
|
155
|
+
const record: HandoffRecord = {
|
|
156
|
+
coordinate,
|
|
157
|
+
state: "pending",
|
|
158
|
+
from: input.from,
|
|
159
|
+
to: undefined,
|
|
160
|
+
sessionId: input.sessionId,
|
|
161
|
+
requireApproval: input.requireApproval === true,
|
|
162
|
+
}
|
|
163
|
+
this.records.set(input.requestId, record)
|
|
164
|
+
|
|
165
|
+
if (record.requireApproval && this.approvals !== undefined)
|
|
166
|
+
void this.approvals.offer({
|
|
167
|
+
id: input.requestId,
|
|
168
|
+
sessionId: input.sessionId ?? input.runId,
|
|
169
|
+
action: "handoff",
|
|
170
|
+
capability: input.capability,
|
|
171
|
+
})
|
|
172
|
+
return this.view(record)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Every boundary, or - with `activeOnly` - just those still awaiting a decision. */
|
|
176
|
+
list(activeOnly = false): readonly HandoffView[] {
|
|
177
|
+
const views: HandoffView[] = []
|
|
178
|
+
for (const record of this.records.values()) {
|
|
179
|
+
if (activeOnly && this.isTerminal(record.state)) continue
|
|
180
|
+
views.push(this.view(record))
|
|
181
|
+
}
|
|
182
|
+
return Object.freeze(views)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Inspect one boundary by request id, or `undefined` when unknown. */
|
|
186
|
+
inspect(requestId: string): HandoffView | undefined {
|
|
187
|
+
const record = this.records.get(requestId)
|
|
188
|
+
return record === undefined ? undefined : this.view(record)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
assign(decision: HandoffDecision): HandoffView {
|
|
192
|
+
if (decision.by === undefined || !decision.by || decision.by.length > 128)
|
|
193
|
+
throw new HandoffError("invalid_handoff")
|
|
194
|
+
return this.drive("assign", decision, decision.by)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
accept(decision: HandoffDecision): HandoffView {
|
|
198
|
+
return this.drive("accept", decision)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
decline(decision: HandoffDecision): HandoffView {
|
|
202
|
+
return this.drive("decline", decision)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Resolve a boundary, resuming exactly the one matching paused node. */
|
|
206
|
+
resolve(decision: HandoffDecision): HandoffView {
|
|
207
|
+
return this.drive("resolve", decision)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
expire(decision: HandoffDecision): HandoffView {
|
|
211
|
+
return this.drive("expire", decision)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cancel(decision: HandoffDecision): HandoffView {
|
|
215
|
+
return this.drive("cancel", decision)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private drive(
|
|
219
|
+
op: "assign" | "accept" | "decline" | "resolve" | "expire" | "cancel",
|
|
220
|
+
decision: HandoffDecision,
|
|
221
|
+
assignee?: string,
|
|
222
|
+
): HandoffView {
|
|
223
|
+
const record = this.records.get(decision.coordinate.requestId)
|
|
224
|
+
if (record === undefined) throw new HandoffError("unknown_boundary")
|
|
225
|
+
|
|
226
|
+
if (!coordinatesMatch(record.coordinate, decision.coordinate)) {
|
|
227
|
+
throw new HandoffError(
|
|
228
|
+
record.coordinate.vector !== decision.coordinate.vector
|
|
229
|
+
? "stale_vector"
|
|
230
|
+
: "identity_mismatch",
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Expiry is checked before the transition and, except for an explicit `expire`/`cancel`, is
|
|
235
|
+
// terminal: a decision that arrives at or past the deadline expires the boundary closed and
|
|
236
|
+
// resumes no work.
|
|
237
|
+
if (op !== "expire" && op !== "cancel" && !coordinateIsFresh(record.coordinate, this.now())) {
|
|
238
|
+
if (!this.isTerminal(record.state)) this.settle(record, "expired")
|
|
239
|
+
throw new HandoffError("expired")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// An assigned boundary may only be acted on by its owner. A different actor is an authority
|
|
243
|
+
// expansion, not a routine mismatch.
|
|
244
|
+
if (
|
|
245
|
+
record.to !== undefined &&
|
|
246
|
+
(op === "accept" || op === "decline" || op === "resolve") &&
|
|
247
|
+
decision.by !== record.to
|
|
248
|
+
)
|
|
249
|
+
throw new HandoffError("authority_expanded")
|
|
250
|
+
|
|
251
|
+
const next = nextHandoffState(record.state, op)
|
|
252
|
+
if (next === undefined) throw new HandoffError("illegal_transition")
|
|
253
|
+
|
|
254
|
+
if (op === "assign") record.to = assignee
|
|
255
|
+
record.state = next
|
|
256
|
+
if (this.isTerminal(next)) this.settleApproval(record, op)
|
|
257
|
+
return this.view(record)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private settle(record: HandoffRecord, state: HandoffLifecycleState): void {
|
|
261
|
+
record.state = state
|
|
262
|
+
this.settleApproval(record, "expire")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private settleApproval(record: HandoffRecord, op: string): void {
|
|
266
|
+
if (!record.requireApproval || this.approvals === undefined) return
|
|
267
|
+
this.approvals.resolve(record.coordinate.requestId, op === "accept" || op === "resolve")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private isTerminal(state: HandoffLifecycleState): boolean {
|
|
271
|
+
return (
|
|
272
|
+
state === "accepted" ||
|
|
273
|
+
state === "declined" ||
|
|
274
|
+
state === "resolved" ||
|
|
275
|
+
state === "expired" ||
|
|
276
|
+
state === "cancelled"
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private activeCount(): number {
|
|
281
|
+
let count = 0
|
|
282
|
+
for (const record of this.records.values()) if (!this.isTerminal(record.state)) count++
|
|
283
|
+
return count
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private view(record: HandoffRecord): HandoffView {
|
|
287
|
+
return Object.freeze({
|
|
288
|
+
requestId: record.coordinate.requestId,
|
|
289
|
+
runId: record.coordinate.runId,
|
|
290
|
+
nodeId: record.coordinate.nodeId,
|
|
291
|
+
capability: record.coordinate.capability,
|
|
292
|
+
vector: record.coordinate.vector,
|
|
293
|
+
expiresAt: record.coordinate.expiresAt,
|
|
294
|
+
state: record.state,
|
|
295
|
+
from: record.from,
|
|
296
|
+
...(record.to === undefined ? {} : { to: record.to }),
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
}
|
package/src/healing.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface RepairProposal<State = unknown> {
|
|
2
|
+
readonly id: string
|
|
3
|
+
readonly reason: string
|
|
4
|
+
readonly capabilities?: readonly string[]
|
|
5
|
+
readonly stage: () => State | PromiseLike<State>
|
|
6
|
+
readonly verify: (state: State) => boolean | PromiseLike<boolean>
|
|
7
|
+
/** Commit the verified staged state. If it fails, rollback still runs. */
|
|
8
|
+
readonly activate?: (state: State) => void | PromiseLike<void>
|
|
9
|
+
/** Optional post-activation health check. A false result triggers rollback. */
|
|
10
|
+
readonly monitor?: (state: State) => boolean | PromiseLike<boolean>
|
|
11
|
+
readonly rollback: (state?: State) => void | PromiseLike<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HealingOptions {
|
|
15
|
+
readonly trustedCapabilities?: readonly string[]
|
|
16
|
+
readonly maxAttempts?: number
|
|
17
|
+
readonly onEvent?: (event: HealingEvent) => void | PromiseLike<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type HealingEvent =
|
|
21
|
+
| { readonly type: "repair.staged"; readonly id: string; readonly attempt: number }
|
|
22
|
+
| { readonly type: "repair.verified"; readonly id: string; readonly attempt: number }
|
|
23
|
+
| { readonly type: "repair.activated"; readonly id: string; readonly attempt: number }
|
|
24
|
+
| { readonly type: "repair.unhealthy"; readonly id: string; readonly attempt: number }
|
|
25
|
+
| {
|
|
26
|
+
readonly type: "repair.rolled_back"
|
|
27
|
+
readonly id: string
|
|
28
|
+
readonly attempt: number
|
|
29
|
+
readonly error: string
|
|
30
|
+
}
|
|
31
|
+
| { readonly type: "repair.denied"; readonly id: string; readonly capability: string }
|
|
32
|
+
|
|
33
|
+
export interface HealingResult {
|
|
34
|
+
readonly ok: boolean
|
|
35
|
+
readonly attempts: number
|
|
36
|
+
readonly error?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Staged repair loop for extensions, tools, and workflows. It never activates an unverified change. */
|
|
40
|
+
export class SelfHealingController {
|
|
41
|
+
private readonly options: Required<Pick<HealingOptions, "maxAttempts">> & HealingOptions
|
|
42
|
+
|
|
43
|
+
constructor(options: HealingOptions = {}) {
|
|
44
|
+
this.options = { ...options, maxAttempts: options.maxAttempts ?? 2 }
|
|
45
|
+
if (
|
|
46
|
+
!Number.isSafeInteger(this.options.maxAttempts) ||
|
|
47
|
+
this.options.maxAttempts < 1 ||
|
|
48
|
+
this.options.maxAttempts > 8
|
|
49
|
+
)
|
|
50
|
+
throw new RangeError("healing: maxAttempts must be between 1 and 8")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async repair<State>(proposal: RepairProposal<State>): Promise<HealingResult> {
|
|
54
|
+
if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(proposal.id))
|
|
55
|
+
return { ok: false, attempts: 0, error: "invalid repair id" }
|
|
56
|
+
const trusted = new Set(this.options.trustedCapabilities ?? [])
|
|
57
|
+
const denied = (proposal.capabilities ?? []).find((capability) => !trusted.has(capability))
|
|
58
|
+
if (denied !== undefined) {
|
|
59
|
+
await this.emit({ type: "repair.denied", id: proposal.id, capability: denied })
|
|
60
|
+
return { ok: false, attempts: 0, error: `untrusted capability: ${denied}` }
|
|
61
|
+
}
|
|
62
|
+
let lastError = "repair failed"
|
|
63
|
+
for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) {
|
|
64
|
+
let state: State | undefined
|
|
65
|
+
try {
|
|
66
|
+
state = await proposal.stage()
|
|
67
|
+
await this.emit({ type: "repair.staged", id: proposal.id, attempt })
|
|
68
|
+
if (!(await proposal.verify(state))) throw new Error("repair verification failed")
|
|
69
|
+
await this.emit({ type: "repair.verified", id: proposal.id, attempt })
|
|
70
|
+
await proposal.activate?.(state)
|
|
71
|
+
await this.emit({ type: "repair.activated", id: proposal.id, attempt })
|
|
72
|
+
if (proposal.monitor !== undefined && !(await proposal.monitor(state))) {
|
|
73
|
+
await this.emit({ type: "repair.unhealthy", id: proposal.id, attempt })
|
|
74
|
+
throw new Error("repair monitor reported unhealthy")
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, attempts: attempt }
|
|
77
|
+
} catch (error) {
|
|
78
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
79
|
+
try {
|
|
80
|
+
await proposal.rollback(state)
|
|
81
|
+
} catch (rollbackError) {
|
|
82
|
+
lastError = `${lastError}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
|
|
83
|
+
}
|
|
84
|
+
await this.emit({ type: "repair.rolled_back", id: proposal.id, attempt, error: lastError })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, attempts: this.options.maxAttempts, error: lastError }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async emit(event: HealingEvent): Promise<void> {
|
|
91
|
+
await this.options.onEvent?.(event)
|
|
92
|
+
}
|
|
93
|
+
}
|