@huynle/hub 0.8.0 → 0.8.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 +213 -1
- package/README.md +84 -15
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +649 -242
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +7 -3
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/teams-mcp.ts +59 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -1
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/acting.ts +3 -0
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +101 -27
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +26 -2
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +4 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +4 -3
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +38 -15
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +26 -1
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +22 -0
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +39 -18
- package/src/migrate.ts +109 -3
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/hub-tool.ts +24 -10
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/session-context.ts +107 -27
- package/src/spoke/spoke.ts +13 -5
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
|
@@ -23,12 +23,25 @@
|
|
|
23
23
|
// the session's own asks open and none of its Decisions open, unchanged for END_SETTLE_MS. end.when asked also
|
|
24
24
|
// ends needs_user when a question stays open that long (blocked, or idle with an ask or Decision open); never
|
|
25
25
|
// ends only by stop or timeout. end.timeout counts from running and stops the session (outcome timeout). A
|
|
26
|
-
// session unreported for SESSION_LOST_MS is lost.
|
|
27
|
-
//
|
|
26
|
+
// session unreported for SESSION_LOST_MS is lost. A running launch is lost too when the SAME node instance
|
|
27
|
+
// (instanceID) that reported it leaves it out of its reports for NODE_UNREPORTED_MS (hub.json
|
|
28
|
+
// agents.launches.lostAfterMinutes; 0 or false: never) while its session is not reported by any spoke. A new
|
|
29
|
+
// node instance (a restarted daemon keeps host launches in memory only) never starts that clock for launches it
|
|
30
|
+
// never reported; a disconnected node, the session reported again or the launch reported again starts it over.
|
|
31
|
+
// Registry status events, node events, a tick (END_TICK_MS) and settle/lost timers drive it.
|
|
28
32
|
// - Ending: stop the session when asked (stop, timeout), collect artifacts (once prompted) and clean the workspace
|
|
29
33
|
// up on the node (policy: needs_user keeps; never prompted removes; else spec.end.cleanup; the node never removes
|
|
30
|
-
// reused, dirty or unpushed work), then the ended step with artifacts. Node away
|
|
31
|
-
// record and runs when the node
|
|
34
|
+
// reused, dirty or unpushed work), then the ended step with artifacts. Node away, or the cleanup request failed:
|
|
35
|
+
// its cleanup waits in the work record and runs on reconcile: when the node (re)connects, reports capacity, and
|
|
36
|
+
// on every tick (a failing retry stops after RECONCILE_RETRIES per process and is logged once), so a node never
|
|
37
|
+
// keeps a slot for a launch the hub ended.
|
|
38
|
+
// - Waiting (FX-02): each placement pass's waiting launches get their reason stored (LaunchStore.setWaiting: only
|
|
39
|
+
// on change) and logged once per change: `[hub] launch <id> waits (<wait>): <reason>`. A new wait kind is a launch
|
|
40
|
+
// change (onLaunchChanged: journal, webhooks, SSE); a new text of the same kind (e.g. slot counts) goes only to
|
|
41
|
+
// onLaunchWaiting (the console's live stream).
|
|
42
|
+
// - A lost launch whose session a spoke still reports keeps its workspace (cleanup keep, never remove).
|
|
43
|
+
// - Launchers: view and list flag a launch whose app token was revoked (launcherRevoked; deps.launchers); its
|
|
44
|
+
// launches are not stopped, they keep counting toward maxActive until the user stops them.
|
|
32
45
|
// - Work records (StateStore LAUNCH_WORK, per launch): fence, workspace, sessionID, prompted, seenWorking, pending
|
|
33
46
|
// cleanup, so a restarted hub carries on: queued/placing → placement; provisioning → recover (see recover);
|
|
34
47
|
// running → watched again (seen working and the timeout survive).
|
|
@@ -52,7 +65,7 @@ import { AgentError } from "./errors"
|
|
|
52
65
|
import { validateLaunchSpec } from "./launch-spec"
|
|
53
66
|
import type { CleanupPolicy, LaunchSpec } from "./launch-spec"
|
|
54
67
|
import { type Launch, type LaunchFilter, type LaunchOutcome, type LaunchState, LaunchStore, LaunchTransitionError } from "./launch-store"
|
|
55
|
-
import { Placement, PlacementLoop, type Placed, type PlacementNode } from "./placement"
|
|
68
|
+
import { Placement, PlacementLoop, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
|
|
56
69
|
|
|
57
70
|
/** The NodeRegistry calls the engine needs. */
|
|
58
71
|
export type EngineNodes = {
|
|
@@ -124,6 +137,10 @@ export interface LaunchEngineDeps {
|
|
|
124
137
|
readonly profiles?: { applyAtLaunch(userID: string, sessionID: string, ref: { readonly profileID: string; readonly version: number }): Promise<string[]> }
|
|
125
138
|
/** (CON-02) Stores the spec's intent as the session's intent (source launch) at identify. */
|
|
126
139
|
readonly intents?: { storeLaunch(userID: string, sessionID: string, text: string, by?: string): Promise<void> }
|
|
140
|
+
/** (FX-02) A queued launch's waiting text changed, same kind: live only (SSE), no journal or webhook. */
|
|
141
|
+
readonly onLaunchWaiting?: (launch: Launch) => void
|
|
142
|
+
/** (FX-02) Revoked launchers: their launches are flagged (view, list, the active wait); without it none is. */
|
|
143
|
+
readonly launchers?: PlacementLaunchers
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
/** Node events of every pod (cluster mode; the same as local ones on a single hub). */
|
|
@@ -156,6 +173,13 @@ export const END_SETTLE_MS = 30_000
|
|
|
156
173
|
export const END_TICK_MS = 15_000
|
|
157
174
|
/** How long a running launch's session may go unreported before the launch is lost. */
|
|
158
175
|
export const SESSION_LOST_MS = 10 * 60_000
|
|
176
|
+
/** How long a connected node may leave a running launch out of its reports before it is lost (default). */
|
|
177
|
+
export const NODE_UNREPORTED_MS = 5 * 60_000
|
|
178
|
+
/** setTimeout's longest delay (2^31 − 1 ms); longer ones fire at once. Every engine timer is clamped to it. */
|
|
179
|
+
export const MAX_TIMER_MS = 2 ** 31 - 1
|
|
180
|
+
const clampDelay = (ms: number) => Math.min(Math.max(0, ms), MAX_TIMER_MS)
|
|
181
|
+
/** Reconcile retries of a node cleanup that keeps failing (per launch and process; a node reconnecting tries again). */
|
|
182
|
+
export const RECONCILE_RETRIES = 5
|
|
159
183
|
|
|
160
184
|
/** A provisioning run in this process; stop: a stop was asked for (its reason). */
|
|
161
185
|
interface Pipeline {
|
|
@@ -220,10 +244,16 @@ interface Watch {
|
|
|
220
244
|
readonly launchID: string
|
|
221
245
|
readonly sessionID: string
|
|
222
246
|
readonly when: LaunchSpec["end"]["when"]
|
|
247
|
+
readonly machineID?: string
|
|
223
248
|
seenWorking: boolean
|
|
224
249
|
/** The end it would take and since when that has held. */
|
|
225
250
|
candidate?: { readonly outcome: LaunchOutcome; readonly since: number }
|
|
226
251
|
unreportedSince?: number
|
|
252
|
+
/** The node instance that last reported it (only its omission counts). */
|
|
253
|
+
listedBy?: string
|
|
254
|
+
/** Since when that node instance has left it out of its reports (its session unreported too). */
|
|
255
|
+
unlistedSince?: number
|
|
256
|
+
unlistedTimer?: unknown
|
|
227
257
|
settleTimer?: unknown
|
|
228
258
|
lostTimer?: unknown
|
|
229
259
|
timeoutTimer?: unknown
|
|
@@ -234,7 +264,7 @@ interface Watch {
|
|
|
234
264
|
|
|
235
265
|
const defaultTimers: Timers = {
|
|
236
266
|
set: (fn, ms) => {
|
|
237
|
-
const handle = setTimeout(fn, ms)
|
|
267
|
+
const handle = setTimeout(fn, clampDelay(ms))
|
|
238
268
|
;(handle as { unref?: () => void }).unref?.()
|
|
239
269
|
return handle
|
|
240
270
|
},
|
|
@@ -242,7 +272,7 @@ const defaultTimers: Timers = {
|
|
|
242
272
|
}
|
|
243
273
|
|
|
244
274
|
/** A launch as the API shows it: the stored launch plus its session's directory. */
|
|
245
|
-
export type LaunchView = Launch & { readonly directory?: string }
|
|
275
|
+
export type LaunchView = Launch & { readonly directory?: string; readonly launcherRevoked?: boolean }
|
|
246
276
|
|
|
247
277
|
/** `provider/model#variant` as a ModelSelection. */
|
|
248
278
|
export function modelSelection(model: string): ModelSelection {
|
|
@@ -292,6 +322,13 @@ export class LaunchEngine {
|
|
|
292
322
|
private readonly seenEarly = new Set<string>()
|
|
293
323
|
private readonly timers: Timers
|
|
294
324
|
private tickTimer: unknown
|
|
325
|
+
/** How long the reporting node instance may omit a running launch (NODE_UNREPORTED_MS, or caps.lostAfterMinutes; undefined: never). */
|
|
326
|
+
private readonly nodeLostMs: number | undefined
|
|
327
|
+
/** Machines being reconciled now. */
|
|
328
|
+
private readonly reconciling = new Set<string>()
|
|
329
|
+
/** Reconcile cleanup retries per launch, and the launches whose failing retry was logged. */
|
|
330
|
+
private readonly cleanupTries = new Map<string, number>()
|
|
331
|
+
private readonly cleanupLogged = new Set<string>()
|
|
295
332
|
/** start() ran and shutdown() did not: this engine runs placement, pipelines and end watchers. */
|
|
296
333
|
private supervising = false
|
|
297
334
|
|
|
@@ -299,6 +336,8 @@ export class LaunchEngine {
|
|
|
299
336
|
this.now = deps.now ?? Date.now
|
|
300
337
|
this.timers = deps.timers ?? defaultTimers
|
|
301
338
|
this.store = new LaunchStore({ state: deps.state, now: this.now })
|
|
339
|
+
const lostAfter = deps.caps?.lostAfterMinutes
|
|
340
|
+
this.nodeLostMs = lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
|
|
302
341
|
const { registry } = deps
|
|
303
342
|
this.placement = new Placement({
|
|
304
343
|
store: this.store,
|
|
@@ -314,12 +353,14 @@ export class LaunchEngine {
|
|
|
314
353
|
locks: deps.locks,
|
|
315
354
|
owner: deps.owner,
|
|
316
355
|
...(deps.caps ? { caps: deps.caps } : {}),
|
|
356
|
+
...(deps.launchers ? { launchers: deps.launchers } : {}),
|
|
317
357
|
now: this.now,
|
|
318
358
|
})
|
|
319
359
|
this.loop = new PlacementLoop({
|
|
320
360
|
placement: this.placement,
|
|
321
361
|
users: async () => [...(await deps.users())],
|
|
322
362
|
onPlaced: (placed) => void this.provision(placed),
|
|
363
|
+
onWaiting: (userID, waiting) => this.waited(userID, waiting),
|
|
323
364
|
...(deps.timers ? { timers: deps.timers } : {}),
|
|
324
365
|
})
|
|
325
366
|
}
|
|
@@ -328,7 +369,7 @@ export class LaunchEngine {
|
|
|
328
369
|
* Validates and queues a launch (or, same key and body, returns the existing one with created false). Throws
|
|
329
370
|
* invalid, name_taken (the name is held by a session or another live launch) or rate_limited (caps).
|
|
330
371
|
*/
|
|
331
|
-
async accept(userID: string, body: unknown, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
372
|
+
async accept(userID: string, body: unknown, options: { readonly by?: string; readonly byLabel?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
332
373
|
const spec = validateLaunchSpec(body)
|
|
333
374
|
const launches = await this.store.list(userID)
|
|
334
375
|
const retry = spec.key !== undefined && launches.some((l) => l.spec.key === spec.key)
|
|
@@ -336,7 +377,7 @@ export class LaunchEngine {
|
|
|
336
377
|
if (spec.name !== undefined) this.checkName(userID, spec.name, launches)
|
|
337
378
|
await this.placement.admit(userID)
|
|
338
379
|
}
|
|
339
|
-
const result = await this.store.create(userID, spec, options.by !== undefined ? { by: options.by } : {})
|
|
380
|
+
const result = await this.store.create(userID, spec, { ...(options.by !== undefined ? { by: options.by } : {}), ...(options.byLabel ? { byLabel: options.byLabel } : {}) })
|
|
340
381
|
if (result.created) {
|
|
341
382
|
await this.placement.recordLaunch(userID)
|
|
342
383
|
this.changed(result.launch)
|
|
@@ -349,19 +390,49 @@ export class LaunchEngine {
|
|
|
349
390
|
async view(userID: string, id: string): Promise<LaunchView | undefined> {
|
|
350
391
|
const launch = await this.store.get(userID, id)
|
|
351
392
|
if (!launch) return undefined
|
|
352
|
-
|
|
353
|
-
|
|
393
|
+
return this.viewOf(userID, launch)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The launch as the user's live stream shows it: flagged when its launcher (app token) was revoked. */
|
|
397
|
+
live(launch: Launch): LaunchView {
|
|
398
|
+
return this.revoked(launch) ? { ...launch, launcherRevoked: true } : launch
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private revoked(launch: Launch): boolean {
|
|
402
|
+
return launch.by !== undefined && launch.by.startsWith("app:") && this.deps.launchers?.revoked(launch.owner, launch.by) === true
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The stored launch with its directory, flagged when its launcher was revoked. */
|
|
406
|
+
private async viewOf(userID: string, launch: Launch): Promise<LaunchView> {
|
|
407
|
+
const directory = (await this.work(userID, launch.id))?.workspace?.directory
|
|
408
|
+
const revoked = this.revoked(launch)
|
|
409
|
+
return { ...launch, ...(directory !== undefined ? { directory } : {}), ...(revoked ? { launcherRevoked: true } : {}) }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** A placement pass's waiting launches: the reason stored (only when it changed), announced and logged once. */
|
|
413
|
+
private async waited(userID: string, waiting: readonly Waiting[]): Promise<void> {
|
|
414
|
+
for (const { launchID, wait, reason, shared } of waiting) {
|
|
415
|
+
const before = (await this.store.get(userID, launchID))?.waiting?.wait
|
|
416
|
+
const launch = await this.store.setWaiting(userID, launchID, { wait, reason, ...(shared !== undefined ? { shared } : {}) })
|
|
417
|
+
if (!launch) continue
|
|
418
|
+
console.log(`[hub] launch ${launchID} waits (${wait}): ${launch.reason}`)
|
|
419
|
+
if (before !== wait) this.changed(launch)
|
|
420
|
+
else this.waitingText(launch)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private waitingText(launch: Launch): void {
|
|
425
|
+
try {
|
|
426
|
+
this.deps.onLaunchWaiting?.(launch)
|
|
427
|
+
} catch (cause) {
|
|
428
|
+
console.error("[hub] launch listener failed:", cause instanceof Error ? cause.message : cause)
|
|
429
|
+
}
|
|
354
430
|
}
|
|
355
431
|
|
|
356
432
|
/** The user's launches by creation (filtered), with their directories. */
|
|
357
433
|
async list(userID: string, filter: LaunchFilter = {}): Promise<LaunchView[]> {
|
|
358
434
|
const launches = await this.store.list(userID, filter)
|
|
359
|
-
return Promise.all(
|
|
360
|
-
launches.map(async (launch) => {
|
|
361
|
-
const directory = (await this.work(userID, launch.id))?.workspace?.directory
|
|
362
|
-
return directory !== undefined ? { ...launch, directory } : launch
|
|
363
|
-
}),
|
|
364
|
-
)
|
|
435
|
+
return Promise.all(launches.map((launch) => this.viewOf(userID, launch)))
|
|
365
436
|
}
|
|
366
437
|
|
|
367
438
|
/** The labels of the (latest) launch that started the session, or undefined when no launch did. */
|
|
@@ -380,8 +451,10 @@ export class LaunchEngine {
|
|
|
380
451
|
const { nodes, registry } = this.deps
|
|
381
452
|
this.unsubscribe.push(
|
|
382
453
|
nodes.subscribe((event) => {
|
|
454
|
+
// Its running launches: is the node (still) reporting them?
|
|
455
|
+
for (const watch of this.watches.values()) if (watch.machineID === event.node.machineID) this.evaluate(watch)
|
|
383
456
|
if (event.type === "disconnected" || event.node.ownerID === undefined) return
|
|
384
|
-
if (event.type === "connected") void this.reconcile(event.node)
|
|
457
|
+
if (event.type === "connected" || event.type === "capacity") void this.reconcile(event.node, event.type === "connected")
|
|
385
458
|
void this.loop.kick(event.node.ownerID)
|
|
386
459
|
}, REMOTE),
|
|
387
460
|
registry.subscribe((message) => {
|
|
@@ -619,15 +692,30 @@ export class LaunchEngine {
|
|
|
619
692
|
})
|
|
620
693
|
}
|
|
621
694
|
|
|
622
|
-
/**
|
|
623
|
-
|
|
695
|
+
/**
|
|
696
|
+
* A node (re)connected, reported capacity, or the tick: it cleans up what it still holds for launches that ended
|
|
697
|
+
* (or that this hub forgot). Outside a connect, a cleanup that keeps failing is retried RECONCILE_RETRIES times.
|
|
698
|
+
*/
|
|
699
|
+
private async reconcile(node: NodeView, connected = false): Promise<void> {
|
|
624
700
|
const userID = node.ownerID
|
|
625
|
-
if (userID === undefined) return
|
|
701
|
+
if (userID === undefined || this.reconciling.has(node.machineID)) return
|
|
702
|
+
this.reconciling.add(node.machineID)
|
|
703
|
+
try {
|
|
704
|
+
await this.reconcileNow(node, userID, connected)
|
|
705
|
+
} finally {
|
|
706
|
+
this.reconciling.delete(node.machineID)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private async reconcileNow(node: NodeView, userID: string, connected: boolean): Promise<void> {
|
|
626
711
|
const { nodeOps } = this.deps
|
|
627
712
|
for (const report of node.launches) {
|
|
628
|
-
if (this.pipelines.has(report.launchID)) continue
|
|
713
|
+
if (this.pipelines.has(report.launchID) || this.ending.has(report.launchID)) continue
|
|
629
714
|
const launch = await this.store.get(userID, report.launchID)
|
|
630
715
|
if (launch && launch.state !== "ended") continue
|
|
716
|
+
const tries = this.cleanupTries.get(report.launchID) ?? 0
|
|
717
|
+
if (!connected && tries >= RECONCILE_RETRIES) continue
|
|
718
|
+
if (!connected) this.cleanupTries.set(report.launchID, tries + 1)
|
|
631
719
|
try {
|
|
632
720
|
if (report.phase === "preparing") {
|
|
633
721
|
await nodeOps.requestNode(node.connID, { op: "cancel", launchID: report.launchID })
|
|
@@ -638,7 +726,11 @@ export class LaunchEngine {
|
|
|
638
726
|
if (!workspace) continue
|
|
639
727
|
if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
|
|
640
728
|
await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
|
|
729
|
+
this.cleanupTries.delete(report.launchID)
|
|
730
|
+
this.cleanupLogged.delete(report.launchID)
|
|
641
731
|
} catch (cause) {
|
|
732
|
+
if (this.cleanupLogged.has(report.launchID)) continue
|
|
733
|
+
this.cleanupLogged.add(report.launchID)
|
|
642
734
|
console.error(`[hub] launch ${report.launchID}: cleanup on the node failed: ${errorText(cause)}`)
|
|
643
735
|
}
|
|
644
736
|
}
|
|
@@ -818,14 +910,24 @@ export class LaunchEngine {
|
|
|
818
910
|
/** Watches a running launch for its end (timeout, idle or asked, a lost session). */
|
|
819
911
|
private arm(userID: string, launch: Launch, seenWorking: boolean): void {
|
|
820
912
|
if (this.watches.has(launch.id) || launch.sessionID === undefined) return
|
|
821
|
-
const watch: Watch = {
|
|
913
|
+
const watch: Watch = {
|
|
914
|
+
userID,
|
|
915
|
+
launchID: launch.id,
|
|
916
|
+
sessionID: launch.sessionID,
|
|
917
|
+
when: launch.spec.end.when,
|
|
918
|
+
...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
|
|
919
|
+
seenWorking: seenWorking || this.seenEarly.delete(launch.id),
|
|
920
|
+
}
|
|
822
921
|
this.watches.set(launch.id, watch)
|
|
823
922
|
const { timeoutMs } = launch.spec.end
|
|
824
923
|
if (timeoutMs !== undefined) {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
924
|
+
// Up to 30 days: past setTimeout's limit the clamped timer fires early and is armed again for the rest.
|
|
925
|
+
const fire = () => {
|
|
926
|
+
const left = launch.at + timeoutMs - this.now()
|
|
927
|
+
if (left > 0) watch.timeoutTimer = this.timers.set(fire, clampDelay(left))
|
|
928
|
+
else void this.end(userID, launch.id, "timeout", "Timed out", { stopSession: true })
|
|
929
|
+
}
|
|
930
|
+
watch.timeoutTimer = this.timers.set(fire, clampDelay(launch.at + timeoutMs - this.now()))
|
|
829
931
|
}
|
|
830
932
|
this.evaluate(watch)
|
|
831
933
|
}
|
|
@@ -834,7 +936,7 @@ export class LaunchEngine {
|
|
|
834
936
|
const watch = this.watches.get(id)
|
|
835
937
|
if (!watch) return
|
|
836
938
|
this.watches.delete(id)
|
|
837
|
-
for (const timer of [watch.settleTimer, watch.lostTimer, watch.timeoutTimer]) if (timer !== undefined) this.timers.clear(timer)
|
|
939
|
+
for (const timer of [watch.settleTimer, watch.lostTimer, watch.timeoutTimer, watch.unlistedTimer]) if (timer !== undefined) this.timers.clear(timer)
|
|
838
940
|
}
|
|
839
941
|
|
|
840
942
|
/** The launch's prompt was seen working: an idle end may start from now on. */
|
|
@@ -854,6 +956,7 @@ export class LaunchEngine {
|
|
|
854
956
|
private scheduleTick(): void {
|
|
855
957
|
this.tickTimer = this.timers.set(() => {
|
|
856
958
|
for (const watch of this.watches.values()) this.evaluate(watch)
|
|
959
|
+
for (const node of this.deps.nodes.list()) void this.reconcile(node)
|
|
857
960
|
this.scheduleTick()
|
|
858
961
|
}, END_TICK_MS)
|
|
859
962
|
}
|
|
@@ -884,6 +987,11 @@ export class LaunchEngine {
|
|
|
884
987
|
const { registry, delivery, decisions } = this.deps
|
|
885
988
|
const { userID, sessionID } = watch
|
|
886
989
|
const now = this.now()
|
|
990
|
+
if (this.unlisted(watch, now)) {
|
|
991
|
+
const minutes = Math.round(this.nodeLostMs! / 60_000)
|
|
992
|
+
await this.end(userID, watch.launchID, "lost", `The node stopped reporting the launch for ${minutes} minute${minutes === 1 ? "" : "s"}`)
|
|
993
|
+
return
|
|
994
|
+
}
|
|
887
995
|
if (!registry.hasSession(sessionID)) {
|
|
888
996
|
this.clearCandidate(watch)
|
|
889
997
|
watch.unreportedSince ??= now
|
|
@@ -928,6 +1036,37 @@ export class LaunchEngine {
|
|
|
928
1036
|
await this.end(userID, watch.launchID, outcome, outcome === "idle" ? "Idle: the session finished" : "Needs the user: the session asked a question")
|
|
929
1037
|
}
|
|
930
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* Whether its connected node has left the launch out of its reports for nodeLostMs (it is lost). A disconnected
|
|
1041
|
+
* node, or the launch in its reports again, starts the time over.
|
|
1042
|
+
*/
|
|
1043
|
+
private unlisted(watch: Watch, now: number): boolean {
|
|
1044
|
+
const lostMs = this.nodeLostMs
|
|
1045
|
+
const node = watch.machineID !== undefined ? this.deps.nodes.forMachine(watch.machineID) : undefined
|
|
1046
|
+
const reset = () => {
|
|
1047
|
+
watch.unlistedSince = undefined
|
|
1048
|
+
if (watch.unlistedTimer !== undefined) this.timers.clear(watch.unlistedTimer)
|
|
1049
|
+
watch.unlistedTimer = undefined
|
|
1050
|
+
return false
|
|
1051
|
+
}
|
|
1052
|
+
if (!node) return reset()
|
|
1053
|
+
if (node.launches.some((l) => l.launchID === watch.launchID)) {
|
|
1054
|
+
watch.listedBy = node.instanceID
|
|
1055
|
+
return reset()
|
|
1056
|
+
}
|
|
1057
|
+
// A new instance (a restarted daemon) never reported it; a live session means the launch still runs.
|
|
1058
|
+
if (lostMs === undefined || watch.listedBy !== node.instanceID || this.deps.registry.hasSession(watch.sessionID)) return reset()
|
|
1059
|
+
watch.unlistedSince ??= now
|
|
1060
|
+
if (now - watch.unlistedSince >= lostMs) return true
|
|
1061
|
+
if (watch.unlistedTimer === undefined) {
|
|
1062
|
+
watch.unlistedTimer = this.timers.set(() => {
|
|
1063
|
+
watch.unlistedTimer = undefined
|
|
1064
|
+
this.evaluate(watch)
|
|
1065
|
+
}, clampDelay(watch.unlistedSince + lostMs - now))
|
|
1066
|
+
}
|
|
1067
|
+
return false
|
|
1068
|
+
}
|
|
1069
|
+
|
|
931
1070
|
private clearCandidate(watch: Watch): void {
|
|
932
1071
|
watch.candidate = undefined
|
|
933
1072
|
if (watch.settleTimer !== undefined) this.timers.clear(watch.settleTimer)
|
|
@@ -958,7 +1097,9 @@ export class LaunchEngine {
|
|
|
958
1097
|
const notes: string[] = []
|
|
959
1098
|
let artifacts: Record<string, unknown> | undefined
|
|
960
1099
|
if (work?.workspace) {
|
|
961
|
-
|
|
1100
|
+
// A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
|
|
1101
|
+
const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
|
|
1102
|
+
const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
|
|
962
1103
|
const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
|
|
963
1104
|
if (node) {
|
|
964
1105
|
if (work.started) {
|
|
@@ -980,7 +1121,9 @@ export class LaunchEngine {
|
|
|
980
1121
|
const cleaned = await this.deps.nodeOps.requestNode(node.connID, { op: "cleanup", launchID: id, workspace: work.workspace, policy })
|
|
981
1122
|
notes.push(cleaned.removed ? "workspace removed" : `workspace kept${cleaned.reason ? ` (${cleaned.reason})` : ""}`)
|
|
982
1123
|
} catch (cause) {
|
|
983
|
-
|
|
1124
|
+
// The node may still hold the launch (and its slot): reconcile retries with this policy.
|
|
1125
|
+
notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
|
|
1126
|
+
await this.updateWork(userID, id, { cleanup: policy })
|
|
984
1127
|
}
|
|
985
1128
|
} else {
|
|
986
1129
|
notes.push("workspace cleanup waits for the node")
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// POST /api/agent/launches {launch spec} → 201 {launch}; an idempotent repeat (same key and
|
|
5
5
|
// body) → 200 {launch}; same key, other body → 400
|
|
6
6
|
// GET /api/agent/launches?state=a,b&label=k=v → {launches} (oldest first; every label must match)
|
|
7
|
-
// GET /api/agent/launches/:id → {launch}
|
|
7
|
+
// GET /api/agent/launches/:id → {launch} (queued: `waiting` {wait, reason, since}, and `reason` is
|
|
8
|
+
// the waiting reason; launcherRevoked: its app token was revoked)
|
|
8
9
|
// DELETE /api/agent/launches/:id → 200 {launch} (ended stopped); already ended → 410 gone
|
|
9
10
|
//
|
|
10
11
|
// Who: users always (their own launches; tenancy only). App tokens with scope sessions:launch and sessions through
|
|
@@ -85,16 +86,25 @@ function wireSpec(spec: LaunchSpec): WireSpec {
|
|
|
85
86
|
return { ...spec, end: { ...end, ...(timeoutMs !== undefined ? { timeout: duration(timeoutMs) } : {}) } }
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The API view of a launch: never its owner; `updated` is when it entered its state. forLauncher (an app or session
|
|
91
|
+
* reading its own launch): a waiting reason in its launcher-safe form (counts only, no other launchers or machine
|
|
92
|
+
* names); the user gets the full text.
|
|
93
|
+
*/
|
|
94
|
+
export function launchInfo(launch: LaunchView, options: { readonly forLauncher?: boolean } = {}): LaunchApiView {
|
|
95
|
+
const waiting = launch.state === "queued" ? launch.waiting : undefined
|
|
96
|
+
const waitText = waiting ? (options.forLauncher ? (waiting.shared ?? waiting.reason) : waiting.reason) : undefined
|
|
90
97
|
return {
|
|
91
98
|
id: launch.id,
|
|
92
99
|
state: launch.state,
|
|
93
|
-
reason: launch.reason,
|
|
100
|
+
reason: waitText ?? launch.reason,
|
|
94
101
|
spec: wireSpec(launch.spec),
|
|
95
102
|
created: launch.created,
|
|
96
103
|
updated: launch.at,
|
|
97
104
|
...(launch.by !== undefined ? { by: launch.by } : {}),
|
|
105
|
+
...(launch.byLabel !== undefined ? { byLabel: launch.byLabel } : {}),
|
|
106
|
+
...(launch.launcherRevoked ? { launcherRevoked: true } : {}),
|
|
107
|
+
...(waiting ? { waiting: { wait: waiting.wait, reason: waitText!, since: waiting.since } } : {}),
|
|
98
108
|
...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
|
|
99
109
|
...(launch.directory !== undefined ? { directory: launch.directory } : {}),
|
|
100
110
|
...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
|
|
@@ -203,6 +213,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
203
213
|
throw new AgentError("forbidden", "A launch's agentProfile needs scope profiles:assign; sessions cannot pin profiles")
|
|
204
214
|
}
|
|
205
215
|
const by = launchedBy(actor)
|
|
216
|
+
const byLabel = actor.kind === "app" ? actor.label : undefined
|
|
206
217
|
const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
|
|
207
218
|
let launchID: string | undefined
|
|
208
219
|
const result = await act(user.id, actor, "launch", spec.workspace.repo, {
|
|
@@ -212,12 +223,12 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
212
223
|
run: async () => {
|
|
213
224
|
// An agent never gets another launcher's launch through its key.
|
|
214
225
|
if (held && actor.kind !== "user" && held.by !== by) throw new AgentError("invalid", "This key was used by another launcher", { hint: "Use a key of your own" })
|
|
215
|
-
const accepted = await launches.accept(user.id, raw, { by })
|
|
226
|
+
const accepted = await launches.accept(user.id, raw, { by, ...(byLabel ? { byLabel } : {}) })
|
|
216
227
|
launchID = accepted.launch.id
|
|
217
228
|
return accepted
|
|
218
229
|
},
|
|
219
230
|
})
|
|
220
|
-
return Response.json({ launch: launchInfo(result.launch) }, { status: result.created ? 201 : 200, headers: NO_STORE })
|
|
231
|
+
return Response.json({ launch: launchInfo(result.launch, { forLauncher: actor.kind !== "user" }) }, { status: result.created ? 201 : 200, headers: NO_STORE })
|
|
221
232
|
},
|
|
222
233
|
},
|
|
223
234
|
{
|
|
@@ -228,7 +239,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
228
239
|
const filter = filterOf(url.searchParams)
|
|
229
240
|
const by = actor.kind === "user" ? undefined : launchedBy(actor)
|
|
230
241
|
const list = (await launches.list(user.id, filter)).filter((l) => by === undefined || l.by === by)
|
|
231
|
-
return Response.json({ launches: list.map(launchInfo) }, { headers: NO_STORE })
|
|
242
|
+
return Response.json({ launches: list.map((l) => launchInfo(l, { forLauncher: actor.kind !== "user" })) }, { headers: NO_STORE })
|
|
232
243
|
},
|
|
233
244
|
},
|
|
234
245
|
{
|
|
@@ -236,7 +247,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
236
247
|
path: `${PATH}/:id`,
|
|
237
248
|
allow: LAUNCH,
|
|
238
249
|
async handle({ params, actor, user }) {
|
|
239
|
-
return Response.json({ launch: launchInfo(await own(user.id, actor, params.id!)) }, { headers: NO_STORE })
|
|
250
|
+
return Response.json({ launch: launchInfo(await own(user.id, actor, params.id!), { forLauncher: actor.kind !== "user" }) }, { headers: NO_STORE })
|
|
240
251
|
},
|
|
241
252
|
},
|
|
242
253
|
{
|
|
@@ -250,7 +261,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
250
261
|
spend: async () => true,
|
|
251
262
|
run: () => launches.stop(user.id, launch.id),
|
|
252
263
|
})
|
|
253
|
-
return Response.json({ launch: launchInfo(stopped) }, { headers: NO_STORE })
|
|
264
|
+
return Response.json({ launch: launchInfo(stopped, { forLauncher: actor.kind !== "user" }) }, { headers: NO_STORE })
|
|
254
265
|
},
|
|
255
266
|
},
|
|
256
267
|
]
|
|
@@ -92,8 +92,18 @@ export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
|
|
|
92
92
|
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
|
|
93
93
|
const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
|
|
94
94
|
const RUNTIME_FIELDS = ["kind", "profile"]
|
|
95
|
-
const PLACEMENT_FIELDS = ["
|
|
95
|
+
const PLACEMENT_FIELDS = ["machine", "labels", "mode"]
|
|
96
96
|
const END_FIELDS = ["when", "timeout", "cleanup"]
|
|
97
|
+
/** The nested sections, in the order suggestions name them. */
|
|
98
|
+
const SECTIONS: readonly (readonly [string, readonly string[]])[] = [
|
|
99
|
+
["workspace", WORKSPACE_FIELDS],
|
|
100
|
+
["placement", PLACEMENT_FIELDS],
|
|
101
|
+
["runtime", RUNTIME_FIELDS],
|
|
102
|
+
["end", END_FIELDS],
|
|
103
|
+
]
|
|
104
|
+
const TOP_FIELDS = FIELDS.filter((field) => !SECTIONS.some(([section]) => section === field))
|
|
105
|
+
/** (FX-03) The hint every unknown-field answer carries: callers often send a flat spec. */
|
|
106
|
+
export const LAUNCH_SPEC_SHAPE = `A launch spec is nested: ${SECTIONS.map(([section, fields]) => `${section}{${fields.join(", ")}}`).join(", ")}; top level: ${TOP_FIELDS.join(", ")}`
|
|
97
107
|
|
|
98
108
|
const KEY = /^[!-~]{1,200}$/
|
|
99
109
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
@@ -109,8 +119,22 @@ const PROMPT_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
|
|
|
109
119
|
const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
|
|
110
120
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
111
121
|
|
|
122
|
+
/** Where an unknown `key` at level `what` (launch, or a section) belongs instead: `section.key`s, or the top level. */
|
|
123
|
+
function placesOf(key: string, what: string): string[] {
|
|
124
|
+
const places = SECTIONS.filter(([section, fields]) => section !== what && fields.includes(key)).map(([section]) => `${section}.${key}`)
|
|
125
|
+
if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(`${key} (top level)`)
|
|
126
|
+
return places
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Refuses every field outside `allowed` at once, naming where a misplaced one belongs (FX-03). */
|
|
112
130
|
function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
|
|
113
|
-
|
|
131
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key))
|
|
132
|
+
if (unknown.length === 0) return
|
|
133
|
+
const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what)).filter((places) => places.length > 0).map((places) => places.join(" or "))
|
|
134
|
+
// Keys are the caller's: bounded in number and length.
|
|
135
|
+
const shown = [...unknown.slice(0, 10).map((key) => key.slice(0, 40)), ...(unknown.length > 10 ? ["…"] : [])]
|
|
136
|
+
const message = `Unknown ${what} field${unknown.length > 1 ? "s" : ""} ${shown.join(", ")}${suggestions.length ? `: did you mean ${suggestions.join(", ")}?` : ""}`
|
|
137
|
+
throw invalid(message, LAUNCH_SPEC_SHAPE)
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
|
|
12
12
|
// ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
|
|
13
13
|
// (its creator crashed) blocks the key for RESERVATION_MS, then is taken over.
|
|
14
|
+
// - Waiting (FX-02): a queued launch carries why it waits (`waiting`, from placement): setWaiting writes it only
|
|
15
|
+
// while the launch is queued and only when it changed (no history step; `since` kept while the wait kind is the
|
|
16
|
+
// same), and mirrors it into `reason`; every transition clears it.
|
|
14
17
|
// - Pruning: ended launches older than endedKeepMs, and all but the newest endedMax ended ones, are removed and
|
|
15
18
|
// their keys released. Live launches are never pruned.
|
|
16
19
|
import { randomBytes } from "node:crypto"
|
|
@@ -26,6 +29,8 @@ export const ENDED_KEEP_MS = 7 * 86_400_000
|
|
|
26
29
|
export const ENDED_MAX = 500
|
|
27
30
|
/** How long a key claim without its launch (a creator that may still be writing) blocks the key. */
|
|
28
31
|
export const RESERVATION_MS = 60_000
|
|
32
|
+
/** The longest launcher label kept on a launch. */
|
|
33
|
+
const LABEL_MAX = 100
|
|
29
34
|
/** Reads of a key holder's launch: its creator may be between the claim and the write, so a miss is re-read. */
|
|
30
35
|
const READS = 5
|
|
31
36
|
/** Claim attempts (a stale claim is taken over between them). */
|
|
@@ -45,6 +50,21 @@ const NEXT: Record<LaunchState, readonly LaunchState[]> = {
|
|
|
45
50
|
ended: [],
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** Why a queued launch waits (placement's Waiting without the launch ID). */
|
|
54
|
+
export interface LaunchWaitReason {
|
|
55
|
+
/** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
|
|
56
|
+
readonly wait: "capacity" | "no_match" | "active"
|
|
57
|
+
/** The full text, for the user (names other launchers and machines). */
|
|
58
|
+
readonly reason: string
|
|
59
|
+
/** The text for the launch's own launcher (an app or session) when it differs: counts only, no other launchers or machine names. */
|
|
60
|
+
readonly shared?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A queued launch's wait, since it first waited for this kind of reason. */
|
|
64
|
+
export interface LaunchWaiting extends LaunchWaitReason {
|
|
65
|
+
readonly since: number
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
export interface LaunchStep {
|
|
49
69
|
readonly state: LaunchState
|
|
50
70
|
readonly reason: string
|
|
@@ -59,6 +79,8 @@ export interface Launch {
|
|
|
59
79
|
readonly spec: LaunchSpec
|
|
60
80
|
/** Who launched it, as the caller describes them (e.g. `app:<token id>`, `ses_…`, `user`). */
|
|
61
81
|
readonly by?: string
|
|
82
|
+
/** The launcher's label when it launched (an app token's label, a session's @name); never a secret. */
|
|
83
|
+
readonly byLabel?: string
|
|
62
84
|
readonly state: LaunchState
|
|
63
85
|
readonly reason: string
|
|
64
86
|
/** When it entered its state. */
|
|
@@ -68,6 +90,8 @@ export interface Launch {
|
|
|
68
90
|
readonly sessionID?: string
|
|
69
91
|
readonly name?: string
|
|
70
92
|
readonly artifacts?: Readonly<Record<string, unknown>>
|
|
93
|
+
/** Only while queued: why it waits (placement). */
|
|
94
|
+
readonly waiting?: LaunchWaiting
|
|
71
95
|
readonly created: number
|
|
72
96
|
readonly history: readonly LaunchStep[]
|
|
73
97
|
}
|
|
@@ -138,7 +162,7 @@ export class LaunchStore {
|
|
|
138
162
|
* Stores a new queued launch, or (same key, equivalent body) returns the existing one with created false.
|
|
139
163
|
* Throws invalid (key reused with another body) or rate_limited (the key's launch is still being created).
|
|
140
164
|
*/
|
|
141
|
-
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
165
|
+
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string; readonly byLabel?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
142
166
|
const id = this.newID()
|
|
143
167
|
if (spec.key !== undefined) {
|
|
144
168
|
const existing = await this.claimKey(userID, spec.key, id)
|
|
@@ -157,6 +181,7 @@ export class LaunchStore {
|
|
|
157
181
|
owner: userID,
|
|
158
182
|
spec,
|
|
159
183
|
...(options.by !== undefined ? { by: options.by } : {}),
|
|
184
|
+
...(options.byLabel ? { byLabel: cleanReason(options.byLabel).slice(0, LABEL_MAX) } : {}),
|
|
160
185
|
state: "queued",
|
|
161
186
|
reason: "Queued",
|
|
162
187
|
at,
|
|
@@ -190,8 +215,9 @@ export class LaunchStore {
|
|
|
190
215
|
if (!NEXT[current.state].includes(to) || (input.from && !input.from.includes(current.state))) throw new LaunchTransitionError(current.state, to)
|
|
191
216
|
checkDetails(to, input)
|
|
192
217
|
const step: LaunchStep = { state: to, reason, at, ...(input.outcome !== undefined ? { outcome: input.outcome } : {}) }
|
|
218
|
+
const { waiting: _cleared, ...rest } = current
|
|
193
219
|
return {
|
|
194
|
-
...
|
|
220
|
+
...rest,
|
|
195
221
|
state: to,
|
|
196
222
|
reason,
|
|
197
223
|
at,
|
|
@@ -206,6 +232,33 @@ export class LaunchStore {
|
|
|
206
232
|
return next!
|
|
207
233
|
}
|
|
208
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Records why a queued launch waits (undefined: it no longer waits). Writes nothing (resolves undefined) when the
|
|
237
|
+
* launch is not queued or the wait and reason are unchanged; else resolves with the launch as written. The
|
|
238
|
+
* reason shows as the launch's reason; `since` stays while the wait kind stays the same.
|
|
239
|
+
*/
|
|
240
|
+
async setWaiting(userID: string, id: string, waiting: LaunchWaitReason | undefined): Promise<Launch | undefined> {
|
|
241
|
+
const reason = waiting ? cleanReason(waiting.reason) : undefined
|
|
242
|
+
const shared = waiting?.shared !== undefined ? cleanReason(waiting.shared) : undefined
|
|
243
|
+
let written = false
|
|
244
|
+
const next = await updateVersioned<Launch>(this.deps.state, userID, LAUNCHES, id, (current) => {
|
|
245
|
+
written = false
|
|
246
|
+
if (!current || current.state !== "queued") return undefined
|
|
247
|
+
const was = current.waiting
|
|
248
|
+
if (!waiting) {
|
|
249
|
+
if (!was) return undefined
|
|
250
|
+
const { waiting: _cleared, ...rest } = current
|
|
251
|
+
written = true
|
|
252
|
+
return { ...rest, reason: "Queued" }
|
|
253
|
+
}
|
|
254
|
+
if (was && was.wait === waiting.wait && was.reason === reason && was.shared === shared) return undefined
|
|
255
|
+
written = true
|
|
256
|
+
const since = was?.wait === waiting.wait ? was.since : this.now()
|
|
257
|
+
return { ...current, reason: reason!, waiting: { wait: waiting.wait, reason: reason!, ...(shared !== undefined ? { shared } : {}), since } }
|
|
258
|
+
})
|
|
259
|
+
return written ? next : undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
209
262
|
/** Removes ended launches past endedKeepMs or beyond the newest endedMax; resolves with the removed ids. */
|
|
210
263
|
async prune(userID: string): Promise<string[]> {
|
|
211
264
|
const { state } = this.deps
|