@huynle/hub 0.8.0 → 0.8.2
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 +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -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/client.ts +3 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
|
@@ -92,8 +92,36 @@ export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
|
|
|
92
92
|
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
|
|
93
93
|
const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
|
|
94
94
|
const RUNTIME_FIELDS = ["kind", "profile"]
|
|
95
|
-
const PLACEMENT_FIELDS = ["
|
|
95
|
+
const PLACEMENT_FIELDS = ["machine", "labels", "mode"]
|
|
96
96
|
const END_FIELDS = ["when", "timeout", "cleanup"]
|
|
97
|
+
/** The nested sections, in the order suggestions name them. */
|
|
98
|
+
const SECTIONS: readonly (readonly [string, readonly string[]])[] = [
|
|
99
|
+
["workspace", WORKSPACE_FIELDS],
|
|
100
|
+
["placement", PLACEMENT_FIELDS],
|
|
101
|
+
["runtime", RUNTIME_FIELDS],
|
|
102
|
+
["end", END_FIELDS],
|
|
103
|
+
]
|
|
104
|
+
const TOP_FIELDS = FIELDS.filter((field) => !SECTIONS.some(([section]) => section === field))
|
|
105
|
+
/** (FX-03) The hint every unknown-field answer carries: callers often send a flat spec. */
|
|
106
|
+
export const LAUNCH_SPEC_SHAPE = `A launch spec is nested: ${SECTIONS.map(([section, fields]) => `${section}{${fields.join(", ")}}`).join(", ")}; top level: ${TOP_FIELDS.join(", ")}`
|
|
107
|
+
|
|
108
|
+
/** How validateLaunchSpec words its hints for one caller. */
|
|
109
|
+
export interface LaunchSpecOptions {
|
|
110
|
+
/**
|
|
111
|
+
* (R-04) Team staffing builds the spec: it sets launch.key itself and refuses the caller's, so a misplaced `key`
|
|
112
|
+
* points at idempotencyKey (the Idempotency-Key header), not "key (top level)".
|
|
113
|
+
*/
|
|
114
|
+
readonly staffing?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where a misplaced `key` belongs and the shape hint, for one caller. */
|
|
118
|
+
interface Wording {
|
|
119
|
+
readonly keyPlace: string
|
|
120
|
+
readonly shape: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const PLAIN: Wording = { keyPlace: "key (top level)", shape: LAUNCH_SPEC_SHAPE }
|
|
124
|
+
const STAFFING: Wording = { keyPlace: "idempotencyKey (the Idempotency-Key header)", shape: `${LAUNCH_SPEC_SHAPE}; key is set by staffing: send idempotencyKey (the Idempotency-Key header) instead` }
|
|
97
125
|
|
|
98
126
|
const KEY = /^[!-~]{1,200}$/
|
|
99
127
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
@@ -109,8 +137,22 @@ const PROMPT_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
|
|
|
109
137
|
const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
|
|
110
138
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
111
139
|
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
/** Where an unknown `key` at level `what` (launch, or a section) belongs instead: `section.key`s, or the top level. */
|
|
141
|
+
function placesOf(key: string, what: string, wording: Wording): string[] {
|
|
142
|
+
const places = SECTIONS.filter(([section, fields]) => section !== what && fields.includes(key)).map(([section]) => `${section}.${key}`)
|
|
143
|
+
if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(key === "key" ? wording.keyPlace : `${key} (top level)`)
|
|
144
|
+
return places
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Refuses every field outside `allowed` at once, naming where a misplaced one belongs (FX-03). */
|
|
148
|
+
function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string, wording: Wording = PLAIN): void {
|
|
149
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key))
|
|
150
|
+
if (unknown.length === 0) return
|
|
151
|
+
const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what, wording)).filter((places) => places.length > 0).map((places) => places.join(" or "))
|
|
152
|
+
// Keys are the caller's: bounded in number and length.
|
|
153
|
+
const shown = [...unknown.slice(0, 10).map((key) => key.slice(0, 40)), ...(unknown.length > 10 ? ["…"] : [])]
|
|
154
|
+
const message = `Unknown ${what} field${unknown.length > 1 ? "s" : ""} ${shown.join(", ")}${suggestions.length ? `: did you mean ${suggestions.join(", ")}?` : ""}`
|
|
155
|
+
throw invalid(message, wording.shape)
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
|
|
@@ -178,9 +220,9 @@ function validRoles(value: unknown): string[] {
|
|
|
178
220
|
return roles
|
|
179
221
|
}
|
|
180
222
|
|
|
181
|
-
function validWorkspace(value: unknown): LaunchWorkspace {
|
|
223
|
+
function validWorkspace(value: unknown, wording: Wording): LaunchWorkspace {
|
|
182
224
|
if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
|
|
183
|
-
onlyFields(value, WORKSPACE_FIELDS, "workspace")
|
|
225
|
+
onlyFields(value, WORKSPACE_FIELDS, "workspace", wording)
|
|
184
226
|
// canonicalProject rejects relative, `~` and root paths.
|
|
185
227
|
const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
|
|
186
228
|
if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
|
|
@@ -200,10 +242,10 @@ function validWorkspace(value: unknown): LaunchWorkspace {
|
|
|
200
242
|
}
|
|
201
243
|
}
|
|
202
244
|
|
|
203
|
-
function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime {
|
|
245
|
+
function validRuntime(value: unknown, workspace: LaunchWorkspace, wording: Wording): LaunchRuntime {
|
|
204
246
|
if (value === undefined) return { kind: "host" }
|
|
205
247
|
if (!isObject(value)) throw invalid("runtime is {kind, profile?}")
|
|
206
|
-
onlyFields(value, RUNTIME_FIELDS, "runtime")
|
|
248
|
+
onlyFields(value, RUNTIME_FIELDS, "runtime", wording)
|
|
207
249
|
const kind = oneOf<RuntimeKind>(value.kind, ["host", "openshell"], "host", "runtime.kind")
|
|
208
250
|
const profile = matching(value.profile, SLUG, "runtime.profile")
|
|
209
251
|
if (kind === "host" && profile !== undefined) throw invalid("runtime.profile applies to the openshell runtime only")
|
|
@@ -211,20 +253,20 @@ function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime
|
|
|
211
253
|
return { kind, ...(profile !== undefined ? { profile } : {}) }
|
|
212
254
|
}
|
|
213
255
|
|
|
214
|
-
function validPlacement(value: unknown): LaunchPlacement {
|
|
256
|
+
function validPlacement(value: unknown, wording: Wording): LaunchPlacement {
|
|
215
257
|
if (value === undefined) return { labels: {}, mode: "require" }
|
|
216
258
|
if (!isObject(value)) throw invalid("placement is {labels?, machine?, mode?}")
|
|
217
|
-
onlyFields(value, PLACEMENT_FIELDS, "placement")
|
|
259
|
+
onlyFields(value, PLACEMENT_FIELDS, "placement", wording)
|
|
218
260
|
const labels = validLabels(value.labels, "placement.labels")
|
|
219
261
|
const machine = matching(value.machine, MACHINE, "placement.machine")
|
|
220
262
|
const mode = oneOf<PlacementMode>(value.mode, ["require", "prefer"], "require", "placement.mode")
|
|
221
263
|
return { labels, ...(machine !== undefined ? { machine } : {}), mode }
|
|
222
264
|
}
|
|
223
265
|
|
|
224
|
-
function validEnd(value: unknown): LaunchEnd {
|
|
266
|
+
function validEnd(value: unknown, wording: Wording): LaunchEnd {
|
|
225
267
|
if (value === undefined) return { when: "idle", cleanup: "keep" }
|
|
226
268
|
if (!isObject(value)) throw invalid("end is {when?, timeout?, cleanup?}")
|
|
227
|
-
onlyFields(value, END_FIELDS, "end")
|
|
269
|
+
onlyFields(value, END_FIELDS, "end", wording)
|
|
228
270
|
const when = oneOf<EndWhen>(value.when, ["idle", "asked", "never"], "idle", "end.when")
|
|
229
271
|
let timeoutMs: number | undefined
|
|
230
272
|
if (value.timeout !== undefined) {
|
|
@@ -238,10 +280,11 @@ function validEnd(value: unknown): LaunchEnd {
|
|
|
238
280
|
// ---------- spec ----------
|
|
239
281
|
|
|
240
282
|
/** Validates a launch body into a LaunchSpec (a fresh copy, defaults filled in); throws AgentError invalid. */
|
|
241
|
-
export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
283
|
+
export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions = {}): LaunchSpec {
|
|
284
|
+
const wording = options.staffing ? STAFFING : PLAIN
|
|
242
285
|
if (!isObject(input)) throw invalid("A launch is an object")
|
|
243
286
|
if ("grant" in input) throw invalid("grant is not supported yet: a launched session has its launcher's powers only")
|
|
244
|
-
onlyFields(input, FIELDS, "launch")
|
|
287
|
+
onlyFields(input, FIELDS, "launch", wording)
|
|
245
288
|
const key = matching(input.key, KEY, `key (1–${KEY_MAX} visible characters)`)
|
|
246
289
|
let name: string | undefined
|
|
247
290
|
if (input.name !== undefined) {
|
|
@@ -252,9 +295,9 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
|
252
295
|
const harness = matching(input.harness, SLUG, "harness") ?? "opencode"
|
|
253
296
|
const agent = matching(input.agent, AGENT, "agent")
|
|
254
297
|
const model = matching(input.model, MODEL, "model (provider/model)", MODEL_MAX)
|
|
255
|
-
const workspace = validWorkspace(input.workspace)
|
|
256
|
-
const runtime = validRuntime(input.runtime, workspace)
|
|
257
|
-
const placement = validPlacement(input.placement)
|
|
298
|
+
const workspace = validWorkspace(input.workspace, wording)
|
|
299
|
+
const runtime = validRuntime(input.runtime, workspace, wording)
|
|
300
|
+
const placement = validPlacement(input.placement, wording)
|
|
258
301
|
let prompt: string | undefined
|
|
259
302
|
if (input.prompt !== undefined) {
|
|
260
303
|
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`)
|
|
@@ -263,7 +306,7 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
|
263
306
|
// (CON-02) What the session is for: untrusted, sanitised to one line, at most INTENT_MAX characters.
|
|
264
307
|
const intent = input.intent === undefined ? undefined : intentText(input.intent)
|
|
265
308
|
const labels = validLabels(input.labels, "labels")
|
|
266
|
-
const end = validEnd(input.end)
|
|
309
|
+
const end = validEnd(input.end, wording)
|
|
267
310
|
const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
|
|
268
311
|
if (profileProblem) throw invalid(profileProblem)
|
|
269
312
|
const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
|
|
12
12
|
// ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
|
|
13
13
|
// (its creator crashed) blocks the key for RESERVATION_MS, then is taken over.
|
|
14
|
+
// - Waiting (FX-02): a queued launch carries why it waits (`waiting`, from placement): setWaiting writes it only
|
|
15
|
+
// while the launch is queued and only when it changed (no history step; `since` kept while the wait kind is the
|
|
16
|
+
// same), and mirrors it into `reason`; every transition clears it.
|
|
14
17
|
// - Pruning: ended launches older than endedKeepMs, and all but the newest endedMax ended ones, are removed and
|
|
15
18
|
// their keys released. Live launches are never pruned.
|
|
16
19
|
import { randomBytes } from "node:crypto"
|
|
@@ -26,6 +29,8 @@ export const ENDED_KEEP_MS = 7 * 86_400_000
|
|
|
26
29
|
export const ENDED_MAX = 500
|
|
27
30
|
/** How long a key claim without its launch (a creator that may still be writing) blocks the key. */
|
|
28
31
|
export const RESERVATION_MS = 60_000
|
|
32
|
+
/** The longest launcher label kept on a launch. */
|
|
33
|
+
const LABEL_MAX = 100
|
|
29
34
|
/** Reads of a key holder's launch: its creator may be between the claim and the write, so a miss is re-read. */
|
|
30
35
|
const READS = 5
|
|
31
36
|
/** Claim attempts (a stale claim is taken over between them). */
|
|
@@ -45,6 +50,21 @@ const NEXT: Record<LaunchState, readonly LaunchState[]> = {
|
|
|
45
50
|
ended: [],
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** Why a queued launch waits (placement's Waiting without the launch ID). */
|
|
54
|
+
export interface LaunchWaitReason {
|
|
55
|
+
/** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
|
|
56
|
+
readonly wait: "capacity" | "no_match" | "active"
|
|
57
|
+
/** The full text, for the user (names other launchers and machines). */
|
|
58
|
+
readonly reason: string
|
|
59
|
+
/** The text for the launch's own launcher (an app or session) when it differs: counts only, no other launchers or machine names. */
|
|
60
|
+
readonly shared?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A queued launch's wait, since it first waited for this kind of reason. */
|
|
64
|
+
export interface LaunchWaiting extends LaunchWaitReason {
|
|
65
|
+
readonly since: number
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
export interface LaunchStep {
|
|
49
69
|
readonly state: LaunchState
|
|
50
70
|
readonly reason: string
|
|
@@ -59,6 +79,8 @@ export interface Launch {
|
|
|
59
79
|
readonly spec: LaunchSpec
|
|
60
80
|
/** Who launched it, as the caller describes them (e.g. `app:<token id>`, `ses_…`, `user`). */
|
|
61
81
|
readonly by?: string
|
|
82
|
+
/** The launcher's label when it launched (an app token's label, a session's @name); never a secret. */
|
|
83
|
+
readonly byLabel?: string
|
|
62
84
|
readonly state: LaunchState
|
|
63
85
|
readonly reason: string
|
|
64
86
|
/** When it entered its state. */
|
|
@@ -68,6 +90,8 @@ export interface Launch {
|
|
|
68
90
|
readonly sessionID?: string
|
|
69
91
|
readonly name?: string
|
|
70
92
|
readonly artifacts?: Readonly<Record<string, unknown>>
|
|
93
|
+
/** Only while queued: why it waits (placement). */
|
|
94
|
+
readonly waiting?: LaunchWaiting
|
|
71
95
|
readonly created: number
|
|
72
96
|
readonly history: readonly LaunchStep[]
|
|
73
97
|
}
|
|
@@ -138,7 +162,7 @@ export class LaunchStore {
|
|
|
138
162
|
* Stores a new queued launch, or (same key, equivalent body) returns the existing one with created false.
|
|
139
163
|
* Throws invalid (key reused with another body) or rate_limited (the key's launch is still being created).
|
|
140
164
|
*/
|
|
141
|
-
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
165
|
+
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string; readonly byLabel?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
142
166
|
const id = this.newID()
|
|
143
167
|
if (spec.key !== undefined) {
|
|
144
168
|
const existing = await this.claimKey(userID, spec.key, id)
|
|
@@ -157,6 +181,7 @@ export class LaunchStore {
|
|
|
157
181
|
owner: userID,
|
|
158
182
|
spec,
|
|
159
183
|
...(options.by !== undefined ? { by: options.by } : {}),
|
|
184
|
+
...(options.byLabel ? { byLabel: cleanReason(options.byLabel).slice(0, LABEL_MAX) } : {}),
|
|
160
185
|
state: "queued",
|
|
161
186
|
reason: "Queued",
|
|
162
187
|
at,
|
|
@@ -190,8 +215,9 @@ export class LaunchStore {
|
|
|
190
215
|
if (!NEXT[current.state].includes(to) || (input.from && !input.from.includes(current.state))) throw new LaunchTransitionError(current.state, to)
|
|
191
216
|
checkDetails(to, input)
|
|
192
217
|
const step: LaunchStep = { state: to, reason, at, ...(input.outcome !== undefined ? { outcome: input.outcome } : {}) }
|
|
218
|
+
const { waiting: _cleared, ...rest } = current
|
|
193
219
|
return {
|
|
194
|
-
...
|
|
220
|
+
...rest,
|
|
195
221
|
state: to,
|
|
196
222
|
reason,
|
|
197
223
|
at,
|
|
@@ -206,6 +232,33 @@ export class LaunchStore {
|
|
|
206
232
|
return next!
|
|
207
233
|
}
|
|
208
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Records why a queued launch waits (undefined: it no longer waits). Writes nothing (resolves undefined) when the
|
|
237
|
+
* launch is not queued or the wait and reason are unchanged; else resolves with the launch as written. The
|
|
238
|
+
* reason shows as the launch's reason; `since` stays while the wait kind stays the same.
|
|
239
|
+
*/
|
|
240
|
+
async setWaiting(userID: string, id: string, waiting: LaunchWaitReason | undefined): Promise<Launch | undefined> {
|
|
241
|
+
const reason = waiting ? cleanReason(waiting.reason) : undefined
|
|
242
|
+
const shared = waiting?.shared !== undefined ? cleanReason(waiting.shared) : undefined
|
|
243
|
+
let written = false
|
|
244
|
+
const next = await updateVersioned<Launch>(this.deps.state, userID, LAUNCHES, id, (current) => {
|
|
245
|
+
written = false
|
|
246
|
+
if (!current || current.state !== "queued") return undefined
|
|
247
|
+
const was = current.waiting
|
|
248
|
+
if (!waiting) {
|
|
249
|
+
if (!was) return undefined
|
|
250
|
+
const { waiting: _cleared, ...rest } = current
|
|
251
|
+
written = true
|
|
252
|
+
return { ...rest, reason: "Queued" }
|
|
253
|
+
}
|
|
254
|
+
if (was && was.wait === waiting.wait && was.reason === reason && was.shared === shared) return undefined
|
|
255
|
+
written = true
|
|
256
|
+
const since = was?.wait === waiting.wait ? was.since : this.now()
|
|
257
|
+
return { ...current, reason: reason!, waiting: { wait: waiting.wait, reason: reason!, ...(shared !== undefined ? { shared } : {}), since } }
|
|
258
|
+
})
|
|
259
|
+
return written ? next : undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
209
262
|
/** Removes ended launches past endedKeepMs or beyond the newest endedMax; resolves with the removed ids. */
|
|
210
263
|
async prune(userID: string): Promise<string[]> {
|
|
211
264
|
const { state } = this.deps
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
// Loop protection for agent messages (design §3.4), over the backend interfaces with an injected clock.
|
|
2
2
|
//
|
|
3
|
-
// - HopTracker: every message carries a hop. A session's
|
|
4
|
-
//
|
|
5
|
-
// human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and
|
|
6
|
-
// is alerted (the delivery engine does both).
|
|
3
|
+
// - HopTracker: every message carries a hop. A session's depth is the deepest hop that reached it since its last
|
|
4
|
+
// human turn; what it sends (msg, ask, reply, decline) carries depth + 1. The user sends hop 0, and a hop-0
|
|
5
|
+
// message (or a human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and
|
|
6
|
+
// the user is alerted (the delivery engine does both).
|
|
7
|
+
// - Apps (app:<tokenID>, including the hub's own app:concierge, the workspace coordinator and team intake) keep NO
|
|
8
|
+
// depth (S1, bug wge0vjdz; decision in Brain, tag fixup-0.8.1): one token serves many unrelated conversations and
|
|
9
|
+
// has no human turn to reset it, so a shared depth grew by 2 per ask/reply round trip and silenced the app for
|
|
10
|
+
// 24 h after 4 of them. Instead the hop is per conversation: a NEW msg or ask from an app is hop 1, a reply or
|
|
11
|
+
// decline from an app is the hop of the message it answers + 1, and messages to an app raise nothing. Reply
|
|
12
|
+
// ping-pong through an app still counts up (the session side keeps its depth) and hits HOP_CAP. Session-to-session
|
|
13
|
+
// behaviour is unchanged.
|
|
14
|
+
// - Relay depth (0.8.1 verify finding 1): so an app cannot launder a chain by relaying between sessions as NEW
|
|
15
|
+
// messages (A → app → B → app → A, each hop 1), an app remembers, per (user, app) and per sending session, the
|
|
16
|
+
// deepest hop it RECEIVED from a session in the last RELAY_WINDOW_MS (5 min; refreshed by each message, then it
|
|
17
|
+
// fades). Its new msg/ask carries that relay depth + 1 (else 1), counting only what OTHER sessions sent it: what
|
|
18
|
+
// the recipient itself sent is the same conversation, not a relay, so a one-to-one app conversation stays at hop 1
|
|
19
|
+
// at any pace (the S1 case; an app answering one session with new messages forever is held by the pair limit and
|
|
20
|
+
// the recipient's wake budget instead). A relay loop through an app thus reaches HOP_CAP after ~8 hops. Only
|
|
21
|
+
// session senders count (the user's hop 0 and other apps do not), and the hub's own parties (HUB_APP_TOKENS:
|
|
22
|
+
// app:concierge, the workspace coordinator, team intake) keep none: they send on events and submissions, not on
|
|
23
|
+
// the messages they receive (team intake carries the submitter's depth + 1 itself, SendOptions.chainHop). It is
|
|
24
|
+
// not a persistent depth: messages to an app still raise nothing beyond this time-boxed memory.
|
|
7
25
|
// - MessageLimiter: fixed windows aligned to the clock (so retryAfter is exact): messages per sender→recipient
|
|
8
26
|
// pair and per user (refused with rate_limited), and wakes per recipient (exhausted: deliver without waking).
|
|
9
27
|
import type { Clock, Counters, StateStore } from "../backend/types"
|
|
@@ -18,26 +36,67 @@ export const HOP_TTL_MS = 24 * 60 * 60_000
|
|
|
18
36
|
|
|
19
37
|
export const overHopCap = (hop: number): boolean => hop > HOP_CAP
|
|
20
38
|
|
|
39
|
+
/** How long what an app received from a session raises the app's new messages (relay depth). */
|
|
40
|
+
export const RELAY_WINDOW_MS = 5 * 60_000
|
|
41
|
+
/** Sending sessions remembered per (user, app); the oldest go first. */
|
|
42
|
+
export const RELAY_SOURCES_MAX = 32
|
|
43
|
+
/** The hub's own app parties (app:<tokenID>): no relay depth (see the header). */
|
|
44
|
+
export const HUB_APP_TOKENS: ReadonlySet<string> = new Set(["concierge", "workspace-coordinator", "team-intake"])
|
|
45
|
+
|
|
21
46
|
/** StateStore collection: partyKey → HopDepth. */
|
|
22
47
|
const HOPS = "message-hops"
|
|
48
|
+
/** StateStore collection: partyKey of an app → RelayDepth. */
|
|
49
|
+
const RELAYS = "message-relays"
|
|
23
50
|
|
|
24
51
|
interface HopDepth {
|
|
25
52
|
readonly depth: number
|
|
26
53
|
readonly at: number
|
|
27
54
|
}
|
|
28
55
|
|
|
56
|
+
interface RelayDepth {
|
|
57
|
+
/** Sending session → the deepest hop it sent the app, and when it last sent one. */
|
|
58
|
+
readonly sources: Readonly<Record<string, HopDepth>>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a message continues: the message a reply or decline answers, and its recipient. */
|
|
62
|
+
export interface HopContext {
|
|
63
|
+
readonly answering?: { readonly hop: number }
|
|
64
|
+
readonly to?: Party
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const relays = (party: Party): party is Extract<Party, { kind: "app" }> => party.kind === "app" && !HUB_APP_TOKENS.has(party.tokenID)
|
|
68
|
+
|
|
29
69
|
export class HopTracker {
|
|
30
70
|
private readonly state: StateStore
|
|
31
71
|
private readonly now: Clock
|
|
72
|
+
private readonly relayWindowMs: number
|
|
32
73
|
|
|
33
|
-
constructor(deps: { readonly state: StateStore; readonly now?: Clock }) {
|
|
74
|
+
constructor(deps: { readonly state: StateStore; readonly now?: Clock; readonly relayWindowMs?: number }) {
|
|
34
75
|
this.state = deps.state
|
|
35
76
|
this.now = deps.now ?? Date.now
|
|
77
|
+
this.relayWindowMs = deps.relayWindowMs ?? RELAY_WINDOW_MS
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The hop a message from `from` carries. The user: 0. An app: a reply or decline (`answering`) is the answered
|
|
82
|
+
* message's hop + 1; a new msg or ask is its relay depth toward `to` + 1 (see the header). A session: its depth + 1.
|
|
83
|
+
*/
|
|
84
|
+
async next(userID: string, from: Party, context: HopContext = {}): Promise<number> {
|
|
85
|
+
if (from.kind === "user") return 0
|
|
86
|
+
if (from.kind === "app") return (context.answering ? context.answering.hop : await this.relayDepth(userID, from, context.to)) + 1
|
|
87
|
+
return (await this.depth(userID, from)) + 1
|
|
36
88
|
}
|
|
37
89
|
|
|
38
|
-
/** The hop
|
|
39
|
-
async
|
|
40
|
-
|
|
90
|
+
/** The deepest hop sessions other than `to` sent the app within the relay window (0: none; hub parties: 0). */
|
|
91
|
+
async relayDepth(userID: string, app: Party, to?: Party): Promise<number> {
|
|
92
|
+
if (!relays(app)) return 0
|
|
93
|
+
const entry = (await this.state.get<RelayDepth>(userID, RELAYS, partyKey(app)))?.value
|
|
94
|
+
const skip = to?.kind === "session" ? to.sessionID : undefined
|
|
95
|
+
let deepest = 0
|
|
96
|
+
for (const [sessionID, source] of Object.entries(entry?.sources ?? {})) {
|
|
97
|
+
if (sessionID !== skip && this.relayLive(source)) deepest = Math.max(deepest, source.depth)
|
|
98
|
+
}
|
|
99
|
+
return deepest
|
|
41
100
|
}
|
|
42
101
|
|
|
43
102
|
/** The deepest hop that reached the party since its last human turn (0: none, or forgotten). */
|
|
@@ -45,9 +104,13 @@ export class HopTracker {
|
|
|
45
104
|
return this.live((await this.state.get<HopDepth>(userID, HOPS, partyKey(party)))?.value)
|
|
46
105
|
}
|
|
47
106
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
107
|
+
/**
|
|
108
|
+
* A message with `hop` from `from` reached the party. Sessions keep a depth; an app (not a hub party) only
|
|
109
|
+
* remembers, for RELAY_WINDOW_MS, what each session sent it (relay depth); the user keeps nothing.
|
|
110
|
+
*/
|
|
111
|
+
async received(userID: string, to: Party, hop: number, from?: Party): Promise<void> {
|
|
112
|
+
if (relays(to) && from?.kind === "session") return this.relayed(userID, to, from.sessionID, hop)
|
|
113
|
+
if (to.kind !== "session") return
|
|
51
114
|
const at = this.now()
|
|
52
115
|
await updateVersioned<HopDepth>(this.state, userID, HOPS, partyKey(to), (current) => ({ depth: hop === 0 ? 0 : Math.max(this.live(current), hop), at }))
|
|
53
116
|
}
|
|
@@ -57,6 +120,21 @@ export class HopTracker {
|
|
|
57
120
|
await this.state.delete(userID, HOPS, partyKey({ kind: "session", sessionID }))
|
|
58
121
|
}
|
|
59
122
|
|
|
123
|
+
private async relayed(userID: string, app: Party, sessionID: string, hop: number): Promise<void> {
|
|
124
|
+
const at = this.now()
|
|
125
|
+
await updateVersioned<RelayDepth>(this.state, userID, RELAYS, partyKey(app), (current) => {
|
|
126
|
+
const live = Object.entries(current?.sources ?? {}).filter(([id, source]) => id !== sessionID && this.relayLive(source))
|
|
127
|
+
const previous = current?.sources[sessionID]
|
|
128
|
+
const depth = Math.max(previous && this.relayLive(previous) ? previous.depth : 0, hop)
|
|
129
|
+
const kept = live.sort((a, b) => b[1].at - a[1].at).slice(0, RELAY_SOURCES_MAX - 1)
|
|
130
|
+
return { sources: Object.fromEntries([[sessionID, { depth, at }], ...kept]) }
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private relayLive(source: HopDepth): boolean {
|
|
135
|
+
return this.now() - source.at < this.relayWindowMs
|
|
136
|
+
}
|
|
137
|
+
|
|
60
138
|
private live(entry: HopDepth | undefined): number {
|
|
61
139
|
return entry && this.now() - entry.at < HOP_TTL_MS ? entry.depth : 0
|
|
62
140
|
}
|
|
@@ -154,7 +154,13 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
|
|
|
154
154
|
return out as Omit<AskInput, "question" | "about" | "expiresInMs"> & { urgency?: Urgency; tier?: Tier; about?: string[] }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
/** The stored message, how it was delivered and, for a session recipient, whether it woke it (and why not). */
|
|
158
|
+
const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
|
|
159
|
+
message,
|
|
160
|
+
...(delivery ? { delivery } : {}),
|
|
161
|
+
...(woke === undefined ? {} : { woke }),
|
|
162
|
+
...(wakeSkipped ? { wakeSkipped } : {}),
|
|
163
|
+
})
|
|
158
164
|
|
|
159
165
|
export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
160
166
|
const { delivery, feed } = deps
|
|
@@ -270,8 +276,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
270
276
|
if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
|
|
271
277
|
const sent = await send(user.id, actor, to, "ask", text, options, all)
|
|
272
278
|
if (all) return ok({ sent: sent.map(sentView) }, 201)
|
|
273
|
-
|
|
274
|
-
return ok({ ...sentView({ message, ...(how ? { delivery: how } : {}) }), ...(await settled(user.id, message, wait, request.signal)) }, 201)
|
|
279
|
+
return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
|
|
275
280
|
},
|
|
276
281
|
},
|
|
277
282
|
{
|
|
@@ -42,9 +42,10 @@ export type MessageKind = "msg" | "ask" | "reply" | "decline"
|
|
|
42
42
|
export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
|
|
43
43
|
/**
|
|
44
44
|
* How a message reached its recipient (design §3.3), recorded by the delivery engine. (CON-10) "dropped": it was
|
|
45
|
-
* pending for a session that ended for good (a retired Concierge session) and will never be delivered.
|
|
45
|
+
* pending for a session that ended for good (a retired Concierge session) and will never be delivered. "expired": it
|
|
46
|
+
* was pending for PENDING_MS (its recipient stayed offline or busy) and will never be delivered; its sender was told.
|
|
46
47
|
*/
|
|
47
|
-
export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped"
|
|
48
|
+
export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
|
|
48
49
|
|
|
49
50
|
export interface Message {
|
|
50
51
|
readonly id: string
|