@huynle/hub 0.7.4 → 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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- 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 +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- 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 +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -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-CTgqHk5g.css +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/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// - Placement (placement.ts, under the user's fenced lease) moves it to provisioning on a node; the engine then
|
|
7
7
|
// runs one pipeline per launch: prepare on the node (requestNode) → launch create on the machine's service spoke
|
|
8
8
|
// (feature "launch": directory, launchID, agent, model) → roles (byUser only for the user's own launches; a refused
|
|
9
|
-
// role is noted, not fatal) → the name (taken meanwhile: failed) → the
|
|
9
|
+
// role is noted, not fatal) → the name (taken meanwhile: failed) → the spec's intent (CON-02, source launch; a
|
|
10
|
+
// failure is noted, not fatal) → the primer (Delivery.prime, so roles and name
|
|
10
11
|
// are in it) → the prompt (actions.prompt, resetHops false; marked prompted first, so at most once) → running.
|
|
11
12
|
// A stop between steps ends it stopped; the node going away ends it lost; anything else ends it failed.
|
|
12
13
|
// - openshell launches (design §6.4, decision lk6gn3u7): after prepare the engine mints the launch's hlt1 token
|
|
@@ -22,12 +23,25 @@
|
|
|
22
23
|
// the session's own asks open and none of its Decisions open, unchanged for END_SETTLE_MS. end.when asked also
|
|
23
24
|
// ends needs_user when a question stays open that long (blocked, or idle with an ask or Decision open); never
|
|
24
25
|
// ends only by stop or timeout. end.timeout counts from running and stops the session (outcome timeout). A
|
|
25
|
-
// session unreported for SESSION_LOST_MS is lost.
|
|
26
|
-
//
|
|
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.
|
|
27
32
|
// - Ending: stop the session when asked (stop, timeout), collect artifacts (once prompted) and clean the workspace
|
|
28
33
|
// up on the node (policy: needs_user keeps; never prompted removes; else spec.end.cleanup; the node never removes
|
|
29
|
-
// reused, dirty or unpushed work), then the ended step with artifacts. Node away
|
|
30
|
-
// 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.
|
|
31
45
|
// - Work records (StateStore LAUNCH_WORK, per launch): fence, workspace, sessionID, prompted, seenWorking, pending
|
|
32
46
|
// cleanup, so a restarted hub carries on: queued/placing → placement; provisioning → recover (see recover);
|
|
33
47
|
// running → watched again (seen working and the timeout survive).
|
|
@@ -51,7 +65,7 @@ import { AgentError } from "./errors"
|
|
|
51
65
|
import { validateLaunchSpec } from "./launch-spec"
|
|
52
66
|
import type { CleanupPolicy, LaunchSpec } from "./launch-spec"
|
|
53
67
|
import { type Launch, type LaunchFilter, type LaunchOutcome, type LaunchState, LaunchStore, LaunchTransitionError } from "./launch-store"
|
|
54
|
-
import { Placement, PlacementLoop, type Placed, type PlacementNode } from "./placement"
|
|
68
|
+
import { Placement, PlacementLoop, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
|
|
55
69
|
|
|
56
70
|
/** The NodeRegistry calls the engine needs. */
|
|
57
71
|
export type EngineNodes = {
|
|
@@ -119,6 +133,14 @@ export interface LaunchEngineDeps {
|
|
|
119
133
|
readonly onLaunchChanged?: (launch: Launch) => void
|
|
120
134
|
/** Cluster mode: the leader's supervisor, for an engine that does not supervise (see EngineSupervisor). */
|
|
121
135
|
readonly supervisor?: EngineSupervisor
|
|
136
|
+
/** (HWA-15) Applies a launch's agentProfile before its prompt; returns notes for the reason, never throws. */
|
|
137
|
+
readonly profiles?: { applyAtLaunch(userID: string, sessionID: string, ref: { readonly profileID: string; readonly version: number }): Promise<string[]> }
|
|
138
|
+
/** (CON-02) Stores the spec's intent as the session's intent (source launch) at identify. */
|
|
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
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
/** Node events of every pod (cluster mode; the same as local ones on a single hub). */
|
|
@@ -151,6 +173,13 @@ export const END_SETTLE_MS = 30_000
|
|
|
151
173
|
export const END_TICK_MS = 15_000
|
|
152
174
|
/** How long a running launch's session may go unreported before the launch is lost. */
|
|
153
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
|
|
154
183
|
|
|
155
184
|
/** A provisioning run in this process; stop: a stop was asked for (its reason). */
|
|
156
185
|
interface Pipeline {
|
|
@@ -215,10 +244,16 @@ interface Watch {
|
|
|
215
244
|
readonly launchID: string
|
|
216
245
|
readonly sessionID: string
|
|
217
246
|
readonly when: LaunchSpec["end"]["when"]
|
|
247
|
+
readonly machineID?: string
|
|
218
248
|
seenWorking: boolean
|
|
219
249
|
/** The end it would take and since when that has held. */
|
|
220
250
|
candidate?: { readonly outcome: LaunchOutcome; readonly since: number }
|
|
221
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
|
|
222
257
|
settleTimer?: unknown
|
|
223
258
|
lostTimer?: unknown
|
|
224
259
|
timeoutTimer?: unknown
|
|
@@ -229,7 +264,7 @@ interface Watch {
|
|
|
229
264
|
|
|
230
265
|
const defaultTimers: Timers = {
|
|
231
266
|
set: (fn, ms) => {
|
|
232
|
-
const handle = setTimeout(fn, ms)
|
|
267
|
+
const handle = setTimeout(fn, clampDelay(ms))
|
|
233
268
|
;(handle as { unref?: () => void }).unref?.()
|
|
234
269
|
return handle
|
|
235
270
|
},
|
|
@@ -237,7 +272,7 @@ const defaultTimers: Timers = {
|
|
|
237
272
|
}
|
|
238
273
|
|
|
239
274
|
/** A launch as the API shows it: the stored launch plus its session's directory. */
|
|
240
|
-
export type LaunchView = Launch & { readonly directory?: string }
|
|
275
|
+
export type LaunchView = Launch & { readonly directory?: string; readonly launcherRevoked?: boolean }
|
|
241
276
|
|
|
242
277
|
/** `provider/model#variant` as a ModelSelection. */
|
|
243
278
|
export function modelSelection(model: string): ModelSelection {
|
|
@@ -287,6 +322,13 @@ export class LaunchEngine {
|
|
|
287
322
|
private readonly seenEarly = new Set<string>()
|
|
288
323
|
private readonly timers: Timers
|
|
289
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>()
|
|
290
332
|
/** start() ran and shutdown() did not: this engine runs placement, pipelines and end watchers. */
|
|
291
333
|
private supervising = false
|
|
292
334
|
|
|
@@ -294,6 +336,8 @@ export class LaunchEngine {
|
|
|
294
336
|
this.now = deps.now ?? Date.now
|
|
295
337
|
this.timers = deps.timers ?? defaultTimers
|
|
296
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
|
|
297
341
|
const { registry } = deps
|
|
298
342
|
this.placement = new Placement({
|
|
299
343
|
store: this.store,
|
|
@@ -309,12 +353,14 @@ export class LaunchEngine {
|
|
|
309
353
|
locks: deps.locks,
|
|
310
354
|
owner: deps.owner,
|
|
311
355
|
...(deps.caps ? { caps: deps.caps } : {}),
|
|
356
|
+
...(deps.launchers ? { launchers: deps.launchers } : {}),
|
|
312
357
|
now: this.now,
|
|
313
358
|
})
|
|
314
359
|
this.loop = new PlacementLoop({
|
|
315
360
|
placement: this.placement,
|
|
316
361
|
users: async () => [...(await deps.users())],
|
|
317
362
|
onPlaced: (placed) => void this.provision(placed),
|
|
363
|
+
onWaiting: (userID, waiting) => this.waited(userID, waiting),
|
|
318
364
|
...(deps.timers ? { timers: deps.timers } : {}),
|
|
319
365
|
})
|
|
320
366
|
}
|
|
@@ -323,7 +369,7 @@ export class LaunchEngine {
|
|
|
323
369
|
* Validates and queues a launch (or, same key and body, returns the existing one with created false). Throws
|
|
324
370
|
* invalid, name_taken (the name is held by a session or another live launch) or rate_limited (caps).
|
|
325
371
|
*/
|
|
326
|
-
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 }> {
|
|
327
373
|
const spec = validateLaunchSpec(body)
|
|
328
374
|
const launches = await this.store.list(userID)
|
|
329
375
|
const retry = spec.key !== undefined && launches.some((l) => l.spec.key === spec.key)
|
|
@@ -331,7 +377,7 @@ export class LaunchEngine {
|
|
|
331
377
|
if (spec.name !== undefined) this.checkName(userID, spec.name, launches)
|
|
332
378
|
await this.placement.admit(userID)
|
|
333
379
|
}
|
|
334
|
-
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 } : {}) })
|
|
335
381
|
if (result.created) {
|
|
336
382
|
await this.placement.recordLaunch(userID)
|
|
337
383
|
this.changed(result.launch)
|
|
@@ -344,19 +390,49 @@ export class LaunchEngine {
|
|
|
344
390
|
async view(userID: string, id: string): Promise<LaunchView | undefined> {
|
|
345
391
|
const launch = await this.store.get(userID, id)
|
|
346
392
|
if (!launch) return undefined
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}
|
|
349
430
|
}
|
|
350
431
|
|
|
351
432
|
/** The user's launches by creation (filtered), with their directories. */
|
|
352
433
|
async list(userID: string, filter: LaunchFilter = {}): Promise<LaunchView[]> {
|
|
353
434
|
const launches = await this.store.list(userID, filter)
|
|
354
|
-
return Promise.all(
|
|
355
|
-
launches.map(async (launch) => {
|
|
356
|
-
const directory = (await this.work(userID, launch.id))?.workspace?.directory
|
|
357
|
-
return directory !== undefined ? { ...launch, directory } : launch
|
|
358
|
-
}),
|
|
359
|
-
)
|
|
435
|
+
return Promise.all(launches.map((launch) => this.viewOf(userID, launch)))
|
|
360
436
|
}
|
|
361
437
|
|
|
362
438
|
/** The labels of the (latest) launch that started the session, or undefined when no launch did. */
|
|
@@ -375,8 +451,10 @@ export class LaunchEngine {
|
|
|
375
451
|
const { nodes, registry } = this.deps
|
|
376
452
|
this.unsubscribe.push(
|
|
377
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)
|
|
378
456
|
if (event.type === "disconnected" || event.node.ownerID === undefined) return
|
|
379
|
-
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")
|
|
380
458
|
void this.loop.kick(event.node.ownerID)
|
|
381
459
|
}, REMOTE),
|
|
382
460
|
registry.subscribe((message) => {
|
|
@@ -614,15 +692,30 @@ export class LaunchEngine {
|
|
|
614
692
|
})
|
|
615
693
|
}
|
|
616
694
|
|
|
617
|
-
/**
|
|
618
|
-
|
|
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> {
|
|
619
700
|
const userID = node.ownerID
|
|
620
|
-
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> {
|
|
621
711
|
const { nodeOps } = this.deps
|
|
622
712
|
for (const report of node.launches) {
|
|
623
|
-
if (this.pipelines.has(report.launchID)) continue
|
|
713
|
+
if (this.pipelines.has(report.launchID) || this.ending.has(report.launchID)) continue
|
|
624
714
|
const launch = await this.store.get(userID, report.launchID)
|
|
625
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)
|
|
626
719
|
try {
|
|
627
720
|
if (report.phase === "preparing") {
|
|
628
721
|
await nodeOps.requestNode(node.connID, { op: "cancel", launchID: report.launchID })
|
|
@@ -633,7 +726,11 @@ export class LaunchEngine {
|
|
|
633
726
|
if (!workspace) continue
|
|
634
727
|
if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
|
|
635
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)
|
|
636
731
|
} catch (cause) {
|
|
732
|
+
if (this.cleanupLogged.has(report.launchID)) continue
|
|
733
|
+
this.cleanupLogged.add(report.launchID)
|
|
637
734
|
console.error(`[hub] launch ${report.launchID}: cleanup on the node failed: ${errorText(cause)}`)
|
|
638
735
|
}
|
|
639
736
|
}
|
|
@@ -771,11 +868,20 @@ export class LaunchEngine {
|
|
|
771
868
|
}
|
|
772
869
|
}
|
|
773
870
|
if (spec.name !== undefined) await directory.setName(userID, sessionID, spec.name)
|
|
871
|
+
if (spec.intent !== undefined && this.deps.intents) {
|
|
872
|
+
try {
|
|
873
|
+
await this.deps.intents.storeLaunch(userID, sessionID, spec.intent, launch.by ?? "user")
|
|
874
|
+
} catch (cause) {
|
|
875
|
+
notes.push(`intent not stored: ${errorText(cause)}`)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
774
878
|
await delivery.prime(userID, sessionID)
|
|
879
|
+
if (spec.agentProfile) notes.push(...(this.deps.profiles ? await this.deps.profiles.applyAtLaunch(userID, sessionID, spec.agentProfile) : ["agentProfile not applied: this hub does not apply profiles"]))
|
|
775
880
|
checkpoint()
|
|
776
881
|
if (spec.prompt !== undefined) {
|
|
777
882
|
await this.updateWork(userID, launch.id, { prompted: true })
|
|
778
|
-
|
|
883
|
+
// (CON-10) The launch's prompt is the session's first prompt (its intent), even when the launch named it.
|
|
884
|
+
const prompted = await actions.prompt({ sessionID, text: spec.prompt, resetHops: false, intent: "first" })
|
|
779
885
|
if (prompted.outcome === "offline") throw new Error("The session went offline before its prompt")
|
|
780
886
|
if (prompted.outcome === "unsupported") throw new Error(unsupportedMessage(prompted))
|
|
781
887
|
if (prompted.mode === "steer") this.sawWorking(userID, launch.id)
|
|
@@ -804,14 +910,24 @@ export class LaunchEngine {
|
|
|
804
910
|
/** Watches a running launch for its end (timeout, idle or asked, a lost session). */
|
|
805
911
|
private arm(userID: string, launch: Launch, seenWorking: boolean): void {
|
|
806
912
|
if (this.watches.has(launch.id) || launch.sessionID === undefined) return
|
|
807
|
-
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
|
+
}
|
|
808
921
|
this.watches.set(launch.id, watch)
|
|
809
922
|
const { timeoutMs } = launch.spec.end
|
|
810
923
|
if (timeoutMs !== undefined) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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()))
|
|
815
931
|
}
|
|
816
932
|
this.evaluate(watch)
|
|
817
933
|
}
|
|
@@ -820,7 +936,7 @@ export class LaunchEngine {
|
|
|
820
936
|
const watch = this.watches.get(id)
|
|
821
937
|
if (!watch) return
|
|
822
938
|
this.watches.delete(id)
|
|
823
|
-
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)
|
|
824
940
|
}
|
|
825
941
|
|
|
826
942
|
/** The launch's prompt was seen working: an idle end may start from now on. */
|
|
@@ -840,6 +956,7 @@ export class LaunchEngine {
|
|
|
840
956
|
private scheduleTick(): void {
|
|
841
957
|
this.tickTimer = this.timers.set(() => {
|
|
842
958
|
for (const watch of this.watches.values()) this.evaluate(watch)
|
|
959
|
+
for (const node of this.deps.nodes.list()) void this.reconcile(node)
|
|
843
960
|
this.scheduleTick()
|
|
844
961
|
}, END_TICK_MS)
|
|
845
962
|
}
|
|
@@ -870,6 +987,11 @@ export class LaunchEngine {
|
|
|
870
987
|
const { registry, delivery, decisions } = this.deps
|
|
871
988
|
const { userID, sessionID } = watch
|
|
872
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
|
+
}
|
|
873
995
|
if (!registry.hasSession(sessionID)) {
|
|
874
996
|
this.clearCandidate(watch)
|
|
875
997
|
watch.unreportedSince ??= now
|
|
@@ -914,6 +1036,37 @@ export class LaunchEngine {
|
|
|
914
1036
|
await this.end(userID, watch.launchID, outcome, outcome === "idle" ? "Idle: the session finished" : "Needs the user: the session asked a question")
|
|
915
1037
|
}
|
|
916
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
|
+
|
|
917
1070
|
private clearCandidate(watch: Watch): void {
|
|
918
1071
|
watch.candidate = undefined
|
|
919
1072
|
if (watch.settleTimer !== undefined) this.timers.clear(watch.settleTimer)
|
|
@@ -944,7 +1097,9 @@ export class LaunchEngine {
|
|
|
944
1097
|
const notes: string[] = []
|
|
945
1098
|
let artifacts: Record<string, unknown> | undefined
|
|
946
1099
|
if (work?.workspace) {
|
|
947
|
-
|
|
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
|
|
948
1103
|
const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
|
|
949
1104
|
if (node) {
|
|
950
1105
|
if (work.started) {
|
|
@@ -966,7 +1121,9 @@ export class LaunchEngine {
|
|
|
966
1121
|
const cleaned = await this.deps.nodeOps.requestNode(node.connID, { op: "cleanup", launchID: id, workspace: work.workspace, policy })
|
|
967
1122
|
notes.push(cleaned.removed ? "workspace removed" : `workspace kept${cleaned.reason ? ` (${cleaned.reason})` : ""}`)
|
|
968
1123
|
} catch (cause) {
|
|
969
|
-
|
|
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 })
|
|
970
1127
|
}
|
|
971
1128
|
} else {
|
|
972
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
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
// → kill switch again → the engine. Apps and sessions see and stop only the launches they started (by); any other
|
|
14
15
|
// launch, like another user's, is 404 not_found. The engine applies the per-user launch caps (rate_limited).
|
|
15
16
|
// Every outcome once the body is valid is in the agent audit (verb launch, or launch.stop for DELETE).
|
|
17
|
+
import { boundedJson } from "./body"
|
|
16
18
|
import type { LaunchInfo, LaunchSpec as WireSpec } from "../../protocol"
|
|
17
19
|
import { Feed } from "../cluster/feed"
|
|
18
20
|
import type { ActingSwitch } from "./acting-switch"
|
|
@@ -84,16 +86,25 @@ function wireSpec(spec: LaunchSpec): WireSpec {
|
|
|
84
86
|
return { ...spec, end: { ...end, ...(timeoutMs !== undefined ? { timeout: duration(timeoutMs) } : {}) } }
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
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
|
|
89
97
|
return {
|
|
90
98
|
id: launch.id,
|
|
91
99
|
state: launch.state,
|
|
92
|
-
reason: launch.reason,
|
|
100
|
+
reason: waitText ?? launch.reason,
|
|
93
101
|
spec: wireSpec(launch.spec),
|
|
94
102
|
created: launch.created,
|
|
95
103
|
updated: launch.at,
|
|
96
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 } } : {}),
|
|
97
108
|
...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
|
|
98
109
|
...(launch.directory !== undefined ? { directory: launch.directory } : {}),
|
|
99
110
|
...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
|
|
@@ -120,7 +131,7 @@ const invalid = (message: string) => new AgentError("invalid", message)
|
|
|
120
131
|
const notFound = (id: string) => new AgentError("not_found", `No launch ${id}`)
|
|
121
132
|
|
|
122
133
|
async function bodyOf(request: Request): Promise<unknown> {
|
|
123
|
-
return
|
|
134
|
+
return boundedJson(request)
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
/** ?state=a,b and ?label=k=v (repeated). */
|
|
@@ -197,7 +208,12 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
197
208
|
async handle({ request, actor, user }) {
|
|
198
209
|
const raw = await bodyOf(request)
|
|
199
210
|
const spec = validateLaunchSpec(raw)
|
|
211
|
+
// HWA-15: naming a profile pins it; apps need profiles:assign, sessions never pin profiles.
|
|
212
|
+
if (spec.agentProfile && actor.kind !== "user" && !(actor.kind === "app" && actor.scopes.includes("profiles:assign"))) {
|
|
213
|
+
throw new AgentError("forbidden", "A launch's agentProfile needs scope profiles:assign; sessions cannot pin profiles")
|
|
214
|
+
}
|
|
200
215
|
const by = launchedBy(actor)
|
|
216
|
+
const byLabel = actor.kind === "app" ? actor.label : undefined
|
|
201
217
|
const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
|
|
202
218
|
let launchID: string | undefined
|
|
203
219
|
const result = await act(user.id, actor, "launch", spec.workspace.repo, {
|
|
@@ -207,12 +223,12 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
207
223
|
run: async () => {
|
|
208
224
|
// An agent never gets another launcher's launch through its key.
|
|
209
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" })
|
|
210
|
-
const accepted = await launches.accept(user.id, raw, { by })
|
|
226
|
+
const accepted = await launches.accept(user.id, raw, { by, ...(byLabel ? { byLabel } : {}) })
|
|
211
227
|
launchID = accepted.launch.id
|
|
212
228
|
return accepted
|
|
213
229
|
},
|
|
214
230
|
})
|
|
215
|
-
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 })
|
|
216
232
|
},
|
|
217
233
|
},
|
|
218
234
|
{
|
|
@@ -223,7 +239,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
223
239
|
const filter = filterOf(url.searchParams)
|
|
224
240
|
const by = actor.kind === "user" ? undefined : launchedBy(actor)
|
|
225
241
|
const list = (await launches.list(user.id, filter)).filter((l) => by === undefined || l.by === by)
|
|
226
|
-
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 })
|
|
227
243
|
},
|
|
228
244
|
},
|
|
229
245
|
{
|
|
@@ -231,7 +247,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
231
247
|
path: `${PATH}/:id`,
|
|
232
248
|
allow: LAUNCH,
|
|
233
249
|
async handle({ params, actor, user }) {
|
|
234
|
-
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 })
|
|
235
251
|
},
|
|
236
252
|
},
|
|
237
253
|
{
|
|
@@ -245,7 +261,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
245
261
|
spend: async () => true,
|
|
246
262
|
run: () => launches.stop(user.id, launch.id),
|
|
247
263
|
})
|
|
248
|
-
return Response.json({ launch: launchInfo(stopped) }, { headers: NO_STORE })
|
|
264
|
+
return Response.json({ launch: launchInfo(stopped, { forLauncher: actor.kind !== "user" }) }, { headers: NO_STORE })
|
|
249
265
|
},
|
|
250
266
|
},
|
|
251
267
|
]
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// cleanup keep, no timeout.
|
|
11
11
|
// LaunchSpec is defined here until packages/protocol carries it (P4-T1); keep the two in step.
|
|
12
12
|
|
|
13
|
+
import { type LaunchAgentProfile, launchAgentProfileProblem } from "../../protocol"
|
|
13
14
|
import { AgentError } from "./errors"
|
|
15
|
+
import { intentText } from "./intents"
|
|
14
16
|
import { isReservedName, parseAddress, validName } from "./names"
|
|
15
17
|
import { canonicalProject, parseDuration } from "./policy"
|
|
16
18
|
|
|
@@ -66,9 +68,13 @@ export interface LaunchSpec {
|
|
|
66
68
|
readonly runtime: LaunchRuntime
|
|
67
69
|
readonly placement: LaunchPlacement
|
|
68
70
|
readonly prompt?: string
|
|
71
|
+
/** (CON-02) Stored as the session's intent (source launch) when it is identified. */
|
|
72
|
+
readonly intent?: string
|
|
69
73
|
/** Free-form app labels (never interpreted by the hub). */
|
|
70
74
|
readonly labels: Readonly<Record<string, string>>
|
|
71
75
|
readonly end: LaunchEnd
|
|
76
|
+
/** (HWA-15) The profile version pinned and applied before the prompt (explicit version; never latest). */
|
|
77
|
+
readonly agentProfile?: LaunchAgentProfile
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
export const KEY_MAX = 200
|
|
@@ -83,11 +89,21 @@ export const MODEL_MAX = 200
|
|
|
83
89
|
export const LAUNCH_TIMEOUT_MIN_MS = 60_000
|
|
84
90
|
export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
|
|
85
91
|
|
|
86
|
-
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "labels", "end"]
|
|
92
|
+
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
|
|
87
93
|
const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
|
|
88
94
|
const RUNTIME_FIELDS = ["kind", "profile"]
|
|
89
|
-
const PLACEMENT_FIELDS = ["
|
|
95
|
+
const PLACEMENT_FIELDS = ["machine", "labels", "mode"]
|
|
90
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(", ")}`
|
|
91
107
|
|
|
92
108
|
const KEY = /^[!-~]{1,200}$/
|
|
93
109
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
@@ -103,8 +119,22 @@ const PROMPT_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
|
|
|
103
119
|
const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
|
|
104
120
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
105
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). */
|
|
106
130
|
function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
|
|
107
|
-
|
|
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)
|
|
108
138
|
}
|
|
109
139
|
|
|
110
140
|
function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
|
|
@@ -254,8 +284,13 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
|
254
284
|
if (typeof input.prompt !== "string" || input.prompt.length > PROMPT_MAX || PROMPT_CONTROL.test(input.prompt)) throw invalid(`prompt is text of up to ${PROMPT_MAX} characters`)
|
|
255
285
|
prompt = input.prompt
|
|
256
286
|
}
|
|
287
|
+
// (CON-02) What the session is for: untrusted, sanitised to one line, at most INTENT_MAX characters.
|
|
288
|
+
const intent = input.intent === undefined ? undefined : intentText(input.intent)
|
|
257
289
|
const labels = validLabels(input.labels, "labels")
|
|
258
290
|
const end = validEnd(input.end)
|
|
291
|
+
const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
|
|
292
|
+
if (profileProblem) throw invalid(profileProblem)
|
|
293
|
+
const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
|
|
259
294
|
return {
|
|
260
295
|
...(key !== undefined ? { key } : {}),
|
|
261
296
|
...(name !== undefined ? { name } : {}),
|
|
@@ -267,8 +302,10 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
|
267
302
|
runtime,
|
|
268
303
|
placement,
|
|
269
304
|
...(prompt !== undefined ? { prompt } : {}),
|
|
305
|
+
...(intent !== undefined ? { intent } : {}),
|
|
270
306
|
labels,
|
|
271
307
|
end,
|
|
308
|
+
...(agentProfile ? { agentProfile } : {}),
|
|
272
309
|
}
|
|
273
310
|
}
|
|
274
311
|
|