@huynle/hub 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The launch API on /api/agent (design §6.1, §7, §8; decision znbg8xa5): thin HTTP over the launch engine
|
|
2
|
+
// (launch-engine.ts, Central.launches).
|
|
3
|
+
//
|
|
4
|
+
// POST /api/agent/launches {launch spec} → 201 {launch}; an idempotent repeat (same key and
|
|
5
|
+
// body) → 200 {launch}; same key, other body → 400
|
|
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}
|
|
8
|
+
// DELETE /api/agent/launches/:id → 200 {launch} (ended stopped); already ended → 410 gone
|
|
9
|
+
//
|
|
10
|
+
// Who: users always (their own launches; tenancy only). App tokens with scope sessions:launch and sessions through
|
|
11
|
+
// their act token (never a shell token) under a grant of their user with verb `launch` covering the repo, in the
|
|
12
|
+
// acting order: kill switch → grant usable → grant action consumed (hourly cap; an idempotent repeat spends none)
|
|
13
|
+
// → kill switch again → the engine. Apps and sessions see and stop only the launches they started (by); any other
|
|
14
|
+
// launch, like another user's, is 404 not_found. The engine applies the per-user launch caps (rate_limited).
|
|
15
|
+
// Every outcome once the body is valid is in the agent audit (verb launch, or launch.stop for DELETE).
|
|
16
|
+
import type { LaunchInfo, LaunchSpec as WireSpec } from "../../protocol"
|
|
17
|
+
import { Feed } from "../cluster/feed"
|
|
18
|
+
import type { ActingSwitch } from "./acting-switch"
|
|
19
|
+
import { auditOf } from "./acting"
|
|
20
|
+
import type { AgentActor } from "./actor"
|
|
21
|
+
import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
|
|
22
|
+
import type { Directory } from "./directory"
|
|
23
|
+
import { partyFromActor, type Party } from "./envelope"
|
|
24
|
+
import { AgentError } from "./errors"
|
|
25
|
+
import type { Grant, Grants } from "./grants"
|
|
26
|
+
import type { LaunchEngine, LaunchView } from "./launch-engine"
|
|
27
|
+
import { type LaunchSpec, validateLaunchSpec } from "./launch-spec"
|
|
28
|
+
import { LAUNCH_STATES, type Launch, type LaunchFilter, type LaunchState, type LaunchStep } from "./launch-store"
|
|
29
|
+
import type { AgentRoute } from "./routes"
|
|
30
|
+
|
|
31
|
+
// A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
|
|
32
|
+
const PATH = "/api/agent/launches"
|
|
33
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
34
|
+
/** Users; sessions by their act token; apps with sessions:launch. */
|
|
35
|
+
const LAUNCH = { act: true, scope: "sessions:launch" } as const
|
|
36
|
+
|
|
37
|
+
export type RouteLaunches = Pick<LaunchEngine, "accept" | "view" | "list" | "stop">
|
|
38
|
+
|
|
39
|
+
export interface LaunchRoutesDeps {
|
|
40
|
+
readonly launches: RouteLaunches
|
|
41
|
+
readonly grants: Pick<Grants, "usable" | "consume">
|
|
42
|
+
readonly acting: Pick<ActingSwitch, "ensureActing">
|
|
43
|
+
readonly audit: Pick<AgentAudit, "record">
|
|
44
|
+
/** Names a session caller in the audit. */
|
|
45
|
+
readonly directory?: Pick<Directory, "resolveOne">
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A launch change for its user (the engine's onLaunchChanged). */
|
|
49
|
+
export type LaunchListener = (userID: string, launch: Launch) => void
|
|
50
|
+
|
|
51
|
+
/** Launch changes' Bus topic (cluster mode). */
|
|
52
|
+
export const LAUNCHES_TOPIC = "feed.launches"
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every launch change, per user: the hub's SSE 'launch' events, GET /api/agent/watch and webhooks (P4-T10)
|
|
56
|
+
* subscribe. A throwing listener is logged and never stops the others. Shared with the other pods once attached
|
|
57
|
+
* (see ../cluster/feed).
|
|
58
|
+
*/
|
|
59
|
+
export class LaunchFeed extends Feed<Launch> {
|
|
60
|
+
constructor() {
|
|
61
|
+
super(LAUNCHES_TOPIC, (cause) => console.error(`[hub] launch listener failed: ${cause instanceof Error ? cause.message : String(cause)}`))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A launch as the API shows it: LaunchInfo, plus who launched it and its steps. */
|
|
66
|
+
export type LaunchApiView = LaunchInfo & { readonly by?: string; readonly history: readonly LaunchStep[] }
|
|
67
|
+
|
|
68
|
+
const UNITS: readonly [string, number][] = [
|
|
69
|
+
["d", 86_400_000],
|
|
70
|
+
["h", 3_600_000],
|
|
71
|
+
["m", 60_000],
|
|
72
|
+
["s", 1000],
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
/** A duration as the launch API writes it (`4h`, `90m`, `45s`). */
|
|
76
|
+
function duration(ms: number): string {
|
|
77
|
+
for (const [unit, size] of UNITS) if (ms % size === 0) return `${ms / size}${unit}`
|
|
78
|
+
return `${Math.ceil(ms / 1000)}s`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The validated spec as a request body (end.timeout as a duration). */
|
|
82
|
+
function wireSpec(spec: LaunchSpec): WireSpec {
|
|
83
|
+
const { timeoutMs, ...end } = spec.end
|
|
84
|
+
return { ...spec, end: { ...end, ...(timeoutMs !== undefined ? { timeout: duration(timeoutMs) } : {}) } }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The API view of a launch: never its owner; `updated` is when it entered its state. */
|
|
88
|
+
export function launchInfo(launch: LaunchView): LaunchApiView {
|
|
89
|
+
return {
|
|
90
|
+
id: launch.id,
|
|
91
|
+
state: launch.state,
|
|
92
|
+
reason: launch.reason,
|
|
93
|
+
spec: wireSpec(launch.spec),
|
|
94
|
+
created: launch.created,
|
|
95
|
+
updated: launch.at,
|
|
96
|
+
...(launch.by !== undefined ? { by: launch.by } : {}),
|
|
97
|
+
...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
|
|
98
|
+
...(launch.directory !== undefined ? { directory: launch.directory } : {}),
|
|
99
|
+
...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
|
|
100
|
+
...(launch.name !== undefined ? { name: launch.name } : {}),
|
|
101
|
+
...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
|
|
102
|
+
...(launch.artifacts !== undefined ? { artifacts: launch.artifacts as LaunchInfo["artifacts"] } : {}),
|
|
103
|
+
history: launch.history,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Who launched, as the launch records it: `user`, `app:<token id>` or the session ID. */
|
|
108
|
+
export function launchedBy(actor: AgentActor): string {
|
|
109
|
+
switch (actor.kind) {
|
|
110
|
+
case "user":
|
|
111
|
+
return "user"
|
|
112
|
+
case "app":
|
|
113
|
+
return `app:${actor.tokenID}`
|
|
114
|
+
case "session":
|
|
115
|
+
return actor.sessionID
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const invalid = (message: string) => new AgentError("invalid", message)
|
|
120
|
+
const notFound = (id: string) => new AgentError("not_found", `No launch ${id}`)
|
|
121
|
+
|
|
122
|
+
async function bodyOf(request: Request): Promise<unknown> {
|
|
123
|
+
return JSON.parse(await request.text())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** ?state=a,b and ?label=k=v (repeated). */
|
|
127
|
+
function filterOf(params: URLSearchParams): LaunchFilter {
|
|
128
|
+
const raw = params.get("state")
|
|
129
|
+
const states = raw === null ? undefined : raw.split(",").filter(Boolean)
|
|
130
|
+
if (states?.some((s) => !(LAUNCH_STATES as readonly string[]).includes(s))) throw invalid(`state must be ${LAUNCH_STATES.join(", ")} (comma-separated)`)
|
|
131
|
+
const labels: Record<string, string> = {}
|
|
132
|
+
for (const label of params.getAll("label")) {
|
|
133
|
+
const at = label.indexOf("=")
|
|
134
|
+
if (at < 1) throw invalid("label must be key=value")
|
|
135
|
+
labels[label.slice(0, at)] = label.slice(at + 1)
|
|
136
|
+
}
|
|
137
|
+
return { ...(states?.length ? { states: states as LaunchState[] } : {}), ...(Object.keys(labels).length ? { labels } : {}) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
141
|
+
const { launches, grants, acting, audit } = deps
|
|
142
|
+
|
|
143
|
+
const party = (userID: string, actor: AgentActor): Party => {
|
|
144
|
+
if (actor.kind !== "session" || !deps.directory) return partyFromActor(actor)
|
|
145
|
+
let name: string | undefined
|
|
146
|
+
try {
|
|
147
|
+
name = deps.directory.resolveOne(userID, actor.sessionID).name
|
|
148
|
+
} catch {}
|
|
149
|
+
return partyFromActor(actor, name ? { name } : {})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The caller's launch: not_found when unknown, another user's, or (apps, sessions) started by someone else. */
|
|
153
|
+
const own = async (userID: string, actor: AgentActor, id: string): Promise<LaunchView> => {
|
|
154
|
+
const launch = await launches.view(userID, id)
|
|
155
|
+
if (!launch || (actor.kind !== "user" && launch.by !== launchedBy(actor))) throw notFound(id)
|
|
156
|
+
return launch
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Runs `run` for the caller in the acting order (agents: kill switch, launch grant on the repo, its action
|
|
161
|
+
* spent unless `spend` is false, kill switch again) and audits the outcome.
|
|
162
|
+
*/
|
|
163
|
+
async function act<T>(
|
|
164
|
+
userID: string,
|
|
165
|
+
actor: AgentActor,
|
|
166
|
+
verb: string,
|
|
167
|
+
repo: string,
|
|
168
|
+
plan: { readonly target: () => AuditTarget; readonly spend: () => Promise<boolean>; readonly run: () => Promise<T> },
|
|
169
|
+
): Promise<T> {
|
|
170
|
+
let grant: Grant | undefined
|
|
171
|
+
const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
|
|
172
|
+
audit.record(userID, { actor: party(userID, actor), verb, target: plan.target(), ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
|
|
173
|
+
try {
|
|
174
|
+
if (actor.kind !== "user") {
|
|
175
|
+
acting.ensureActing(userID)
|
|
176
|
+
const usable = await grants.usable(actor, "launch", { directory: repo })
|
|
177
|
+
if (!usable.length) throw new AgentError("forbidden", `No grant lets this ${actor.kind} launch in ${repo}`)
|
|
178
|
+
grant = usable[0]!
|
|
179
|
+
if (await plan.spend()) await grants.consume(grant)
|
|
180
|
+
acting.ensureActing(userID)
|
|
181
|
+
}
|
|
182
|
+
const result = await plan.run()
|
|
183
|
+
await record("allow", "ok")
|
|
184
|
+
return result
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
const [decision, outcome] = auditOf(cause)
|
|
187
|
+
await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
|
|
188
|
+
throw cause
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
method: "POST",
|
|
195
|
+
path: PATH,
|
|
196
|
+
allow: LAUNCH,
|
|
197
|
+
async handle({ request, actor, user }) {
|
|
198
|
+
const raw = await bodyOf(request)
|
|
199
|
+
const spec = validateLaunchSpec(raw)
|
|
200
|
+
const by = launchedBy(actor)
|
|
201
|
+
const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
|
|
202
|
+
let launchID: string | undefined
|
|
203
|
+
const result = await act(user.id, actor, "launch", spec.workspace.repo, {
|
|
204
|
+
target: () => (launchID !== undefined ? { launchID } : {}),
|
|
205
|
+
// An idempotent repeat spends no grant action.
|
|
206
|
+
spend: async () => held === undefined,
|
|
207
|
+
run: async () => {
|
|
208
|
+
// An agent never gets another launcher's launch through its key.
|
|
209
|
+
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 })
|
|
211
|
+
launchID = accepted.launch.id
|
|
212
|
+
return accepted
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
return Response.json({ launch: launchInfo(result.launch) }, { status: result.created ? 201 : 200, headers: NO_STORE })
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
method: "GET",
|
|
220
|
+
path: PATH,
|
|
221
|
+
allow: LAUNCH,
|
|
222
|
+
async handle({ url, actor, user }) {
|
|
223
|
+
const filter = filterOf(url.searchParams)
|
|
224
|
+
const by = actor.kind === "user" ? undefined : launchedBy(actor)
|
|
225
|
+
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 })
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
method: "GET",
|
|
231
|
+
path: `${PATH}/:id`,
|
|
232
|
+
allow: LAUNCH,
|
|
233
|
+
async handle({ params, actor, user }) {
|
|
234
|
+
return Response.json({ launch: launchInfo(await own(user.id, actor, params.id!)) }, { headers: NO_STORE })
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
method: "DELETE",
|
|
239
|
+
path: `${PATH}/:id`,
|
|
240
|
+
allow: LAUNCH,
|
|
241
|
+
async handle({ params, actor, user }) {
|
|
242
|
+
const launch = await own(user.id, actor, params.id!)
|
|
243
|
+
const stopped = await act(user.id, actor, "launch.stop", launch.spec.workspace.repo, {
|
|
244
|
+
target: () => ({ launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) }),
|
|
245
|
+
spend: async () => true,
|
|
246
|
+
run: () => launches.stop(user.id, launch.id),
|
|
247
|
+
})
|
|
248
|
+
return Response.json({ launch: launchInfo(stopped) }, { headers: NO_STORE })
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
]
|
|
252
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// The launch spec (design §6.1, §6.4; decision znbg8xa5): validates a POST /api/agent/launches body into a
|
|
2
|
+
// LaunchSpec with the defaults filled in. Pure: no state, no I/O; tenancy, grants, caps and placement are the
|
|
3
|
+
// launch engine's. Malformed input is AgentError invalid.
|
|
4
|
+
// - Git-facing fields are strict because the node hands them to git (argv only, never a shell): branch and base
|
|
5
|
+
// follow `git check-ref-format --branch` with no option-like (`-`) component; remotes are https or ssh only
|
|
6
|
+
// (no credentials in the URL, no `ext::`, `file://`, local paths or option-like hosts); repo is an absolute path.
|
|
7
|
+
// - `grant` is not supported in phase 4 (a launched session gets no powers beyond its launcher's).
|
|
8
|
+
// - labels are free-form and never interpreted by the hub, but bounded (LABELS_MAX entries, short keys and values).
|
|
9
|
+
// - Defaults: harness opencode, workspace mode worktree, runtime host, placement mode require, end when idle,
|
|
10
|
+
// cleanup keep, no timeout.
|
|
11
|
+
// LaunchSpec is defined here until packages/protocol carries it (P4-T1); keep the two in step.
|
|
12
|
+
|
|
13
|
+
import { AgentError } from "./errors"
|
|
14
|
+
import { isReservedName, parseAddress, validName } from "./names"
|
|
15
|
+
import { canonicalProject, parseDuration } from "./policy"
|
|
16
|
+
|
|
17
|
+
export type WorkspaceMode = "worktree" | "checkout" | "clone"
|
|
18
|
+
export type RuntimeKind = "host" | "openshell"
|
|
19
|
+
export type PlacementMode = "require" | "prefer"
|
|
20
|
+
export type EndWhen = "idle" | "asked" | "never"
|
|
21
|
+
export type CleanupPolicy = "keep" | "remove"
|
|
22
|
+
|
|
23
|
+
export interface LaunchWorkspace {
|
|
24
|
+
/** Canonical absolute path of the repository on the node. */
|
|
25
|
+
readonly repo: string
|
|
26
|
+
readonly mode: WorkspaceMode
|
|
27
|
+
/** The branch to create or check out (required for worktree). */
|
|
28
|
+
readonly branch?: string
|
|
29
|
+
/** What a new branch starts from. */
|
|
30
|
+
readonly base?: string
|
|
31
|
+
/** https or ssh remote (required for clone). */
|
|
32
|
+
readonly remote?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LaunchRuntime {
|
|
36
|
+
readonly kind: RuntimeKind
|
|
37
|
+
/** An openshell sandbox profile of the user's. */
|
|
38
|
+
readonly profile?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LaunchPlacement {
|
|
42
|
+
/** Node labels to match. */
|
|
43
|
+
readonly labels: Readonly<Record<string, string>>
|
|
44
|
+
/** A machine ID or name. */
|
|
45
|
+
readonly machine?: string
|
|
46
|
+
readonly mode: PlacementMode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LaunchEnd {
|
|
50
|
+
readonly when: EndWhen
|
|
51
|
+
readonly timeoutMs?: number
|
|
52
|
+
readonly cleanup: CleanupPolicy
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LaunchSpec {
|
|
56
|
+
/** App-chosen idempotency key: the same key (and body) returns the same launch. */
|
|
57
|
+
readonly key?: string
|
|
58
|
+
/** The session's @name (without the @). */
|
|
59
|
+
readonly name?: string
|
|
60
|
+
readonly roles: readonly string[]
|
|
61
|
+
readonly harness: string
|
|
62
|
+
readonly agent?: string
|
|
63
|
+
/** `provider/model`. */
|
|
64
|
+
readonly model?: string
|
|
65
|
+
readonly workspace: LaunchWorkspace
|
|
66
|
+
readonly runtime: LaunchRuntime
|
|
67
|
+
readonly placement: LaunchPlacement
|
|
68
|
+
readonly prompt?: string
|
|
69
|
+
/** Free-form app labels (never interpreted by the hub). */
|
|
70
|
+
readonly labels: Readonly<Record<string, string>>
|
|
71
|
+
readonly end: LaunchEnd
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const KEY_MAX = 200
|
|
75
|
+
export const ROLES_MAX = 10
|
|
76
|
+
export const PROMPT_MAX = 10_000
|
|
77
|
+
export const LABELS_MAX = 32
|
|
78
|
+
export const LABEL_KEY_MAX = 63
|
|
79
|
+
export const LABEL_VALUE_MAX = 256
|
|
80
|
+
export const BRANCH_MAX = 200
|
|
81
|
+
export const REMOTE_MAX = 500
|
|
82
|
+
export const MODEL_MAX = 200
|
|
83
|
+
export const LAUNCH_TIMEOUT_MIN_MS = 60_000
|
|
84
|
+
export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
|
|
85
|
+
|
|
86
|
+
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "labels", "end"]
|
|
87
|
+
const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
|
|
88
|
+
const RUNTIME_FIELDS = ["kind", "profile"]
|
|
89
|
+
const PLACEMENT_FIELDS = ["labels", "machine", "mode"]
|
|
90
|
+
const END_FIELDS = ["when", "timeout", "cleanup"]
|
|
91
|
+
|
|
92
|
+
const KEY = /^[!-~]{1,200}$/
|
|
93
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
94
|
+
const AGENT = /^[A-Za-z0-9_.-]{1,80}$/
|
|
95
|
+
const MODEL = /^[A-Za-z0-9._-]{1,80}\/[!-~]+$/
|
|
96
|
+
const MACHINE = /^[A-Za-z0-9._-]{1,80}$/
|
|
97
|
+
const LABEL_KEY = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9])?$/
|
|
98
|
+
/** Control characters (C0, DEL, C1). */
|
|
99
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
|
|
100
|
+
/** Control characters other than tab, newline and carriage return. */
|
|
101
|
+
const PROMPT_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
|
|
102
|
+
|
|
103
|
+
const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
|
|
104
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
105
|
+
|
|
106
|
+
function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
|
|
107
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) throw invalid(`Unknown ${what} field ${key}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
|
|
111
|
+
if (value === undefined) return fallback
|
|
112
|
+
if (!options.includes(value as T)) throw invalid(`${what} is ${options.join(", ")}`)
|
|
113
|
+
return value as T
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function matching(value: unknown, pattern: RegExp, what: string, max = Infinity): string | undefined {
|
|
117
|
+
if (value === undefined) return undefined
|
|
118
|
+
if (typeof value !== "string" || value.length > max || !pattern.test(value)) throw invalid(`${what} is malformed`)
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- git ----------
|
|
123
|
+
|
|
124
|
+
/** A branch name `git check-ref-format --branch` accepts, printable ASCII, with no component starting with `-`. */
|
|
125
|
+
export function validBranch(name: unknown): boolean {
|
|
126
|
+
if (typeof name !== "string" || name.length === 0 || name.length > BRANCH_MAX) return false
|
|
127
|
+
if (!/^[!-~]+$/.test(name) || /[~^:?*[\\]/.test(name)) return false
|
|
128
|
+
if (name === "@" || name === "HEAD" || name.includes("@{") || name.includes("..")) return false
|
|
129
|
+
if (name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.includes("//")) return false
|
|
130
|
+
return name.split("/").every((part) => !part.startsWith(".") && !part.startsWith("-") && !part.endsWith(".lock"))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const HOST = "[A-Za-z0-9][A-Za-z0-9.-]*"
|
|
134
|
+
const USER = "[A-Za-z0-9_][A-Za-z0-9._-]*"
|
|
135
|
+
const PORT = "(?::\\d{1,5})?"
|
|
136
|
+
const REMOTES = [
|
|
137
|
+
new RegExp(`^https://${HOST}${PORT}/[!-~]+$`),
|
|
138
|
+
new RegExp(`^ssh://(?:${USER}@)?${HOST}${PORT}/[!-~]+$`),
|
|
139
|
+
new RegExp(`^${USER}@${HOST}:(?![-/])[!-~]+$`),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
/** An https or ssh git remote (URL or scp-like `user@host:path`), without credentials or option-like parts. */
|
|
143
|
+
export function validRemote(remote: unknown): boolean {
|
|
144
|
+
if (typeof remote !== "string" || remote.length === 0 || remote.length > REMOTE_MAX) return false
|
|
145
|
+
return REMOTES.some((pattern) => pattern.test(remote))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------- parts ----------
|
|
149
|
+
|
|
150
|
+
function validLabels(value: unknown, what: string): Record<string, string> {
|
|
151
|
+
if (value === undefined) return {}
|
|
152
|
+
if (!isObject(value)) throw invalid(`${what} is an object of string values`)
|
|
153
|
+
const entries = Object.entries(value)
|
|
154
|
+
if (entries.length > LABELS_MAX) throw invalid(`${what} has at most ${LABELS_MAX} entries`)
|
|
155
|
+
const out: Record<string, string> = {}
|
|
156
|
+
for (const [key, text] of entries) {
|
|
157
|
+
if (key.length > LABEL_KEY_MAX || !LABEL_KEY.test(key)) throw invalid(`${what}: ${key.slice(0, LABEL_KEY_MAX)} is not a label key (letters, digits, . _ / -; up to ${LABEL_KEY_MAX})`)
|
|
158
|
+
if (typeof text !== "string" || text.length > LABEL_VALUE_MAX || CONTROL.test(text)) throw invalid(`${what}.${key} is a string of up to ${LABEL_VALUE_MAX} characters`)
|
|
159
|
+
out[key] = text
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validRoles(value: unknown): string[] {
|
|
165
|
+
if (value === undefined) return []
|
|
166
|
+
if (!Array.isArray(value) || value.length > ROLES_MAX) throw invalid(`roles lists up to ${ROLES_MAX} role names`)
|
|
167
|
+
const roles: string[] = []
|
|
168
|
+
for (const role of value) {
|
|
169
|
+
if (typeof role !== "string" || parseAddress(`@@${role}`)?.kind !== "role") throw invalid(`roles: ${String(role)} is not a role name (such as reviewer or reviewer.hub)`)
|
|
170
|
+
if (!roles.includes(role)) roles.push(role)
|
|
171
|
+
}
|
|
172
|
+
return roles
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validWorkspace(value: unknown): LaunchWorkspace {
|
|
176
|
+
if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
|
|
177
|
+
onlyFields(value, WORKSPACE_FIELDS, "workspace")
|
|
178
|
+
// canonicalProject rejects relative, `~` and root paths.
|
|
179
|
+
const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
|
|
180
|
+
if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
|
|
181
|
+
const mode = oneOf<WorkspaceMode>(value.mode, ["worktree", "checkout", "clone"], "worktree", "workspace.mode")
|
|
182
|
+
for (const field of ["branch", "base"] as const) {
|
|
183
|
+
if (value[field] !== undefined && !validBranch(value[field])) throw invalid(`workspace.${field} is not a safe git branch name`, "Branch names follow git check-ref-format and never start with -")
|
|
184
|
+
}
|
|
185
|
+
if (value.remote !== undefined && !validRemote(value.remote)) throw invalid("workspace.remote is an https or ssh git remote", "Use https://host/path or git@host:path, without credentials")
|
|
186
|
+
if (mode === "worktree" && value.branch === undefined) throw invalid("A worktree workspace needs workspace.branch")
|
|
187
|
+
if (mode === "clone" && value.remote === undefined) throw invalid("A clone workspace needs workspace.remote")
|
|
188
|
+
return {
|
|
189
|
+
repo,
|
|
190
|
+
mode,
|
|
191
|
+
...(value.branch !== undefined ? { branch: value.branch as string } : {}),
|
|
192
|
+
...(value.base !== undefined ? { base: value.base as string } : {}),
|
|
193
|
+
...(value.remote !== undefined ? { remote: value.remote as string } : {}),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime {
|
|
198
|
+
if (value === undefined) return { kind: "host" }
|
|
199
|
+
if (!isObject(value)) throw invalid("runtime is {kind, profile?}")
|
|
200
|
+
onlyFields(value, RUNTIME_FIELDS, "runtime")
|
|
201
|
+
const kind = oneOf<RuntimeKind>(value.kind, ["host", "openshell"], "host", "runtime.kind")
|
|
202
|
+
const profile = matching(value.profile, SLUG, "runtime.profile")
|
|
203
|
+
if (kind === "host" && profile !== undefined) throw invalid("runtime.profile applies to the openshell runtime only")
|
|
204
|
+
if (kind === "openshell" && workspace.mode !== "clone") throw invalid("The openshell runtime runs clone workspaces only")
|
|
205
|
+
return { kind, ...(profile !== undefined ? { profile } : {}) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validPlacement(value: unknown): LaunchPlacement {
|
|
209
|
+
if (value === undefined) return { labels: {}, mode: "require" }
|
|
210
|
+
if (!isObject(value)) throw invalid("placement is {labels?, machine?, mode?}")
|
|
211
|
+
onlyFields(value, PLACEMENT_FIELDS, "placement")
|
|
212
|
+
const labels = validLabels(value.labels, "placement.labels")
|
|
213
|
+
const machine = matching(value.machine, MACHINE, "placement.machine")
|
|
214
|
+
const mode = oneOf<PlacementMode>(value.mode, ["require", "prefer"], "require", "placement.mode")
|
|
215
|
+
return { labels, ...(machine !== undefined ? { machine } : {}), mode }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validEnd(value: unknown): LaunchEnd {
|
|
219
|
+
if (value === undefined) return { when: "idle", cleanup: "keep" }
|
|
220
|
+
if (!isObject(value)) throw invalid("end is {when?, timeout?, cleanup?}")
|
|
221
|
+
onlyFields(value, END_FIELDS, "end")
|
|
222
|
+
const when = oneOf<EndWhen>(value.when, ["idle", "asked", "never"], "idle", "end.when")
|
|
223
|
+
let timeoutMs: number | undefined
|
|
224
|
+
if (value.timeout !== undefined) {
|
|
225
|
+
timeoutMs = parseDuration(value.timeout)
|
|
226
|
+
if (timeoutMs === undefined || timeoutMs < LAUNCH_TIMEOUT_MIN_MS || timeoutMs > LAUNCH_TIMEOUT_MAX_MS) throw invalid("end.timeout is 1 minute to 30 days (seconds, or 30m, 4h, 2d)")
|
|
227
|
+
}
|
|
228
|
+
const cleanup = oneOf<CleanupPolicy>(value.cleanup, ["keep", "remove"], "keep", "end.cleanup")
|
|
229
|
+
return { when, ...(timeoutMs !== undefined ? { timeoutMs } : {}), cleanup }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------- spec ----------
|
|
233
|
+
|
|
234
|
+
/** Validates a launch body into a LaunchSpec (a fresh copy, defaults filled in); throws AgentError invalid. */
|
|
235
|
+
export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
236
|
+
if (!isObject(input)) throw invalid("A launch is an object")
|
|
237
|
+
if ("grant" in input) throw invalid("grant is not supported yet: a launched session has its launcher's powers only")
|
|
238
|
+
onlyFields(input, FIELDS, "launch")
|
|
239
|
+
const key = matching(input.key, KEY, `key (1–${KEY_MAX} visible characters)`)
|
|
240
|
+
let name: string | undefined
|
|
241
|
+
if (input.name !== undefined) {
|
|
242
|
+
if (typeof input.name !== "string" || !validName(input.name) || isReservedName(input.name)) throw invalid("name is a session name (lowercase letters, digits, dashes; 2–40), without the @")
|
|
243
|
+
name = input.name
|
|
244
|
+
}
|
|
245
|
+
const roles = validRoles(input.roles)
|
|
246
|
+
const harness = matching(input.harness, SLUG, "harness") ?? "opencode"
|
|
247
|
+
const agent = matching(input.agent, AGENT, "agent")
|
|
248
|
+
const model = matching(input.model, MODEL, "model (provider/model)", MODEL_MAX)
|
|
249
|
+
const workspace = validWorkspace(input.workspace)
|
|
250
|
+
const runtime = validRuntime(input.runtime, workspace)
|
|
251
|
+
const placement = validPlacement(input.placement)
|
|
252
|
+
let prompt: string | undefined
|
|
253
|
+
if (input.prompt !== undefined) {
|
|
254
|
+
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
|
+
prompt = input.prompt
|
|
256
|
+
}
|
|
257
|
+
const labels = validLabels(input.labels, "labels")
|
|
258
|
+
const end = validEnd(input.end)
|
|
259
|
+
return {
|
|
260
|
+
...(key !== undefined ? { key } : {}),
|
|
261
|
+
...(name !== undefined ? { name } : {}),
|
|
262
|
+
roles,
|
|
263
|
+
harness,
|
|
264
|
+
...(agent !== undefined ? { agent } : {}),
|
|
265
|
+
...(model !== undefined ? { model } : {}),
|
|
266
|
+
workspace,
|
|
267
|
+
runtime,
|
|
268
|
+
placement,
|
|
269
|
+
...(prompt !== undefined ? { prompt } : {}),
|
|
270
|
+
labels,
|
|
271
|
+
end,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** JSON with object keys sorted (stable for equal values). */
|
|
276
|
+
function stable(value: unknown): string {
|
|
277
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
|
|
278
|
+
if (isObject(value)) {
|
|
279
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort()
|
|
280
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`
|
|
281
|
+
}
|
|
282
|
+
return JSON.stringify(value)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** What idempotency compares: the validated spec without its key, as stable JSON (equal for equivalent bodies). */
|
|
286
|
+
export function launchBodyKey(spec: LaunchSpec): string {
|
|
287
|
+
const { key: _key, ...body } = spec
|
|
288
|
+
return stable(body)
|
|
289
|
+
}
|