@huynle/hub 0.6.0 → 0.7.1
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/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The cluster view (design §9, decision vkxkqiuf §5): how every pod sees the connections other pods hold.
|
|
2
|
+
// Each pod's Registry and NodeRegistry are the single writers of its own connections and export a slice per tenant
|
|
3
|
+
// (registry: connections and what they report, status boards, pending permissions and forms, presence, running
|
|
4
|
+
// turns; nodes: the tenant's nodes). This pod publishes its slices
|
|
5
|
+
// - to the Catalog: hash of the user, session VIEW_SESSION, one report per pod (connID = podID) with a TTL;
|
|
6
|
+
// - as deltas on the Bus (VIEW_TOPIC: {pod, user, slice | null}), shortly after a change and again every
|
|
7
|
+
// heartbeat (which also refreshes the Catalog TTL);
|
|
8
|
+
// and mirrors the other pods' slices into its Registry and NodeRegistry (setRemote), so their synchronous reads
|
|
9
|
+
// cover the whole cluster. The mirror is rebuilt from the Catalog at start and on resync() (e.g. after a Bus
|
|
10
|
+
// reconnect lost deltas). A pod that stops removes its slices; a pod that dies stops refreshing them: the other pods
|
|
11
|
+
// drop a slice not refreshed within the TTL, and the Catalog filters it out on read.
|
|
12
|
+
import { realTimers } from "../backend/memory-bus"
|
|
13
|
+
import type { Bus, Catalog, Clock, Timers, Unsubscribe } from "../backend/types"
|
|
14
|
+
import type { NodeRegistry, NodeSlice } from "../nodes"
|
|
15
|
+
import type { Registry, RegistrySlice } from "../registry"
|
|
16
|
+
|
|
17
|
+
export const VIEW_TOPIC = "cluster.view"
|
|
18
|
+
/** The Catalog session id the slices are reported under (never a real session id: those start with ses). */
|
|
19
|
+
export const VIEW_SESSION = "_view"
|
|
20
|
+
export const VIEW_HEARTBEAT_MS = 15_000
|
|
21
|
+
export const VIEW_TTL_MS = 45_000
|
|
22
|
+
/** Changes within this window go out together. */
|
|
23
|
+
export const VIEW_PUBLISH_DELAY_MS = 50
|
|
24
|
+
|
|
25
|
+
/** One pod's state of one tenant. */
|
|
26
|
+
export interface ViewSlice {
|
|
27
|
+
readonly registry?: RegistrySlice
|
|
28
|
+
readonly nodes?: NodeSlice
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ViewMessage {
|
|
32
|
+
readonly pod: string
|
|
33
|
+
readonly user: string
|
|
34
|
+
readonly slice: ViewSlice | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Mirrored<T extends Registry | NodeRegistry> = Pick<T, "onLocalChange" | "clusterTenants" | "clusterSlice" | "setRemote">
|
|
38
|
+
|
|
39
|
+
export interface ClusterViewOptions {
|
|
40
|
+
readonly catalog: Catalog
|
|
41
|
+
readonly bus: Bus
|
|
42
|
+
/** This pod (HUB_POD_ID). */
|
|
43
|
+
readonly podID: string
|
|
44
|
+
readonly registry: Mirrored<Registry>
|
|
45
|
+
readonly nodes: Mirrored<NodeRegistry>
|
|
46
|
+
/** The users whose Catalog entries a rebuild reads. */
|
|
47
|
+
readonly users: () => readonly string[] | Promise<readonly string[]>
|
|
48
|
+
readonly now?: Clock
|
|
49
|
+
readonly timers?: Timers
|
|
50
|
+
readonly heartbeatMs?: number
|
|
51
|
+
readonly ttlMs?: number
|
|
52
|
+
readonly publishDelayMs?: number
|
|
53
|
+
readonly onError?: (error: unknown) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Tenants that are not users (a machine without an owner) are never published. */
|
|
57
|
+
const publishable = (tenant: string | undefined): tenant is string => tenant !== undefined && tenant !== "" && !tenant.startsWith("?")
|
|
58
|
+
|
|
59
|
+
export class ClusterView {
|
|
60
|
+
readonly podID: string
|
|
61
|
+
private readonly options: ClusterViewOptions
|
|
62
|
+
private readonly now: Clock
|
|
63
|
+
private readonly timers: Timers
|
|
64
|
+
private readonly heartbeatMs: number
|
|
65
|
+
private readonly ttlMs: number
|
|
66
|
+
private readonly publishDelayMs: number
|
|
67
|
+
private readonly onError: (error: unknown) => void
|
|
68
|
+
/** Tenants whose slice changed since the last flush; `all`: every tenant. */
|
|
69
|
+
private dirty = new Set<string>()
|
|
70
|
+
private dirtyAll = false
|
|
71
|
+
private flushTimer: unknown
|
|
72
|
+
private heartbeatTimer: unknown
|
|
73
|
+
/** What this pod published last per user (JSON), to skip unchanged slices between heartbeats. */
|
|
74
|
+
private readonly sent = new Map<string, string>()
|
|
75
|
+
/** Other pods' slices applied here: pod → user → when last received (this pod's clock). */
|
|
76
|
+
private readonly received = new Map<string, Map<string, number>>()
|
|
77
|
+
/** Serialises flushes, rebuilds and stop. */
|
|
78
|
+
private queue: Promise<void> = Promise.resolve()
|
|
79
|
+
private readonly offs: Array<() => void> = []
|
|
80
|
+
private unsubscribe?: Unsubscribe
|
|
81
|
+
private running = false
|
|
82
|
+
|
|
83
|
+
constructor(options: ClusterViewOptions) {
|
|
84
|
+
this.options = options
|
|
85
|
+
this.podID = options.podID
|
|
86
|
+
this.now = options.now ?? Date.now
|
|
87
|
+
this.timers = options.timers ?? realTimers
|
|
88
|
+
this.heartbeatMs = options.heartbeatMs ?? VIEW_HEARTBEAT_MS
|
|
89
|
+
this.ttlMs = options.ttlMs ?? VIEW_TTL_MS
|
|
90
|
+
this.publishDelayMs = options.publishDelayMs ?? VIEW_PUBLISH_DELAY_MS
|
|
91
|
+
this.onError = options.onError ?? ((error) => console.error(`[hub] cluster view: ${error instanceof Error ? error.message : String(error)}`))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Listens for deltas, rebuilds the mirror from the Catalog, publishes this pod's slices and starts the heartbeat. */
|
|
95
|
+
async start(): Promise<void> {
|
|
96
|
+
if (this.running) return
|
|
97
|
+
this.running = true
|
|
98
|
+
const mark = (tenant: string | undefined) => this.mark(tenant)
|
|
99
|
+
this.offs.push(this.options.registry.onLocalChange(mark), this.options.nodes.onLocalChange(mark))
|
|
100
|
+
this.unsubscribe = await this.options.bus.subscribe(VIEW_TOPIC, (message) => this.receive(message as ViewMessage))
|
|
101
|
+
await this.resync()
|
|
102
|
+
this.dirtyAll = true
|
|
103
|
+
await this.flush()
|
|
104
|
+
this.arm()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stops publishing and listening, and removes this pod's slices from the Catalog and the other pods. */
|
|
108
|
+
async stop(): Promise<void> {
|
|
109
|
+
if (!this.running) return
|
|
110
|
+
this.running = false
|
|
111
|
+
for (const off of this.offs.splice(0)) off()
|
|
112
|
+
for (const timer of [this.flushTimer, this.heartbeatTimer]) if (timer !== undefined) this.timers.clear(timer)
|
|
113
|
+
this.flushTimer = this.heartbeatTimer = undefined
|
|
114
|
+
const unsubscribe = this.unsubscribe
|
|
115
|
+
this.unsubscribe = undefined
|
|
116
|
+
await this.enqueue(async () => {
|
|
117
|
+
await unsubscribe?.()
|
|
118
|
+
const users = [...this.sent.keys()]
|
|
119
|
+
this.sent.clear()
|
|
120
|
+
await Promise.allSettled(users.map((user) => this.withdraw(user)))
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** How many users this pod currently publishes slices for. */
|
|
125
|
+
published(): number {
|
|
126
|
+
return this.sent.size
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Reads every other pod's slices from the Catalog and replaces the mirror with them. */
|
|
130
|
+
resync(): Promise<void> {
|
|
131
|
+
return this.enqueue(async () => {
|
|
132
|
+
const users = await this.options.users()
|
|
133
|
+
const seen = new Set<string>()
|
|
134
|
+
for (const user of users) {
|
|
135
|
+
for (const entry of await this.options.catalog.entries(user)) {
|
|
136
|
+
if (entry.sessionID !== VIEW_SESSION || entry.connID === this.podID) continue
|
|
137
|
+
seen.add(`${entry.connID}\u0000${user}`)
|
|
138
|
+
this.apply(entry.connID, user, entry.value as ViewSlice)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const [pod, slices] of this.received) for (const user of slices.keys()) if (!seen.has(`${pod}\u0000${user}`)) this.apply(pod, user, null)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A local change of `tenant` (undefined: any): published after the publish delay. */
|
|
146
|
+
private mark(tenant: string | undefined): void {
|
|
147
|
+
if (!this.running) return
|
|
148
|
+
if (tenant === undefined) this.dirtyAll = true
|
|
149
|
+
else if (publishable(tenant)) this.dirty.add(tenant)
|
|
150
|
+
else return
|
|
151
|
+
if (this.flushTimer !== undefined) return
|
|
152
|
+
this.flushTimer = this.timers.set(() => {
|
|
153
|
+
this.flushTimer = undefined
|
|
154
|
+
void this.flush()
|
|
155
|
+
}, this.publishDelayMs)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Publishes the slices of the dirty tenants (all, after a heartbeat); `force` republishes unchanged ones too. */
|
|
159
|
+
private flush(force = false): Promise<void> {
|
|
160
|
+
return this.enqueue(async () => {
|
|
161
|
+
if (!this.running) return
|
|
162
|
+
const { registry, nodes } = this.options
|
|
163
|
+
const tenants = this.dirtyAll
|
|
164
|
+
? new Set([...registry.clusterTenants(), ...nodes.clusterTenants(), ...this.sent.keys()])
|
|
165
|
+
: new Set(this.dirty)
|
|
166
|
+
this.dirty = new Set()
|
|
167
|
+
this.dirtyAll = false
|
|
168
|
+
for (const user of tenants) {
|
|
169
|
+
if (!publishable(user)) continue
|
|
170
|
+
const r = registry.clusterSlice(user)
|
|
171
|
+
const n = nodes.clusterSlice(user)
|
|
172
|
+
try {
|
|
173
|
+
if (!r && !n) {
|
|
174
|
+
if (this.sent.delete(user)) await this.withdraw(user)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const slice: ViewSlice = { ...(r ? { registry: r } : {}), ...(n ? { nodes: n } : {}) }
|
|
178
|
+
const json = JSON.stringify(slice)
|
|
179
|
+
if (!force && this.sent.get(user) === json) continue
|
|
180
|
+
this.sent.set(user, json)
|
|
181
|
+
await this.options.catalog.set(user, VIEW_SESSION, this.podID, slice as Record<string, unknown>, { ttlMs: this.ttlMs })
|
|
182
|
+
await this.options.bus.publish(VIEW_TOPIC, { pod: this.podID, user, slice } satisfies ViewMessage)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// The next heartbeat publishes it again.
|
|
185
|
+
this.sent.delete(user)
|
|
186
|
+
this.onError(error)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async withdraw(user: string): Promise<void> {
|
|
193
|
+
await this.options.catalog.delete(user, VIEW_SESSION, this.podID)
|
|
194
|
+
await this.options.bus.publish(VIEW_TOPIC, { pod: this.podID, user, slice: null } satisfies ViewMessage)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private receive(message: ViewMessage): void {
|
|
198
|
+
if (!this.running || typeof message?.pod !== "string" || typeof message.user !== "string" || message.pod === this.podID) return
|
|
199
|
+
this.apply(message.pod, message.user, message.slice ?? null)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Applies (or, null, removes) another pod's slice of a user. */
|
|
203
|
+
private apply(pod: string, user: string, slice: ViewSlice | null): void {
|
|
204
|
+
let users = this.received.get(pod)
|
|
205
|
+
if (slice) {
|
|
206
|
+
if (!users) this.received.set(pod, (users = new Map()))
|
|
207
|
+
users.set(user, this.now())
|
|
208
|
+
} else if (users?.delete(user) && !users.size) this.received.delete(pod)
|
|
209
|
+
this.options.nodes.setRemote(pod, user, slice?.nodes)
|
|
210
|
+
this.options.registry.setRemote(pod, user, slice?.registry)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Every heartbeat: republish this pod's slices (refreshing their TTL) and drop the ones other pods stopped refreshing. */
|
|
214
|
+
private arm(): void {
|
|
215
|
+
this.heartbeatTimer = this.timers.set(() => {
|
|
216
|
+
this.heartbeatTimer = undefined
|
|
217
|
+
if (!this.running) return
|
|
218
|
+
const t = this.now()
|
|
219
|
+
for (const [pod, users] of [...this.received]) for (const [user, at] of [...users]) if (t - at > this.ttlMs) this.apply(pod, user, null)
|
|
220
|
+
this.dirtyAll = true
|
|
221
|
+
void this.flush(true).finally(() => {
|
|
222
|
+
if (this.running) this.arm()
|
|
223
|
+
})
|
|
224
|
+
}, this.heartbeatMs)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private enqueue(task: () => Promise<void>): Promise<void> {
|
|
228
|
+
const run = this.queue.then(task, task)
|
|
229
|
+
this.queue = run.catch((error) => this.onError(error))
|
|
230
|
+
return run
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/central/config.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// optional hub config file (~/.config/hub/hub.json or $HUB_CONFIG) over built-in defaults. The file's speech
|
|
3
3
|
// settings sit between the phone's saved settings and the env (see speech/settings.ts).
|
|
4
4
|
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
5
|
-
import { homedir } from "node:os"
|
|
5
|
+
import { homedir, hostname } from "node:os"
|
|
6
6
|
import path from "node:path"
|
|
7
7
|
import { hubEnv } from "../env"
|
|
8
8
|
import { isLoopbackHost } from "../loopback"
|
|
9
|
+
import { formatPattern, parsePattern } from "./agent/policy"
|
|
9
10
|
import { APP_DIR, configBase, legacyPaths, migrateFirst, stateBase } from "../migrate"
|
|
10
11
|
import { updateSpeech, type StoredSpeech } from "./speech/settings"
|
|
11
12
|
|
|
@@ -59,6 +60,57 @@ export interface CentralConfig {
|
|
|
59
60
|
/** Speech settings from hub.json (below the phone's saved settings, above the env). */
|
|
60
61
|
readonly speech?: StoredSpeech
|
|
61
62
|
readonly auth: AuthConfig
|
|
63
|
+
/** hub.json `agents.never`: permission patterns no agent may ever approve, for every user (design §5, §7). */
|
|
64
|
+
readonly agentsNever: readonly string[]
|
|
65
|
+
/** hub.json `agents.launches`: per-user launch caps, only those set (placement fills in its defaults). */
|
|
66
|
+
readonly launchCaps?: HubLaunches
|
|
67
|
+
/** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
|
|
68
|
+
readonly webhooksAllowPrivate?: true
|
|
69
|
+
/** Cluster mode (design §9): set only when HUB_DATABASE_URL and HUB_VALKEY_URL (or hub.json cluster) are. */
|
|
70
|
+
readonly cluster?: ClusterConfig
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cluster mode: shared state in Postgres, cross-pod messages and short-lived data in Valkey. The URLs hold
|
|
75
|
+
* credentials: they are non-enumerable (never serialized or printed with the config) and never put in messages.
|
|
76
|
+
*/
|
|
77
|
+
export interface ClusterConfig {
|
|
78
|
+
/** This pod's id (HUB_POD_ID, default the hostname): RPC topics and leases name it. */
|
|
79
|
+
readonly podID: string
|
|
80
|
+
/** postgres:// or postgresql:// (non-enumerable). */
|
|
81
|
+
readonly databaseURL: string
|
|
82
|
+
/** redis://, rediss://, valkey:// or valkeys:// (non-enumerable). */
|
|
83
|
+
readonly valkeyURL: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** hub.json `cluster` (env HUB_DATABASE_URL / HUB_VALKEY_URL win). */
|
|
87
|
+
export interface HubCluster {
|
|
88
|
+
readonly database?: string
|
|
89
|
+
readonly valkey?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** hub.json `agents.launches`: per-user launch caps (design §6.2, §7). */
|
|
93
|
+
export interface HubLaunches {
|
|
94
|
+
/** Launches waiting to be placed. */
|
|
95
|
+
readonly maxQueued?: number
|
|
96
|
+
/** Launches placed and not ended (placing, provisioning, running). */
|
|
97
|
+
readonly maxActive?: number
|
|
98
|
+
/** New launches per clock hour. */
|
|
99
|
+
readonly perHour?: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** hub.json `agents`, validated. */
|
|
103
|
+
export interface HubAgents {
|
|
104
|
+
/** Normalised `action[:glob]` patterns (see agent/policy.ts), duplicates dropped. */
|
|
105
|
+
readonly never?: readonly string[]
|
|
106
|
+
readonly launches?: HubLaunches
|
|
107
|
+
readonly webhooks?: HubWebhooks
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** hub.json `agents.webhooks` (decision znbg8xa5 #8). */
|
|
111
|
+
export interface HubWebhooks {
|
|
112
|
+
/** Lets webhook URLs use http and private or local targets (self-hosted receivers); off by default (SSRF). */
|
|
113
|
+
readonly allowPrivate?: boolean
|
|
62
114
|
}
|
|
63
115
|
|
|
64
116
|
/** hub.json, validated. */
|
|
@@ -69,11 +121,18 @@ export interface HubFile {
|
|
|
69
121
|
readonly stateDir?: string
|
|
70
122
|
readonly speech?: StoredSpeech
|
|
71
123
|
readonly auth?: HubAuth
|
|
124
|
+
readonly agents?: HubAgents
|
|
125
|
+
readonly cluster?: HubCluster
|
|
72
126
|
}
|
|
73
127
|
|
|
74
128
|
type Env = Readonly<Record<string, string | undefined>>
|
|
75
129
|
|
|
76
|
-
const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth"])
|
|
130
|
+
const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster"])
|
|
131
|
+
const CLUSTER_KEYS = ["database", "valkey"] as const
|
|
132
|
+
const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
|
|
133
|
+
const WEBHOOK_KEYS = new Set(["allowPrivate"])
|
|
134
|
+
const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour"] as const
|
|
135
|
+
const LAUNCH_CAP_MAX = 100_000
|
|
77
136
|
const AUTH_KEYS = new Set(["mode", "issuer", "clientId", "clientSecret", "allowed", "admins"])
|
|
78
137
|
const EMAIL = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/
|
|
79
138
|
const DOMAIN = /^@[^\s@,]+\.[^\s@,]+$/
|
|
@@ -161,6 +220,8 @@ export function readHubFile(file: string, home: string = homedir()): { file?: Hu
|
|
|
161
220
|
hub.stateDir = expandHome((raw.stateDir as string).trim(), home)
|
|
162
221
|
}
|
|
163
222
|
if (raw.auth !== undefined) hub.auth = readHubAuth(raw.auth, file, warnings, fail)
|
|
223
|
+
if (raw.agents !== undefined) hub.agents = readHubAgents(raw.agents, file, warnings, fail)
|
|
224
|
+
if (raw.cluster !== undefined) hub.cluster = readHubCluster(raw.cluster, file, warnings, fail)
|
|
164
225
|
if (raw.speech !== undefined) {
|
|
165
226
|
if (!isObj(raw.speech)) fail("speech must be an object with stt and/or tts")
|
|
166
227
|
const speech = { ...(raw.speech as Record<string, unknown>) }
|
|
@@ -206,6 +267,86 @@ function readHubAuth(raw: unknown, file: string, warnings: string[], fail: (mess
|
|
|
206
267
|
return auth
|
|
207
268
|
}
|
|
208
269
|
|
|
270
|
+
/** hub.json `cluster`. Values are never echoed; URLs with a password are dropped from a file others can read. */
|
|
271
|
+
function readHubCluster(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubCluster {
|
|
272
|
+
if (!isObj(raw)) fail("cluster must be an object with database and valkey URLs")
|
|
273
|
+
const input = raw as Record<string, unknown>
|
|
274
|
+
for (const key of Object.keys(input)) {
|
|
275
|
+
if (!(CLUSTER_KEYS as readonly string[]).includes(key)) warnings.push(`${file}: unknown key "cluster.${key}" ignored`)
|
|
276
|
+
}
|
|
277
|
+
const shared = (statSync(file).mode & 0o044) !== 0
|
|
278
|
+
const cluster: { -readonly [K in keyof HubCluster]: HubCluster[K] } = {}
|
|
279
|
+
for (const key of CLUSTER_KEYS) {
|
|
280
|
+
const value = input[key]
|
|
281
|
+
if (value === undefined) continue
|
|
282
|
+
if (typeof value !== "string" || !value.trim()) fail(`cluster.${key} must be a non-empty URL string`)
|
|
283
|
+
const url = (value as string).trim()
|
|
284
|
+
if (shared && hasPassword(url)) {
|
|
285
|
+
warnings.push(`${file} is readable by other users, so its cluster.${key} is ignored (it holds a password). Fix it with: chmod 600 ${file}`)
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
cluster[key] = url
|
|
289
|
+
}
|
|
290
|
+
return cluster
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function hasPassword(url: string): boolean {
|
|
294
|
+
try {
|
|
295
|
+
return new URL(url).password !== ""
|
|
296
|
+
} catch {
|
|
297
|
+
// Unparseable: assume the worst; centralConfig rejects it anyway.
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubAgents {
|
|
303
|
+
if (!isObj(raw)) fail("agents must be an object")
|
|
304
|
+
const input = raw as Record<string, unknown>
|
|
305
|
+
for (const key of Object.keys(input)) if (!AGENTS_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.${key}" ignored`)
|
|
306
|
+
const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
|
|
307
|
+
const never = input.never === undefined ? undefined : readNever(input.never, fail)
|
|
308
|
+
const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
|
|
309
|
+
return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}) }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubWebhooks {
|
|
313
|
+
if (!isObj(raw)) fail("agents.webhooks must be an object")
|
|
314
|
+
const input = raw as Record<string, unknown>
|
|
315
|
+
for (const key of Object.keys(input)) if (!WEBHOOK_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.webhooks.${key}" ignored`)
|
|
316
|
+
if (input.allowPrivate !== undefined && typeof input.allowPrivate !== "boolean") fail("agents.webhooks.allowPrivate must be true or false")
|
|
317
|
+
return input.allowPrivate === undefined ? {} : { allowPrivate: input.allowPrivate as boolean }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function readHubLaunches(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubLaunches {
|
|
321
|
+
if (!isObj(raw)) fail("agents.launches must be an object")
|
|
322
|
+
const input = raw as Record<string, unknown>
|
|
323
|
+
const caps: Record<string, number> = {}
|
|
324
|
+
for (const key of Object.keys(input)) {
|
|
325
|
+
if (!(LAUNCH_CAP_KEYS as readonly string[]).includes(key)) {
|
|
326
|
+
warnings.push(`${file}: unknown key "agents.launches.${key}" ignored`)
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const value = input[key]
|
|
330
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > LAUNCH_CAP_MAX) {
|
|
331
|
+
fail(`agents.launches.${key} must be a whole number from 1 to ${LAUNCH_CAP_MAX}`)
|
|
332
|
+
}
|
|
333
|
+
caps[key] = value as number
|
|
334
|
+
}
|
|
335
|
+
return caps
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function readNever(raw: unknown, fail: (message: string) => never): string[] {
|
|
339
|
+
if (!Array.isArray(raw)) fail('agents.never must be a list of patterns ("action" or "action:glob")')
|
|
340
|
+
const never: string[] = []
|
|
341
|
+
for (const item of raw as unknown[]) {
|
|
342
|
+
const pattern = typeof item === "string" ? parsePattern(item) : undefined
|
|
343
|
+
if (!pattern) fail(`agents.never: ${JSON.stringify(item)} is not a pattern ("action" or "action:glob")`)
|
|
344
|
+
const text = formatPattern(pattern!)
|
|
345
|
+
if (!never.includes(text)) never.push(text)
|
|
346
|
+
}
|
|
347
|
+
return never
|
|
348
|
+
}
|
|
349
|
+
|
|
209
350
|
/** Validates the OIDC issuer: https, or plain http on loopback when the dev flag is set. No trailing slash. */
|
|
210
351
|
function issuerURL(raw: string, insecure: boolean): string {
|
|
211
352
|
let url: URL
|
|
@@ -252,6 +393,53 @@ function authConfig(env: Env, file: HubAuth = {}): AuthConfig {
|
|
|
252
393
|
return auth as unknown as OidcConfig
|
|
253
394
|
}
|
|
254
395
|
|
|
396
|
+
const POD_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
397
|
+
const DATABASE_KEY = "HUB_DATABASE_URL (cluster.database)"
|
|
398
|
+
const VALKEY_KEY = "HUB_VALKEY_URL (cluster.valkey)"
|
|
399
|
+
|
|
400
|
+
/** The URL when its scheme is one of `protocols`; errors name the key, never the value (it holds credentials). */
|
|
401
|
+
function serviceURL(raw: string, name: string, protocols: readonly string[]): string {
|
|
402
|
+
let protocol: string
|
|
403
|
+
try {
|
|
404
|
+
protocol = new URL(raw).protocol
|
|
405
|
+
} catch {
|
|
406
|
+
throw new Error(`${name} is not a valid URL`)
|
|
407
|
+
}
|
|
408
|
+
if (!protocols.includes(protocol)) throw new Error(`${name} must be a ${protocols.map((p) => `${p}//`).join(" or ")} URL`)
|
|
409
|
+
return raw
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Cluster settings from env over hub.json, or undefined (single process). Both URLs or neither; cluster mode
|
|
414
|
+
* requires HUB_TOKEN_KEYS (keys every pod shares) and OIDC sign-in (pairing codes and the local owner are
|
|
415
|
+
* single-process). Throws without echoing the URLs.
|
|
416
|
+
*/
|
|
417
|
+
function clusterConfig(env: Env, auth: AuthConfig, file: HubCluster = {}): ClusterConfig | undefined {
|
|
418
|
+
const database = env.HUB_DATABASE_URL?.trim() || file.database
|
|
419
|
+
const valkey = env.HUB_VALKEY_URL?.trim() || file.valkey
|
|
420
|
+
if (!database && !valkey) return undefined
|
|
421
|
+
if (!database || !valkey) {
|
|
422
|
+
throw new Error(`${DATABASE_KEY} and ${VALKEY_KEY} must be set together for cluster mode; only ${database ? DATABASE_KEY : VALKEY_KEY} is set`)
|
|
423
|
+
}
|
|
424
|
+
const databaseURL = serviceURL(database, DATABASE_KEY, ["postgres:", "postgresql:"])
|
|
425
|
+
const valkeyURL = serviceURL(valkey, VALKEY_KEY, ["redis:", "rediss:", "valkey:", "valkeys:"])
|
|
426
|
+
if (!env.HUB_TOKEN_KEYS?.trim()) {
|
|
427
|
+
throw new Error("Cluster mode requires HUB_TOKEN_KEYS: token signing keys every pod shares (the token-keys.json shape)")
|
|
428
|
+
}
|
|
429
|
+
if (auth.mode !== "oidc") {
|
|
430
|
+
throw new Error("Cluster mode requires HUB_AUTH=oidc: pairing mode (pairing codes, the local owner) works in a single process only")
|
|
431
|
+
}
|
|
432
|
+
const podID = env.HUB_POD_ID?.trim() || hostname()
|
|
433
|
+
if (!POD_ID.test(podID)) {
|
|
434
|
+
throw new Error(`HUB_POD_ID must be 1-128 letters, digits, '.', '_' or '-' (starting with a letter or digit), got ${JSON.stringify(podID)}`)
|
|
435
|
+
}
|
|
436
|
+
const cluster = { podID }
|
|
437
|
+
// Getters: printed as nothing, skipped by JSON.stringify.
|
|
438
|
+
Object.defineProperty(cluster, "databaseURL", { get: () => databaseURL, enumerable: false })
|
|
439
|
+
Object.defineProperty(cluster, "valkeyURL", { get: () => valkeyURL, enumerable: false })
|
|
440
|
+
return cluster as ClusterConfig
|
|
441
|
+
}
|
|
442
|
+
|
|
255
443
|
export function centralConfig(rawEnv: Env = process.env, home: string = homedir(), file: HubFile = {}): CentralConfig {
|
|
256
444
|
const env = hubEnv(rawEnv)
|
|
257
445
|
const host = env.HUB_HOST || file.host || "127.0.0.1"
|
|
@@ -262,6 +450,8 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
262
450
|
if (!/^https?:\/\/[^\s/]+$/.test(publicURL)) {
|
|
263
451
|
throw new Error(`HUB_PUBLIC_URL must be an http(s) origin without a path, got ${JSON.stringify(publicURL)}`)
|
|
264
452
|
}
|
|
453
|
+
const auth = authConfig(env, file.auth)
|
|
454
|
+
const cluster = clusterConfig(env, auth, file.cluster)
|
|
265
455
|
return {
|
|
266
456
|
host,
|
|
267
457
|
port,
|
|
@@ -277,7 +467,11 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
277
467
|
// Push services want a contact: an https URL or mailto. The public URL works for hosted hubs.
|
|
278
468
|
vapidSubject: env.HUB_VAPID_SUBJECT || (publicURL.startsWith("https://") ? publicURL : "mailto:hub@localhost"),
|
|
279
469
|
...(file.speech ? { speech: file.speech } : {}),
|
|
280
|
-
auth
|
|
470
|
+
auth,
|
|
471
|
+
agentsNever: [...(file.agents?.never ?? [])],
|
|
472
|
+
...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
|
|
473
|
+
...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
|
|
474
|
+
...(cluster ? { cluster } : {}),
|
|
281
475
|
}
|
|
282
476
|
}
|
|
283
477
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// "All done": one notification when a root session and its whole tree settle, instead of one per root turn.
|
|
2
|
+
//
|
|
3
|
+
// In OpenCode v2 a root session runs many short turns: each background subagent that completes injects a synthetic
|
|
4
|
+
// message into its parent, which starts a new turn, and Voice mode and steering do the same. A notification per
|
|
5
|
+
// `session.execution.succeeded` of the root therefore fired while other subagents and jobs were still running.
|
|
6
|
+
// A run is armed when anything in the tree starts; once the root's last turn succeeded and the tree is settled
|
|
7
|
+
// (StatusBoard.settled: nothing runs, waits for an answer or has a running shell; no hub turn) for DONE_SETTLE_MS
|
|
8
|
+
// without anything starting again, it fires once and disarms.
|
|
9
|
+
import type { Timers } from "./registry"
|
|
10
|
+
|
|
11
|
+
/** How long a tree must stay settled before "all done" (a continuation turn starts well within it). */
|
|
12
|
+
export const DONE_SETTLE_MS = 15_000
|
|
13
|
+
|
|
14
|
+
/** Root runs tracked at once (oldest idle ones are forgotten first). */
|
|
15
|
+
const RUNS_LIMIT = 1000
|
|
16
|
+
|
|
17
|
+
type Outcome = "succeeded" | "failed" | "interrupted"
|
|
18
|
+
|
|
19
|
+
interface Run {
|
|
20
|
+
/** Something ran since the last "all done". */
|
|
21
|
+
armed: boolean
|
|
22
|
+
/** How the root's last turn ended (undefined while it runs). */
|
|
23
|
+
outcome?: Outcome
|
|
24
|
+
timer?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class DoneWatch {
|
|
28
|
+
private readonly runs = new Map<string, Run>()
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly timers: Timers,
|
|
32
|
+
/** The tree of `rootID` is settled now. */
|
|
33
|
+
private readonly settled: (rootID: string) => boolean,
|
|
34
|
+
private readonly fire: (rootID: string) => void,
|
|
35
|
+
private readonly delay = DONE_SETTLE_MS,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
/** A session of the tree started a run (`root`: the root itself). */
|
|
39
|
+
started(rootID: string, root: boolean): void {
|
|
40
|
+
const run = this.run(rootID)
|
|
41
|
+
run.armed = true
|
|
42
|
+
if (root) run.outcome = undefined
|
|
43
|
+
this.cancel(run)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The root's turn ended; `wasRunning`: it was known to run (a duplicate or stray end does not arm). */
|
|
47
|
+
rootEnded(rootID: string, outcome: Outcome, wasRunning: boolean): void {
|
|
48
|
+
const run = this.runs.get(rootID) ?? (wasRunning ? this.run(rootID) : undefined)
|
|
49
|
+
if (!run) return
|
|
50
|
+
if (wasRunning) run.armed = true
|
|
51
|
+
run.outcome = outcome
|
|
52
|
+
this.schedule(rootID, run)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Something else changed in the tree (a subagent or job ended, an answer, a hub turn released). */
|
|
56
|
+
poke(rootID: string): void {
|
|
57
|
+
const run = this.runs.get(rootID)
|
|
58
|
+
if (run?.armed) this.schedule(rootID, run)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
forget(rootID: string): void {
|
|
62
|
+
const run = this.runs.get(rootID)
|
|
63
|
+
if (run) this.cancel(run)
|
|
64
|
+
this.runs.delete(rootID)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private run(rootID: string): Run {
|
|
68
|
+
let run = this.runs.get(rootID)
|
|
69
|
+
if (run) return run
|
|
70
|
+
this.runs.set(rootID, (run = { armed: false }))
|
|
71
|
+
if (this.runs.size > RUNS_LIMIT) {
|
|
72
|
+
for (const [id, old] of this.runs) {
|
|
73
|
+
if (old.timer !== undefined) continue
|
|
74
|
+
this.runs.delete(id)
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return run
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private schedule(rootID: string, run: Run): void {
|
|
82
|
+
if (!run.armed) return
|
|
83
|
+
this.cancel(run)
|
|
84
|
+
run.timer = this.timers.set(() => {
|
|
85
|
+
run.timer = undefined
|
|
86
|
+
if (!run.armed || run.outcome !== "succeeded" || !this.settled(rootID)) return
|
|
87
|
+
run.armed = false
|
|
88
|
+
this.fire(rootID)
|
|
89
|
+
}, this.delay)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private cancel(run: Run): void {
|
|
93
|
+
if (run.timer !== undefined) this.timers.clear(run.timer)
|
|
94
|
+
run.timer = undefined
|
|
95
|
+
}
|
|
96
|
+
}
|