@huynle/hub 0.8.6 → 0.9.0
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 +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -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 +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -285,6 +285,16 @@ export interface MsgOptions extends CallOptions {
|
|
|
285
285
|
readonly wake?: boolean
|
|
286
286
|
/** Steer into a running turn. */
|
|
287
287
|
readonly urgent?: boolean
|
|
288
|
+
/**
|
|
289
|
+
* Idempotency-Key (per sender): a retry with the same key and arguments within 24 h answers the first result again
|
|
290
|
+
* and sends nothing; other arguments under the key are HubError conflict.
|
|
291
|
+
*/
|
|
292
|
+
readonly idempotencyKey?: string
|
|
293
|
+
/**
|
|
294
|
+
* Never deliver after this time (epoch ms; at most 30 days ahead). Already passed: the hub answers 422 expired and
|
|
295
|
+
* sends nothing. A message pending for an offline session expires then; an ask expires by then at the latest.
|
|
296
|
+
*/
|
|
297
|
+
readonly notAfter?: number
|
|
288
298
|
}
|
|
289
299
|
|
|
290
300
|
export interface AskOptions extends MsgOptions {
|
|
@@ -338,7 +348,11 @@ const whyField = (options: ActOptions) => (options.why !== undefined ? { why: op
|
|
|
338
348
|
const signalOf = (options: CallOptions) => (options.signal ? { signal: options.signal } : {})
|
|
339
349
|
const idempotent = (options: IdempotentOptions) => ({ ...signalOf(options), ...(options.idempotencyKey ? { headers: { "idempotency-key": options.idempotencyKey } } : {}) })
|
|
340
350
|
/** The wake and urgent fields of a send, when given. */
|
|
341
|
-
const sendFields = (options: MsgOptions) => ({
|
|
351
|
+
const sendFields = (options: MsgOptions) => ({
|
|
352
|
+
...(options.wake !== undefined ? { wake: options.wake } : {}),
|
|
353
|
+
...(options.urgent !== undefined ? { urgent: options.urgent } : {}),
|
|
354
|
+
...(options.notAfter !== undefined ? { notAfter: options.notAfter } : {}),
|
|
355
|
+
})
|
|
342
356
|
/** The session and project fields of a role request, when given. */
|
|
343
357
|
const roleTarget = (options: RoleOptions) => ({ ...(options.session ? { session: options.session } : {}), ...(options.project !== undefined ? { project: options.project } : {}) })
|
|
344
358
|
|
|
@@ -607,7 +621,7 @@ export class HubClient {
|
|
|
607
621
|
/** To every session the address names (each holder of a role). */
|
|
608
622
|
msg(to: string, text: string, options: MsgOptions & { readonly all: true }): Promise<SentAll>
|
|
609
623
|
msg(to: string, text: string, options: MsgOptions & { readonly all?: boolean } = {}): Promise<Sent | SentAll> {
|
|
610
|
-
return this.call("POST", `${AGENT}/msg`, { body: { to, text, ...sendFields(options), ...(options.all ? { all: true } : {}) }, ...
|
|
624
|
+
return this.call("POST", `${AGENT}/msg`, { body: { to, text, ...sendFields(options), ...(options.all ? { all: true } : {}) }, ...idempotent(options) })
|
|
611
625
|
}
|
|
612
626
|
|
|
613
627
|
/** Asks a session; with wait, polls (≤WAIT_POLL_MAX_S each) until it is answered, declined or expired, or the wait is spent. */
|
|
@@ -628,7 +642,7 @@ export class HubClient {
|
|
|
628
642
|
...(options.wait !== undefined ? { wait: first } : {}),
|
|
629
643
|
...decisionFields(options),
|
|
630
644
|
}
|
|
631
|
-
const asked = await this.call<Asked | SentAll>("POST", `${AGENT}/ask`, { body, ...
|
|
645
|
+
const asked = await this.call<Asked | SentAll>("POST", `${AGENT}/ask`, { body, ...idempotent(options) })
|
|
632
646
|
if ("sent" in asked) return asked
|
|
633
647
|
let current: Asked = asked
|
|
634
648
|
while (current.message.state === "open" && left > 0 && !options.signal?.aborted) {
|
|
@@ -30,6 +30,8 @@ export interface TeamIntake {
|
|
|
30
30
|
readonly labels?: readonly string[]
|
|
31
31
|
/** Epoch ms. */
|
|
32
32
|
readonly due?: number
|
|
33
|
+
/** Not delivered after this (epoch ms): still queued then → cancelled with reason "not_after". */
|
|
34
|
+
readonly notAfter?: number
|
|
33
35
|
readonly criteria?: readonly string[]
|
|
34
36
|
readonly workspaceID?: string
|
|
35
37
|
readonly sessionID?: string
|
|
@@ -50,6 +52,12 @@ export interface TeamIntakeSubmit {
|
|
|
50
52
|
readonly labels?: readonly string[]
|
|
51
53
|
/** Epoch ms. */
|
|
52
54
|
readonly due?: number
|
|
55
|
+
/**
|
|
56
|
+
* Never deliver after this (epoch ms; at most 30 days ahead). Already passed: 422 expired, nothing submitted. Still
|
|
57
|
+
* queued (the entry role vacant) when it passes: cancelled, reason "not_after". Once routed, the delivered message
|
|
58
|
+
* carries it.
|
|
59
|
+
*/
|
|
60
|
+
readonly notAfter?: number
|
|
53
61
|
readonly criteria?: readonly string[]
|
|
54
62
|
/** An external reference (e.g. a Brain task ID). */
|
|
55
63
|
readonly ref?: string
|
|
@@ -271,9 +271,12 @@ export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
|
|
|
271
271
|
/** How a message reached its recipient session. */
|
|
272
272
|
/**
|
|
273
273
|
* (CON-10) "dropped": it was pending for a session that ended for good (a retired Concierge session); never delivered.
|
|
274
|
-
* "expired": it was pending for 24 h (its recipient stayed offline or busy); never delivered, and
|
|
274
|
+
* "expired": it was pending for 24 h or past its notAfter (its recipient stayed offline or busy); never delivered, and
|
|
275
|
+
* its sender was told.
|
|
275
276
|
*/
|
|
276
277
|
export type MessageDelivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
|
|
278
|
+
/** Why a pending message expired undelivered (also the message.changed event's data.reason). */
|
|
279
|
+
export type MessageExpiredReason = "not_after" | "pending_timeout"
|
|
277
280
|
|
|
278
281
|
/** A stored agent message. */
|
|
279
282
|
export interface AgentMessage {
|
|
@@ -293,7 +296,11 @@ export interface AgentMessage {
|
|
|
293
296
|
readonly at: number
|
|
294
297
|
readonly hop: number
|
|
295
298
|
readonly expiresAt?: number
|
|
299
|
+
/** Not delivered after this (epoch ms), when the sender set notAfter. */
|
|
300
|
+
readonly notAfter?: number
|
|
296
301
|
readonly delivery?: MessageDelivery
|
|
302
|
+
/** delivery "expired" only: its notAfter passed (not_after), or the 24 h pending hold ran out (pending_timeout). */
|
|
303
|
+
readonly expiredReason?: MessageExpiredReason
|
|
297
304
|
readonly deliveredAt?: number
|
|
298
305
|
/** The reply or decline that closed an ask. */
|
|
299
306
|
readonly answer?: { readonly id: string; readonly at: number }
|
|
@@ -503,7 +510,8 @@ export interface GrantRequested {
|
|
|
503
510
|
// Launches (design §6.1; packages/protocol/src/launch.ts is the wire source of truth, kept in step by the tests).
|
|
504
511
|
|
|
505
512
|
export type LaunchState = "queued" | "placing" | "provisioning" | "running" | "ended"
|
|
506
|
-
|
|
513
|
+
/** expired: its notAfter passed while it was still waiting for a machine (queued or placing); it never started. */
|
|
514
|
+
export type LaunchOutcome = "idle" | "needs_user" | "failed" | "timeout" | "stopped" | "lost" | "expired"
|
|
507
515
|
export type WorkspaceMode = "worktree" | "checkout" | "clone"
|
|
508
516
|
export type RuntimeKind = "host" | "openshell"
|
|
509
517
|
export type PlacementMode = "require" | "prefer"
|
|
@@ -542,6 +550,12 @@ export interface LaunchSpec {
|
|
|
542
550
|
readonly end?: { readonly when?: LaunchEndWhen; readonly timeout?: string; readonly cleanup?: CleanupPolicy }
|
|
543
551
|
/** Pin and apply this profile version before the prompt (explicit version; apps need profiles:assign). */
|
|
544
552
|
readonly agentProfile?: { readonly profileID: string; readonly version: number }
|
|
553
|
+
/**
|
|
554
|
+
* Epoch ms: still waiting for a machine (queued or placing) then, the launch is never started and ends with outcome
|
|
555
|
+
* expired. At most 30 days ahead; already passed: the hub answers 422 expired and stores nothing. Once it is
|
|
556
|
+
* provisioning it has no effect (end.timeout bounds running work). The same key and notAfter replays the launch.
|
|
557
|
+
*/
|
|
558
|
+
readonly notAfter?: number
|
|
545
559
|
}
|
|
546
560
|
|
|
547
561
|
export interface LaunchStep {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
// OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
|
|
17
17
|
// PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). One
|
|
18
18
|
// still pending after PENDING_MS expires (the loop's tick, or a flush that finds it): delivery "expired", and its
|
|
19
|
-
// sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal).
|
|
19
|
+
// sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). A message
|
|
20
|
+
// with notAfter (./not-after, H2) is held until min(at + PENDING_MS, notAfter) instead, and its expiry records
|
|
21
|
+
// why (Message.expiredReason, journaled as the event's reason): not_after or pending_timeout. In a
|
|
20
22
|
// cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
|
|
21
23
|
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
|
|
22
24
|
// session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
|
|
@@ -54,6 +56,7 @@ import type { AgentActor } from "./actor"
|
|
|
54
56
|
import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
|
|
55
57
|
import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
|
|
56
58
|
import { AgentError } from "./errors"
|
|
59
|
+
import { type ExpiredReason, parseNotAfter } from "./not-after"
|
|
57
60
|
import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
|
|
58
61
|
import { parseAddress } from "./names"
|
|
59
62
|
import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
|
|
@@ -149,6 +152,11 @@ export interface SendOptions {
|
|
|
149
152
|
readonly urgent?: boolean
|
|
150
153
|
/** Asks only. */
|
|
151
154
|
readonly expiresInMs?: number
|
|
155
|
+
/**
|
|
156
|
+
* Not delivered after this (epoch ms): checked at submit (parseNotAfter: invalid, or expired when already passed,
|
|
157
|
+
* before anything is stored), then it caps the pending hold and an ask's expiry. @me: checked at submit only.
|
|
158
|
+
*/
|
|
159
|
+
readonly notAfter?: number
|
|
152
160
|
/**
|
|
153
161
|
* Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
|
|
154
162
|
* larger of its own and this). Team intake passes the submitting session's depth + 1.
|
|
@@ -222,7 +230,10 @@ interface Pending {
|
|
|
222
230
|
readonly wake: boolean
|
|
223
231
|
readonly urgent: boolean
|
|
224
232
|
readonly at: number
|
|
233
|
+
/** min(at + PENDING_MS, notAfter). */
|
|
225
234
|
readonly expiresAt: number
|
|
235
|
+
/** The message's notAfter, when it has one (absent on entries stored before H2: pending_timeout). */
|
|
236
|
+
readonly notAfter?: number
|
|
226
237
|
}
|
|
227
238
|
|
|
228
239
|
/** How a party appears in deliver meta: `@name`, `ses_…`, `@me`, `app:<tokenID>`. */
|
|
@@ -298,6 +309,7 @@ export class Delivery {
|
|
|
298
309
|
* ambiguous, unsupported {feature: "deliver"}, rate_limited or forbidden (muted) before anything is stored.
|
|
299
310
|
*/
|
|
300
311
|
async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
|
|
312
|
+
this.checkNotAfter(options.notAfter)
|
|
301
313
|
if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
|
|
302
314
|
const from = this.party(userID, actor)
|
|
303
315
|
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
@@ -306,6 +318,7 @@ export class Delivery {
|
|
|
306
318
|
|
|
307
319
|
/** Like send, to every session the address names (each role holder gets its own message). */
|
|
308
320
|
async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
|
|
321
|
+
this.checkNotAfter(options.notAfter)
|
|
309
322
|
if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
|
|
310
323
|
const from = this.party(userID, actor)
|
|
311
324
|
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
@@ -314,6 +327,11 @@ export class Delivery {
|
|
|
314
327
|
return sent
|
|
315
328
|
}
|
|
316
329
|
|
|
330
|
+
/** A notAfter validated against this engine's clock (parseNotAfter): throws invalid or expired. */
|
|
331
|
+
checkNotAfter(value: unknown): number | undefined {
|
|
332
|
+
return parseNotAfter(value, this.now())
|
|
333
|
+
}
|
|
334
|
+
|
|
317
335
|
/** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
|
|
318
336
|
reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
|
|
319
337
|
return this.answer(userID, actor, replyTo, options, "reply", (input) => this.messages.reply(userID, { ...input, text }))
|
|
@@ -438,6 +456,7 @@ export class Delivery {
|
|
|
438
456
|
text,
|
|
439
457
|
hop,
|
|
440
458
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
459
|
+
...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
|
|
441
460
|
})
|
|
442
461
|
this.users.add(userID)
|
|
443
462
|
if (kind === "msg") {
|
|
@@ -464,6 +483,7 @@ export class Delivery {
|
|
|
464
483
|
hop,
|
|
465
484
|
...(generation === undefined ? {} : { generation }),
|
|
466
485
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
486
|
+
...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
|
|
467
487
|
})
|
|
468
488
|
await this.hops.received(userID, recipient, hop, from)
|
|
469
489
|
this.users.add(userID)
|
|
@@ -658,7 +678,9 @@ export class Delivery {
|
|
|
658
678
|
const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
|
|
659
679
|
if (delivered) return { ...delivered, ...woke }
|
|
660
680
|
const at = this.now()
|
|
661
|
-
const
|
|
681
|
+
const { notAfter } = message
|
|
682
|
+
const expiresAt = Math.min(at + PENDING_MS, notAfter ?? Number.POSITIVE_INFINITY)
|
|
683
|
+
const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt, ...(notAfter === undefined ? {} : { notAfter }) }
|
|
662
684
|
await this.deps.state.put(userID, PENDING, message.id, pending)
|
|
663
685
|
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
|
|
664
686
|
}
|
|
@@ -736,7 +758,7 @@ export class Delivery {
|
|
|
736
758
|
}
|
|
737
759
|
}
|
|
738
760
|
|
|
739
|
-
/** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
|
|
761
|
+
/** The loop's sweep: the user's messages pending past their expiresAt (PENDING_MS or notAfter) expire (their senders are told). */
|
|
740
762
|
private async expirePending(userID: string): Promise<void> {
|
|
741
763
|
const t = this.now()
|
|
742
764
|
for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
|
|
@@ -745,7 +767,7 @@ export class Delivery {
|
|
|
745
767
|
}
|
|
746
768
|
|
|
747
769
|
/**
|
|
748
|
-
* A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
|
|
770
|
+
* A pending message that waited PENDING_MS, or past its notAfter (reason not_after): removed (once: version-checked), marked "expired", and its sender
|
|
749
771
|
* told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
|
|
750
772
|
* journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
|
|
751
773
|
*/
|
|
@@ -753,10 +775,17 @@ export class Delivery {
|
|
|
753
775
|
if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
|
|
754
776
|
const current = await this.messages.get(userID, value.messageID)
|
|
755
777
|
if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
|
|
756
|
-
const
|
|
778
|
+
const reason: ExpiredReason = value.notAfter !== undefined && value.notAfter <= value.at + PENDING_MS ? "not_after" : "pending_timeout"
|
|
779
|
+
const message = (await this.messages.setDelivery(userID, current.id, "expired", reason)) ?? current
|
|
757
780
|
const what = `${message.kind} ${message.id} to ${message.to}`
|
|
758
781
|
// Why it waited: a staged revert holds every delivery (request()), else the session was offline or busy.
|
|
759
|
-
const
|
|
782
|
+
const reverted = this.deps.registry.revertStaged?.(value.sessionID) === true
|
|
783
|
+
const why =
|
|
784
|
+
reason === "not_after"
|
|
785
|
+
? `its notAfter deadline passed while ${reverted ? "a revert stayed staged in its session" : "its session was offline or busy"}`
|
|
786
|
+
: reverted
|
|
787
|
+
? "a revert stayed staged in its session for 24 h"
|
|
788
|
+
: "its session stayed offline or busy for 24 h"
|
|
760
789
|
try {
|
|
761
790
|
this.deps.expired?.(userID, message)
|
|
762
791
|
} catch (cause) {
|
|
@@ -25,6 +25,8 @@ export const AGENT_ERROR_STATUS = {
|
|
|
25
25
|
conflict: 409,
|
|
26
26
|
/** Already answered, expired or ended. */
|
|
27
27
|
gone: 410,
|
|
28
|
+
/** A message's notAfter had already passed at submit: nothing was stored, delivered or pushed. */
|
|
29
|
+
expired: 422,
|
|
28
30
|
/** The spoke or node lacks the feature or runtime (`feature`, `harness`). */
|
|
29
31
|
unsupported: 422,
|
|
30
32
|
/** A kill switch is on. */
|
|
@@ -173,7 +173,7 @@ export class AgentEvents {
|
|
|
173
173
|
|
|
174
174
|
/** A stored agent message changed (sent, delivered, answered…): never its text. */
|
|
175
175
|
export function messageEvent(message: Message): EventInput {
|
|
176
|
-
const { id, kind, state, from, recipient, replyTo, delivery } = message
|
|
176
|
+
const { id, kind, state, from, recipient, replyTo, delivery, expiredReason } = message
|
|
177
177
|
return {
|
|
178
178
|
type: "message.changed",
|
|
179
179
|
family: "message",
|
|
@@ -182,7 +182,16 @@ export function messageEvent(message: Message): EventInput {
|
|
|
182
182
|
...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
|
|
183
183
|
...(from.kind === "session" ? { senderID: from.sessionID } : {}),
|
|
184
184
|
},
|
|
185
|
-
data: {
|
|
185
|
+
data: {
|
|
186
|
+
kind,
|
|
187
|
+
state,
|
|
188
|
+
from: addressOf(from),
|
|
189
|
+
to: message.to,
|
|
190
|
+
...(replyTo ? { replyTo } : {}),
|
|
191
|
+
...(delivery ? { delivery } : {}),
|
|
192
|
+
// An expired delivery: which deadline passed (an enum, never text).
|
|
193
|
+
...(delivery === "expired" && expiredReason ? { reason: expiredReason } : {}),
|
|
194
|
+
},
|
|
186
195
|
audience: { parties: [partyKey(from), partyKey(recipient)] },
|
|
187
196
|
}
|
|
188
197
|
}
|
|
@@ -50,6 +50,12 @@
|
|
|
50
50
|
// lease (its provisioning runs stop at their next step and the next leader recovers them from the work records).
|
|
51
51
|
// accept() works on any pod: a pod that does not supervise kicks the leader (deps.supervisor), and a stop of a
|
|
52
52
|
// provisioning or running launch goes to the leader, where its pipeline or watch lives.
|
|
53
|
+
// - notAfter (H3): a launch still queued or placing once its spec's notAfter passes ends with outcome expired
|
|
54
|
+
// (expire: one version-checked step from queued/placing, so one launch change, journal event and launch.ended
|
|
55
|
+
// webhook). Checked on every tick (END_TICK_MS; the launches are read from the store, so it survives a restart
|
|
56
|
+
// and runs on the cluster's supervisor like the other tick-driven ends) and by placement, which never places such
|
|
57
|
+
// a launch (onExpired). From provisioning on it has no effect (end.timeout bounds running work). accept checks it
|
|
58
|
+
// with parseNotAfter (422 expired, nothing stored) unless the call replays a key (the store compares bodies).
|
|
53
59
|
// - onLaunchChanged gets every change the engine makes (queued, provisioning, running, ended); placement's own
|
|
54
60
|
// intermediate steps (placing, re-queued) are not reported.
|
|
55
61
|
import type { NodeListener, NodeView } from "../nodes"
|
|
@@ -63,9 +69,10 @@ import type { Delivery } from "./delivery"
|
|
|
63
69
|
import type { Directory } from "./directory"
|
|
64
70
|
import { AgentError } from "./errors"
|
|
65
71
|
import { validateLaunchSpec } from "./launch-spec"
|
|
72
|
+
import { parseNotAfter } from "./not-after"
|
|
66
73
|
import type { CleanupPolicy, LaunchSpec } from "./launch-spec"
|
|
67
74
|
import { type Launch, type LaunchFilter, type LaunchOutcome, type LaunchState, LaunchStore, LaunchTransitionError } from "./launch-store"
|
|
68
|
-
import { Placement, PlacementLoop, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
|
|
75
|
+
import { Placement, PlacementLoop, pastNotAfter, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
|
|
69
76
|
|
|
70
77
|
/** The NodeRegistry calls the engine needs. */
|
|
71
78
|
export type EngineNodes = {
|
|
@@ -141,6 +148,10 @@ export interface LaunchEngineDeps {
|
|
|
141
148
|
readonly onLaunchWaiting?: (launch: Launch) => void
|
|
142
149
|
/** (FX-02) Revoked launchers: their launches are flagged (view, list, the active wait); without it none is. */
|
|
143
150
|
readonly launchers?: PlacementLaunchers
|
|
151
|
+
/** How long an idle (or asking) session must stay unchanged before its launch ends (default END_SETTLE_MS; test fixtures). */
|
|
152
|
+
readonly settleMs?: number
|
|
153
|
+
/** How often running launches are looked at again and notAfter is swept (default END_TICK_MS; test fixtures). */
|
|
154
|
+
readonly tickMs?: number
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
/** Node events of every pod (cluster mode; the same as local ones on a single hub). */
|
|
@@ -332,6 +343,11 @@ export class LaunchEngine {
|
|
|
332
343
|
/** start() ran and shutdown() did not: this engine runs placement, pipelines and end watchers. */
|
|
333
344
|
private supervising = false
|
|
334
345
|
|
|
346
|
+
/** END_SETTLE_MS, or deps.settleMs. */
|
|
347
|
+
private get settleMs(): number {
|
|
348
|
+
return this.deps.settleMs ?? END_SETTLE_MS
|
|
349
|
+
}
|
|
350
|
+
|
|
335
351
|
constructor(private readonly deps: LaunchEngineDeps) {
|
|
336
352
|
this.now = deps.now ?? Date.now
|
|
337
353
|
this.timers = deps.timers ?? defaultTimers
|
|
@@ -361,6 +377,9 @@ export class LaunchEngine {
|
|
|
361
377
|
users: async () => [...(await deps.users())],
|
|
362
378
|
onPlaced: (placed) => void this.provision(placed),
|
|
363
379
|
onWaiting: (userID, waiting) => this.waited(userID, waiting),
|
|
380
|
+
onExpired: async (userID, ids) => {
|
|
381
|
+
for (const id of ids) await this.expire(userID, id)
|
|
382
|
+
},
|
|
364
383
|
...(deps.timers ? { timers: deps.timers } : {}),
|
|
365
384
|
})
|
|
366
385
|
}
|
|
@@ -374,6 +393,8 @@ export class LaunchEngine {
|
|
|
374
393
|
const launches = await this.store.list(userID)
|
|
375
394
|
const retry = spec.key !== undefined && launches.some((l) => l.spec.key === spec.key)
|
|
376
395
|
if (!retry) {
|
|
396
|
+
// A key replay may come after the deadline: the store answers it (same body) or refuses it (another body).
|
|
397
|
+
parseNotAfter(spec.notAfter, this.now())
|
|
377
398
|
if (spec.name !== undefined) this.checkName(userID, spec.name, launches)
|
|
378
399
|
await this.placement.admit(userID)
|
|
379
400
|
}
|
|
@@ -957,8 +978,9 @@ export class LaunchEngine {
|
|
|
957
978
|
this.tickTimer = this.timers.set(() => {
|
|
958
979
|
for (const watch of this.watches.values()) this.evaluate(watch)
|
|
959
980
|
for (const node of this.deps.nodes.list()) void this.reconcile(node)
|
|
981
|
+
void this.expireDue()
|
|
960
982
|
this.scheduleTick()
|
|
961
|
-
}, END_TICK_MS)
|
|
983
|
+
}, this.deps.tickMs ?? END_TICK_MS)
|
|
962
984
|
}
|
|
963
985
|
|
|
964
986
|
/** Looks at the watched launch now (one evaluation at a time per launch; a call during one makes one more). */
|
|
@@ -1029,10 +1051,10 @@ export class LaunchEngine {
|
|
|
1029
1051
|
watch.settleTimer = this.timers.set(() => {
|
|
1030
1052
|
watch.settleTimer = undefined
|
|
1031
1053
|
this.evaluate(watch)
|
|
1032
|
-
}, Math.max(0, now +
|
|
1054
|
+
}, Math.max(0, now + this.settleMs - this.now()))
|
|
1033
1055
|
return
|
|
1034
1056
|
}
|
|
1035
|
-
if (now - watch.candidate.since <
|
|
1057
|
+
if (now - watch.candidate.since < this.settleMs) return
|
|
1036
1058
|
await this.end(userID, watch.launchID, outcome, outcome === "idle" ? "Idle: the session finished" : "Needs the user: the session asked a question")
|
|
1037
1059
|
}
|
|
1038
1060
|
|
|
@@ -1147,6 +1169,45 @@ export class LaunchEngine {
|
|
|
1147
1169
|
}
|
|
1148
1170
|
}
|
|
1149
1171
|
|
|
1172
|
+
// ---- notAfter (H3) ---------------------------------------------------------------------------------------
|
|
1173
|
+
|
|
1174
|
+
/** Ends every user's queued or placing launch whose notAfter has passed (the tick). */
|
|
1175
|
+
private async expireDue(): Promise<void> {
|
|
1176
|
+
try {
|
|
1177
|
+
for (const userID of await this.deps.users()) {
|
|
1178
|
+
const now = this.now()
|
|
1179
|
+
for (const launch of await this.store.list(userID, { states: ["queued", "placing"] })) {
|
|
1180
|
+
if (pastNotAfter(launch, now)) await this.expire(userID, launch.id)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
} catch (cause) {
|
|
1184
|
+
console.error(`[hub] launch notAfter check failed: ${errorText(cause)}`)
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Ends a launch still queued or placing whose notAfter has passed, with outcome expired. One version-checked step,
|
|
1190
|
+
* so of racing callers (the tick, placement) only one ends it and reports the change; a launch that moved on
|
|
1191
|
+
* (provisioning: notAfter no longer applies) or ended meanwhile is left alone.
|
|
1192
|
+
*/
|
|
1193
|
+
private async expire(userID: string, id: string): Promise<void> {
|
|
1194
|
+
const launch = await this.store.get(userID, id)
|
|
1195
|
+
if (!launch || (launch.state !== "queued" && launch.state !== "placing") || !pastNotAfter(launch, this.now())) return
|
|
1196
|
+
const deadline = new Date(launch.spec.notAfter!).toISOString()
|
|
1197
|
+
try {
|
|
1198
|
+
const ended = await this.store.transition(userID, id, "ended", {
|
|
1199
|
+
reason: `Expired: its notAfter (${deadline}) passed before a machine took it; it was never started`,
|
|
1200
|
+
outcome: "expired",
|
|
1201
|
+
from: ["queued", "placing"],
|
|
1202
|
+
})
|
|
1203
|
+
this.changed(ended)
|
|
1204
|
+
this.kick(userID)
|
|
1205
|
+
} catch (cause) {
|
|
1206
|
+
if (cause instanceof LaunchTransitionError) return
|
|
1207
|
+
throw cause
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1150
1211
|
// ---- work records -------------------------------------------------------------------------------------
|
|
1151
1212
|
|
|
1152
1213
|
private async work(userID: string, id: string): Promise<LaunchWork | undefined> {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// → kill switch again → the engine. Apps and sessions see and stop only the launches they started (by); any other
|
|
15
15
|
// launch, like another user's, is 404 not_found. The engine applies the per-user launch caps (rate_limited).
|
|
16
16
|
// Every outcome once the body is valid is in the agent audit (verb launch, or launch.stop for DELETE).
|
|
17
|
+
// notAfter (H3): a new launch (not a key replay) whose notAfter has already passed is 422 expired before the acting
|
|
18
|
+
// check, so nothing is launched and no grant action is spent; the engine checks it again (parseNotAfter).
|
|
17
19
|
import { boundedJson } from "./body"
|
|
18
20
|
import type { LaunchInfo, LaunchSpec as WireSpec } from "../../protocol"
|
|
19
21
|
import { Feed } from "../cluster/feed"
|
|
@@ -27,6 +29,8 @@ import { AgentError } from "./errors"
|
|
|
27
29
|
import type { Grant, Grants } from "./grants"
|
|
28
30
|
import type { LaunchEngine, LaunchView } from "./launch-engine"
|
|
29
31
|
import { type LaunchSpec, validateLaunchSpec } from "./launch-spec"
|
|
32
|
+
import { parseNotAfter } from "./not-after"
|
|
33
|
+
import type { Clock } from "../backend/types"
|
|
30
34
|
import { LAUNCH_STATES, type Launch, type LaunchFilter, type LaunchState, type LaunchStep } from "./launch-store"
|
|
31
35
|
import type { AgentRoute } from "./routes"
|
|
32
36
|
|
|
@@ -45,6 +49,8 @@ export interface LaunchRoutesDeps {
|
|
|
45
49
|
readonly audit: Pick<AgentAudit, "record">
|
|
46
50
|
/** Names a session caller in the audit. */
|
|
47
51
|
readonly directory?: Pick<Directory, "resolveOne">
|
|
52
|
+
/** The clock a new launch's notAfter is checked against (default Date.now). */
|
|
53
|
+
readonly now?: Clock
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/** A launch change for its user (the engine's onLaunchChanged). */
|
|
@@ -215,6 +221,8 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
|
|
|
215
221
|
const by = launchedBy(actor)
|
|
216
222
|
const byLabel = actor.kind === "app" ? actor.label : undefined
|
|
217
223
|
const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
|
|
224
|
+
// A key replay may come after the deadline: the engine answers it with the existing launch.
|
|
225
|
+
if (held === undefined) parseNotAfter(spec.notAfter, (deps.now ?? Date.now)())
|
|
218
226
|
let launchID: string | undefined
|
|
219
227
|
const result = await act(user.id, actor, "launch", spec.workspace.repo, {
|
|
220
228
|
target: () => (launchID !== undefined ? { launchID } : {}),
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
// - labels are free-form and never interpreted by the hub, but bounded (LABELS_MAX entries, short keys and values).
|
|
9
9
|
// - Defaults: harness opencode, workspace mode worktree, runtime host, placement mode require, end when idle,
|
|
10
10
|
// cleanup keep, no timeout.
|
|
11
|
+
// - notAfter (H3): epoch ms; a launch still queued or placing then ends expired (the launch engine). Checked with the
|
|
12
|
+
// shared parseNotAfter (not-after.ts) when the caller gives `now` (400 invalid, 422 expired); without `now` (a key
|
|
13
|
+
// replay may come after the deadline) only its shape is checked. Idempotency compares it like any other field.
|
|
11
14
|
// LaunchSpec is defined here until packages/protocol carries it (P4-T1); keep the two in step.
|
|
12
15
|
|
|
13
16
|
import { type LaunchAgentProfile, launchAgentProfileProblem } from "../../protocol"
|
|
14
17
|
import { AgentError } from "./errors"
|
|
15
18
|
import { intentText } from "./intents"
|
|
16
19
|
import { isReservedName, parseAddress, validName } from "./names"
|
|
20
|
+
import { parseNotAfter } from "./not-after"
|
|
17
21
|
import { canonicalProject, parseDuration } from "./policy"
|
|
18
22
|
|
|
19
23
|
export type WorkspaceMode = "worktree" | "checkout" | "clone"
|
|
@@ -75,6 +79,8 @@ export interface LaunchSpec {
|
|
|
75
79
|
readonly end: LaunchEnd
|
|
76
80
|
/** (HWA-15) The profile version pinned and applied before the prompt (explicit version; never latest). */
|
|
77
81
|
readonly agentProfile?: LaunchAgentProfile
|
|
82
|
+
/** (H3) Epoch ms: still queued or placing then, the launch ends expired instead of starting. */
|
|
83
|
+
readonly notAfter?: number
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
export const KEY_MAX = 200
|
|
@@ -89,7 +95,7 @@ export const MODEL_MAX = 200
|
|
|
89
95
|
export const LAUNCH_TIMEOUT_MIN_MS = 60_000
|
|
90
96
|
export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
|
|
91
97
|
|
|
92
|
-
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
|
|
98
|
+
const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile", "notAfter"]
|
|
93
99
|
const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
|
|
94
100
|
const RUNTIME_FIELDS = ["kind", "profile"]
|
|
95
101
|
const PLACEMENT_FIELDS = ["machine", "labels", "mode"]
|
|
@@ -112,6 +118,8 @@ export interface LaunchSpecOptions {
|
|
|
112
118
|
* points at idempotencyKey (the Idempotency-Key header), not "key (top level)".
|
|
113
119
|
*/
|
|
114
120
|
readonly staffing?: boolean
|
|
121
|
+
/** The submit time: notAfter is checked against it (expired when passed; invalid when too far). Without it: shape only. */
|
|
122
|
+
readonly now?: number
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
/** Where a misplaced `key` belongs and the shape hint, for one caller. */
|
|
@@ -310,6 +318,7 @@ export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions =
|
|
|
310
318
|
const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
|
|
311
319
|
if (profileProblem) throw invalid(profileProblem)
|
|
312
320
|
const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
|
|
321
|
+
const notAfter = validNotAfter(input.notAfter, options.now)
|
|
313
322
|
return {
|
|
314
323
|
...(key !== undefined ? { key } : {}),
|
|
315
324
|
...(name !== undefined ? { name } : {}),
|
|
@@ -325,9 +334,17 @@ export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions =
|
|
|
325
334
|
labels,
|
|
326
335
|
end,
|
|
327
336
|
...(agentProfile ? { agentProfile } : {}),
|
|
337
|
+
...(notAfter !== undefined ? { notAfter } : {}),
|
|
328
338
|
}
|
|
329
339
|
}
|
|
330
340
|
|
|
341
|
+
/** notAfter against `now` (parseNotAfter: invalid or expired); without `now` only its shape (an epoch-ms integer). */
|
|
342
|
+
function validNotAfter(value: unknown, now: number | undefined): number | undefined {
|
|
343
|
+
if (now !== undefined) return parseNotAfter(value, now)
|
|
344
|
+
// Shape only: parseNotAfter checks the type before the time, so "just before it" accepts any epoch-ms integer.
|
|
345
|
+
return parseNotAfter(value, typeof value === "number" ? value - 1 : 0)
|
|
346
|
+
}
|
|
347
|
+
|
|
331
348
|
/** JSON with object keys sorted (stable for equal values). */
|
|
332
349
|
function stable(value: unknown): string {
|
|
333
350
|
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// launch already ended, else invalid) and changes nothing; writes are version-checked, so of two racing steps
|
|
7
7
|
// only one applies.
|
|
8
8
|
// - provisioning needs the machine, running the session; ended needs an outcome (idle, needs_user, failed,
|
|
9
|
-
// timeout, stopped, lost), and only ended may carry one.
|
|
9
|
+
// timeout, stopped, lost, expired: its notAfter passed while queued or placing), and only ended may carry one.
|
|
10
10
|
// - Idempotency: a spec with a key claims it in the StateStore (namespace `launch-keys:<userID>`, owner the launch
|
|
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
|
|
@@ -38,7 +38,7 @@ const CLAIMS = 3
|
|
|
38
38
|
|
|
39
39
|
export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
|
|
40
40
|
export type LaunchState = (typeof LAUNCH_STATES)[number]
|
|
41
|
-
export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
|
|
41
|
+
export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost", "expired"] as const
|
|
42
42
|
export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
|
|
43
43
|
|
|
44
44
|
/** The states each state may move to. */
|