@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.
Files changed (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. package/src/workflows.ts +279 -0
@@ -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
+ }