@huynle/hub 0.10.1 → 0.11.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 +80 -0
- package/README.md +36 -8
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/sessions.ts +4 -1
- package/packages/cli/dist/hub-cli.js +851 -466
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +12 -7
- package/packages/cli/src/decisions.ts +52 -4
- package/packages/cli/src/format.ts +9 -0
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +26 -4
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +9 -3
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +14 -3
- 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/api/v1/_root.json +119 -0
- package/packages/protocol/schema/api/v1/account.json +419 -0
- package/packages/protocol/schema/api/v1/decisions.json +635 -0
- package/packages/protocol/schema/api/v1/discovery.json +110 -0
- package/packages/protocol/schema/api/v1/events.json +679 -0
- package/packages/protocol/schema/api/v1/session-actions.json +660 -0
- package/packages/protocol/schema/api/v1/sessions.json +2055 -0
- package/packages/protocol/schema/api/v1/speech.json +492 -0
- package/packages/protocol/schema/api/v1/unspecified.json +282 -0
- package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
- package/packages/protocol/schema/app-front/v1.schema.json +7 -3
- package/packages/protocol/src/api-v1.ts +8 -0
- package/packages/protocol/src/index.ts +2 -0
- package/packages/protocol/src/teams.ts +16 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -1
- 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/app-front.ts +2 -2
- package/packages/sdk/src/ask-fields.ts +76 -0
- package/packages/sdk/src/client.ts +24 -2
- package/packages/sdk/src/envelope.ts +3 -2
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/team-intake.ts +6 -0
- package/packages/sdk/src/teams.ts +4 -0
- package/packages/sdk/src/types.ts +36 -4
- package/packages/sdk/src/wake.ts +9 -4
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/agent-limits.ts +185 -0
- package/src/central/agent/app-decisions.ts +6 -5
- package/src/central/agent/decisions.ts +136 -9
- package/src/central/agent/delivery.ts +164 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +9 -6
- package/src/central/agent/message-limits.ts +6 -0
- package/src/central/agent/message-routes.ts +33 -6
- package/src/central/agent/messages.ts +7 -0
- package/src/central/agent/placement.ts +4 -2
- package/src/central/agent/read-routes.ts +5 -0
- package/src/central/agent/routes.ts +13 -0
- package/src/central/agent/subscriptions.ts +15 -4
- package/src/central/api-v1.ts +115 -0
- package/src/central/artifacts/store.ts +4 -2
- package/src/central/concierge/board-results.ts +2 -1
- package/src/central/concierge/notifier.ts +4 -2
- package/src/central/config.ts +24 -3
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +380 -36
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +236 -17
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +68 -3
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/registry.ts +34 -3
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +80 -0
- package/src/central/teams/intake.ts +38 -6
- package/src/central/teams/involvement.ts +95 -0
- package/src/central/teams/item-decisions.ts +60 -0
- package/src/central/teams/item-eligibility.ts +35 -0
- package/src/central/teams/item-stalls.ts +87 -0
- package/src/central/teams/loops.ts +40 -0
- package/src/central/teams/policy.ts +10 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +20 -1
- package/src/central/teams/validation.ts +16 -2
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
- package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
- package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-B2IfKm37.js +5 -0
- package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
- package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
- package/web/dist/assets/index-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.js +0 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
|
|
2
2
|
// writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
|
|
3
|
-
// or revokes a grant (escalation guard).
|
|
3
|
+
// or revokes a grant on the admin routes (escalation guard). /mine is holder-scoped; a user has no holder.
|
|
4
|
+
// Answers {grant} / {grants}, never cached; the owner is not echoed.
|
|
4
5
|
//
|
|
5
6
|
// POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
|
|
6
7
|
// GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
|
|
@@ -37,6 +38,18 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
|
|
|
37
38
|
} satisfies AgentRoute,
|
|
38
39
|
]
|
|
39
40
|
: []),
|
|
41
|
+
{
|
|
42
|
+
method: "GET", path: `${PREFIX}/mine`, allow: { session: true, scope: "messages:read" },
|
|
43
|
+
handle: async ({ actor }) => Response.json({ grants: (await grants.held(actor)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
method: "GET", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:read" },
|
|
47
|
+
handle: async ({ actor, params }) => one(await grants.heldGrant(actor, params.id!)),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
method: "DELETE", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:send" },
|
|
51
|
+
handle: async ({ actor, params }) => one(await grants.revokeHeld(actor, params.id!)),
|
|
52
|
+
},
|
|
40
53
|
{
|
|
41
54
|
method: "POST",
|
|
42
55
|
path: PREFIX,
|
|
@@ -136,6 +136,29 @@ export class Grants {
|
|
|
136
136
|
return (await this.all(owner)).sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1)).map((g) => ({ ...g, state: grantState(g, at) }))
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** Grants currently held by this credential; a user credential has no session/app holder. */
|
|
140
|
+
async held(actor: AgentActor): Promise<GrantView[]> {
|
|
141
|
+
if (actor.kind === "user") return []
|
|
142
|
+
return (await this.list(actor.userID)).filter((grant) => grant.owner === actor.userID && this.holds(grant, actor))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A holder cannot inspect another holder's grant (including an unheld role). */
|
|
146
|
+
async heldGrant(actor: AgentActor, id: string): Promise<Grant> {
|
|
147
|
+
if (actor.kind === "user") throw notFound()
|
|
148
|
+
const grant = await this.get(actor.userID, id)
|
|
149
|
+
if (grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
|
|
150
|
+
return grant
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Re-check holder inside the versioned mutation, so a role lost after a read cannot revoke it. */
|
|
154
|
+
async revokeHeld(actor: AgentActor, id: string): Promise<Grant> {
|
|
155
|
+
await this.heldGrant(actor, id)
|
|
156
|
+
return this.change(actor.userID, id, "grant.revoke", (grant, at) => {
|
|
157
|
+
if (actor.kind === "user" || grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
|
|
158
|
+
return { ...grant, revokedAt: at }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
/**
|
|
140
163
|
* Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
|
|
141
164
|
* stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
|
|
@@ -120,6 +120,7 @@ export interface LaunchEngineDeps {
|
|
|
120
120
|
/** This process (pod), as placement lease owner. */
|
|
121
121
|
readonly owner: string
|
|
122
122
|
readonly caps?: HubLaunches
|
|
123
|
+
readonly currentCaps?: () => { readonly maxQueued: number; readonly maxActive: number; readonly perHour: number; readonly lostAfterMinutes?: number | false }
|
|
123
124
|
readonly nodes: EngineNodes
|
|
124
125
|
readonly nodeOps: EngineNodeOps
|
|
125
126
|
readonly registry: EngineRegistry
|
|
@@ -334,7 +335,10 @@ export class LaunchEngine {
|
|
|
334
335
|
private readonly timers: Timers
|
|
335
336
|
private tickTimer: unknown
|
|
336
337
|
/** How long the reporting node instance may omit a running launch (NODE_UNREPORTED_MS, or caps.lostAfterMinutes; undefined: never). */
|
|
337
|
-
private
|
|
338
|
+
private get nodeLostMs(): number | undefined {
|
|
339
|
+
const lostAfter = this.deps.currentCaps?.().lostAfterMinutes ?? this.deps.caps?.lostAfterMinutes
|
|
340
|
+
return lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
|
|
341
|
+
}
|
|
338
342
|
/** Machines being reconciled now. */
|
|
339
343
|
private readonly reconciling = new Set<string>()
|
|
340
344
|
/** Reconcile cleanup retries per launch, and the launches whose failing retry was logged. */
|
|
@@ -352,8 +356,6 @@ export class LaunchEngine {
|
|
|
352
356
|
this.now = deps.now ?? Date.now
|
|
353
357
|
this.timers = deps.timers ?? defaultTimers
|
|
354
358
|
this.store = new LaunchStore({ state: deps.state, now: this.now })
|
|
355
|
-
const lostAfter = deps.caps?.lostAfterMinutes
|
|
356
|
-
this.nodeLostMs = lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
|
|
357
359
|
const { registry } = deps
|
|
358
360
|
this.placement = new Placement({
|
|
359
361
|
store: this.store,
|
|
@@ -369,6 +371,7 @@ export class LaunchEngine {
|
|
|
369
371
|
locks: deps.locks,
|
|
370
372
|
owner: deps.owner,
|
|
371
373
|
...(deps.caps ? { caps: deps.caps } : {}),
|
|
374
|
+
...(deps.currentCaps ? { currentCaps: deps.currentCaps } : {}),
|
|
372
375
|
...(deps.launchers ? { launchers: deps.launchers } : {}),
|
|
373
376
|
now: this.now,
|
|
374
377
|
})
|
|
@@ -746,7 +749,7 @@ export class LaunchEngine {
|
|
|
746
749
|
const workspace = work?.workspace ?? (launch ? leftWorkspace(launch, report) : undefined)
|
|
747
750
|
if (!workspace) continue
|
|
748
751
|
if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
|
|
749
|
-
await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
|
|
752
|
+
await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (launch?.spec.end.cleanup ?? (workspace.reused ? "keep" : "remove")) })
|
|
750
753
|
this.cleanupTries.delete(report.launchID)
|
|
751
754
|
this.cleanupLogged.delete(report.launchID)
|
|
752
755
|
} catch (cause) {
|
|
@@ -1122,6 +1125,8 @@ export class LaunchEngine {
|
|
|
1122
1125
|
// A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
|
|
1123
1126
|
const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
|
|
1124
1127
|
const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
|
|
1128
|
+
// Reconciliation must replay the policy actually applied, not the launch's original preference.
|
|
1129
|
+
await this.updateWork(userID, id, { cleanup: policy })
|
|
1125
1130
|
const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
|
|
1126
1131
|
if (node) {
|
|
1127
1132
|
if (work.started) {
|
|
@@ -1145,11 +1150,9 @@ export class LaunchEngine {
|
|
|
1145
1150
|
} catch (cause) {
|
|
1146
1151
|
// The node may still hold the launch (and its slot): reconcile retries with this policy.
|
|
1147
1152
|
notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
|
|
1148
|
-
await this.updateWork(userID, id, { cleanup: policy })
|
|
1149
1153
|
}
|
|
1150
1154
|
} else {
|
|
1151
1155
|
notes.push("workspace cleanup waits for the node")
|
|
1152
|
-
await this.updateWork(userID, id, { cleanup: policy })
|
|
1153
1156
|
}
|
|
1154
1157
|
}
|
|
1155
1158
|
try {
|
|
@@ -28,9 +28,12 @@ import type { Clock, Counters, StateStore } from "../backend/types"
|
|
|
28
28
|
import { type Party, partyKey } from "./envelope"
|
|
29
29
|
import { AgentError } from "./errors"
|
|
30
30
|
import { updateVersioned } from "./versioned"
|
|
31
|
+
import { TEAM_LIMITS } from "../../protocol"
|
|
31
32
|
|
|
32
33
|
/** Hops a reply chain may take without a human turn. */
|
|
33
34
|
export const HOP_CAP = 8
|
|
35
|
+
/** (HC-1) The absolute bound of any configured hop limit or ceiling (the live ceiling is in AgentLimits). */
|
|
36
|
+
export const HOP_ABSOLUTE_MAX = TEAM_LIMITS.hopLimit
|
|
34
37
|
/** A depth no message refreshed for this long is forgotten. */
|
|
35
38
|
export const HOP_TTL_MS = 24 * 60 * 60_000
|
|
36
39
|
|
|
@@ -160,6 +163,9 @@ export class MessageLimiter {
|
|
|
160
163
|
private readonly now: Clock
|
|
161
164
|
private readonly limits: MessageLimits
|
|
162
165
|
|
|
166
|
+
/** The configured recipient wake cap for the current 10-minute window. */
|
|
167
|
+
get wakeLimit(): number { return this.limits.wake }
|
|
168
|
+
|
|
163
169
|
constructor(deps: { readonly counters: Counters; readonly now?: Clock; readonly limits?: Partial<MessageLimits> }) {
|
|
164
170
|
this.counters = deps.counters
|
|
165
171
|
this.now = deps.now ?? Date.now
|
|
@@ -43,6 +43,7 @@ import type { AgentActor } from "./actor"
|
|
|
43
43
|
import { type AskInput, DECISION_STATES, type DecisionState, type Decisions, type Tier, type Urgency } from "./decisions"
|
|
44
44
|
import type { Delivery, SendOptions, Sent } from "./delivery"
|
|
45
45
|
import type { Directory } from "./directory"
|
|
46
|
+
import { isContractID } from "../../protocol"
|
|
46
47
|
import { partyFromActor, partyKey } from "./envelope"
|
|
47
48
|
import { AgentError } from "./errors"
|
|
48
49
|
import { idempotencyKeyOf, REPLAYED_HEADER, RouteIdempotency } from "./idempotency"
|
|
@@ -136,7 +137,7 @@ const sendOptions = (body: Body, delivery: Pick<Delivery, "checkNotAfter">): Sen
|
|
|
136
137
|
/** The user's address (also the malformed `@me.project`, which the delivery engine refuses). */
|
|
137
138
|
const isMe = (to: string) => to === "@me" || to.startsWith("@me.")
|
|
138
139
|
|
|
139
|
-
const DECISION_FIELDS = ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const
|
|
140
|
+
const DECISION_FIELDS = ["options", "recommendation", "choices", "kind", "urgency", "tier", "deadline", "onTimeout", "about"] as const
|
|
140
141
|
|
|
141
142
|
/** The state query parameter of a Decision listing, or undefined. */
|
|
142
143
|
export function decisionStateQuery(raw: string | null): DecisionState | undefined {
|
|
@@ -162,7 +163,21 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
|
|
|
162
163
|
if (typeof r !== "object" || r === null || Array.isArray(r)) throw new AgentError("invalid", "recommendation must be {option, why?}")
|
|
163
164
|
out.recommendation = { option: r.option, ...(r.why === undefined ? {} : { why: r.why }) }
|
|
164
165
|
}
|
|
165
|
-
|
|
166
|
+
if (body.choices !== undefined) {
|
|
167
|
+
const shape = "choices must be a list of {id, label, options: [{id, label, recommended?}]}"
|
|
168
|
+
if (!Array.isArray(body.choices)) throw new AgentError("invalid", shape)
|
|
169
|
+
out.choices = body.choices.map((group: unknown) => {
|
|
170
|
+
if (typeof group !== "object" || group === null || Array.isArray(group)) throw new AgentError("invalid", shape)
|
|
171
|
+
const { id, label, options } = group as Record<string, unknown>
|
|
172
|
+
if (!Array.isArray(options)) throw new AgentError("invalid", shape)
|
|
173
|
+
return { id, label, options: options.map((option: unknown) => {
|
|
174
|
+
if (typeof option !== "object" || option === null || Array.isArray(option)) throw new AgentError("invalid", shape)
|
|
175
|
+
const o = option as Record<string, unknown>
|
|
176
|
+
return { id: o.id, label: o.label, ...(o.recommended === undefined ? {} : { recommended: o.recommended }) }
|
|
177
|
+
}) }
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
for (const key of ["urgency", "tier", "onTimeout", "kind"] as const) {
|
|
166
181
|
if (body[key] === undefined) continue
|
|
167
182
|
if (typeof body[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
168
183
|
out[key] = body[key]
|
|
@@ -177,11 +192,12 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
|
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
/** The stored message, how it was delivered and, for a session recipient, whether it woke it (and why not). */
|
|
180
|
-
const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
|
|
195
|
+
const sentView = ({ message, delivery, woke, wakeSkipped, wakeLimit }: Sent) => ({
|
|
181
196
|
message,
|
|
182
197
|
...(delivery ? { delivery } : {}),
|
|
183
198
|
...(woke === undefined ? {} : { woke }),
|
|
184
199
|
...(wakeSkipped ? { wakeSkipped } : {}),
|
|
200
|
+
...(wakeLimit !== undefined ? { wakeLimit } : {}),
|
|
185
201
|
})
|
|
186
202
|
|
|
187
203
|
export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
@@ -271,6 +287,13 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
271
287
|
return sent
|
|
272
288
|
}
|
|
273
289
|
|
|
290
|
+
async function explicitItem(userID: string, actor: AgentActor, to: string, raw: unknown): Promise<string | undefined> {
|
|
291
|
+
if (raw === undefined) return undefined
|
|
292
|
+
if (!isContractID("intake", raw)) throw new AgentError("invalid", "item must be an itk_ ID")
|
|
293
|
+
if (isMe(to) || actor.kind !== "session") throw new AgentError("invalid", "item applies to messages between team members")
|
|
294
|
+
return raw
|
|
295
|
+
}
|
|
296
|
+
|
|
274
297
|
return [
|
|
275
298
|
{
|
|
276
299
|
method: "POST",
|
|
@@ -281,7 +304,8 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
281
304
|
const to = string(body, "to")
|
|
282
305
|
const text = string(body, "text", TEXT_MAX)
|
|
283
306
|
const all = flag(body, "all")
|
|
284
|
-
const
|
|
307
|
+
const item = await explicitItem(user.id, actor, to, body.item)
|
|
308
|
+
const sent = await send(user.id, actor, to, "msg", text, { ...sendOptions(body, delivery), ...(item ? { item } : {}) }, all)
|
|
285
309
|
return ok(all ? { sent: sent.map(sentView) } : sentView(sent[0]!), 201)
|
|
286
310
|
}),
|
|
287
311
|
},
|
|
@@ -293,11 +317,12 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
293
317
|
const body = await bodyOf(request)
|
|
294
318
|
const to = string(body, "to")
|
|
295
319
|
const text = string(body, "text", TEXT_MAX)
|
|
320
|
+
const item = await explicitItem(user.id, actor, to, body.item)
|
|
296
321
|
const all = flag(body, "all")
|
|
297
322
|
const expiresIn = seconds(body.expiresIn, "expiresIn")
|
|
298
323
|
const wait = seconds(body.wait, "wait", WAIT_MAX_S)
|
|
299
324
|
if (all && wait !== undefined) throw new AgentError("invalid", "wait cannot be combined with all")
|
|
300
|
-
const options: SendOptions = { ...sendOptions(body, delivery), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
|
|
325
|
+
const options: SendOptions = { ...sendOptions(body, delivery), ...(item ? { item } : {}), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
|
|
301
326
|
const decisionAsked = DECISION_FIELDS.some((key) => body[key] !== undefined)
|
|
302
327
|
if (isMe(to) && deps.decisions) {
|
|
303
328
|
if (all) throw new AgentError("invalid", "all cannot be combined with @me")
|
|
@@ -314,7 +339,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
314
339
|
const done = await settled(user.id, message, wait, request.signal)
|
|
315
340
|
return ok({ ...done, decision: (await deps.decisions.get(user.id, decision.id)) ?? decision }, 201)
|
|
316
341
|
}
|
|
317
|
-
if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
|
|
342
|
+
if (decisionAsked) throw new AgentError("invalid", "options, recommendation, choices, kind, urgency, tier, deadline, onTimeout and about are for asks to @me")
|
|
318
343
|
const sent = await send(user.id, actor, to, "ask", text, options, all)
|
|
319
344
|
if (all) return ok({ sent: sent.map(sentView) }, 201)
|
|
320
345
|
return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
|
|
@@ -326,6 +351,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
326
351
|
allow: SEND,
|
|
327
352
|
async handle({ request, user, actor }) {
|
|
328
353
|
const body = await bodyOf(request)
|
|
354
|
+
if (body.item !== undefined) throw new AgentError("invalid", "item is not accepted on replies", { hint: "replies inherit the item of the message they answer" })
|
|
329
355
|
const replyTo = string(body, "replyTo")
|
|
330
356
|
const text = string(body, "text", TEXT_MAX)
|
|
331
357
|
const wake = flag(body, "wake")
|
|
@@ -342,6 +368,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
342
368
|
allow: SEND,
|
|
343
369
|
async handle({ request, user, actor }) {
|
|
344
370
|
const body = await bodyOf(request)
|
|
371
|
+
if (body.item !== undefined) throw new AgentError("invalid", "item is not accepted on declines", { hint: "replies inherit the item of the message they answer" })
|
|
345
372
|
const replyTo = string(body, "replyTo")
|
|
346
373
|
const why = string(body, "why", WHY_MAX)
|
|
347
374
|
ensureOn(user.id)
|
|
@@ -59,6 +59,8 @@ export interface Message {
|
|
|
59
59
|
/** The role holder generation it reached (role addresses). */
|
|
60
60
|
readonly generation?: number
|
|
61
61
|
readonly text: string
|
|
62
|
+
/** Explicit intake item attribution, checked against the sender's current team seat. */
|
|
63
|
+
readonly item?: string
|
|
62
64
|
/** What a reply or decline answers. */
|
|
63
65
|
readonly replyTo?: string
|
|
64
66
|
readonly state: MessageState
|
|
@@ -90,6 +92,7 @@ export interface SendInput {
|
|
|
90
92
|
readonly recipient: Party
|
|
91
93
|
readonly kind: "msg" | "ask"
|
|
92
94
|
readonly text: string
|
|
95
|
+
readonly item?: string
|
|
93
96
|
readonly generation?: number
|
|
94
97
|
/** From HopTracker.next (default 0). */
|
|
95
98
|
readonly hop?: number
|
|
@@ -106,6 +109,8 @@ export interface AnswerInput {
|
|
|
106
109
|
/** The replier's current role holder generation, when it holds the role the original reached. */
|
|
107
110
|
readonly generation?: number
|
|
108
111
|
readonly hop?: number
|
|
112
|
+
/** Only when both parties still hold seats and the answered item's team is active/open. */
|
|
113
|
+
readonly item?: string
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
export interface Answered {
|
|
@@ -228,6 +233,7 @@ export class Messages {
|
|
|
228
233
|
recipient: input.recipient,
|
|
229
234
|
...(input.generation === undefined ? {} : { generation: input.generation }),
|
|
230
235
|
text,
|
|
236
|
+
...(input.item ? { item: input.item } : {}),
|
|
231
237
|
state: input.kind === "ask" ? "open" : "sent",
|
|
232
238
|
at,
|
|
233
239
|
hop: input.hop ?? 0,
|
|
@@ -377,6 +383,7 @@ export class Messages {
|
|
|
377
383
|
recipient: original.from,
|
|
378
384
|
text,
|
|
379
385
|
replyTo: original.id,
|
|
386
|
+
...(input.item ? { item: input.item } : {}),
|
|
380
387
|
state: "sent",
|
|
381
388
|
at,
|
|
382
389
|
hop: input.hop ?? 0,
|
|
@@ -203,6 +203,7 @@ export interface PlacementDeps {
|
|
|
203
203
|
/** This process (pod) as lease owner. */
|
|
204
204
|
readonly owner: string
|
|
205
205
|
readonly caps?: HubLaunches
|
|
206
|
+
readonly currentCaps?: () => LaunchCaps
|
|
206
207
|
readonly now?: Clock
|
|
207
208
|
readonly leaseMs?: number
|
|
208
209
|
/** Revoked launchers (flagged in the active wait); without it none is. */
|
|
@@ -244,12 +245,13 @@ export const pastNotAfter = (launch: Pick<Launch, "spec">, now: number): boolean
|
|
|
244
245
|
export const placementLeaseKey = (userID: string) => `placement:${userID}`
|
|
245
246
|
|
|
246
247
|
export class Placement {
|
|
247
|
-
|
|
248
|
+
get caps(): LaunchCaps { return this.deps.currentCaps?.() ?? this.initialCaps }
|
|
249
|
+
private readonly initialCaps: LaunchCaps
|
|
248
250
|
private readonly now: Clock
|
|
249
251
|
private readonly leaseMs: number
|
|
250
252
|
|
|
251
253
|
constructor(private readonly deps: PlacementDeps) {
|
|
252
|
-
this.
|
|
254
|
+
this.initialCaps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
|
|
253
255
|
this.now = deps.now ?? Date.now
|
|
254
256
|
this.leaseMs = deps.leaseMs ?? PLACEMENT_LEASE_MS
|
|
255
257
|
}
|
|
@@ -26,6 +26,7 @@ import type { EventsRouteDeps } from "./events-routes"
|
|
|
26
26
|
import { type Census, type TeamDiscovery, visible } from "../teams/discovery"
|
|
27
27
|
import type { SessionIntent } from "../../protocol"
|
|
28
28
|
import { intentField } from "./intents"
|
|
29
|
+
import type { SessionHealth } from "../health/classify"
|
|
29
30
|
|
|
30
31
|
/** Labels per session ID. */
|
|
31
32
|
export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
|
|
@@ -86,6 +87,8 @@ export interface AgentSessionView {
|
|
|
86
87
|
readonly harness: string
|
|
87
88
|
readonly parentID?: string
|
|
88
89
|
readonly busySince?: number
|
|
90
|
+
/** (SS-1) Why it is not making progress (provider retry, quota, auth, …); absent: ok. */
|
|
91
|
+
readonly health?: SessionHealth
|
|
89
92
|
readonly now?: string
|
|
90
93
|
readonly blockers: { kind: string; id: string; summary: string; sessionID?: string }[]
|
|
91
94
|
readonly children: number
|
|
@@ -139,6 +142,7 @@ function view({ session: s, harness }: Row, entry: DirectoryEntry | undefined, l
|
|
|
139
142
|
harness,
|
|
140
143
|
...(s.parentID ? { parentID: s.parentID } : {}),
|
|
141
144
|
...(own?.since !== undefined ? { busySince: own.since } : {}),
|
|
145
|
+
...(own?.health ? { health: own.health } : {}),
|
|
142
146
|
...(s.now ? { now: nowText(s.now) } : {}),
|
|
143
147
|
blockers: s.blockers.map((b) => ({ kind: b.kind, id: b.id, summary: b.summary, ...(b.sessionID !== s.id ? { sessionID: b.sessionID } : {}) })),
|
|
144
148
|
children: s.status.filter((e) => e.parentID === s.id).length,
|
|
@@ -290,6 +294,7 @@ async function show(deps: ReadDeps, userID: string, addr: string, census?: Censu
|
|
|
290
294
|
directory: live.directory,
|
|
291
295
|
...(live.parentID ? { parentID: live.parentID } : {}),
|
|
292
296
|
...(own?.since !== undefined ? { busySince: own.since } : {}),
|
|
297
|
+
...(own?.health ? { health: own.health } : {}),
|
|
293
298
|
...(live.now ? { now: nowText(live.now), activity: live.now } : {}),
|
|
294
299
|
blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
|
|
295
300
|
children: live.status.filter((e) => e.parentID === live.id).length,
|
|
@@ -67,6 +67,7 @@ import { isConciergeLaunch } from "../concierge/supervisor"
|
|
|
67
67
|
import type { ConciergeService } from "../concierge/service"
|
|
68
68
|
import type { SessionIntent } from "../../protocol"
|
|
69
69
|
import type { SessionIntents } from "./intents"
|
|
70
|
+
import type { NodeRegistry } from "../nodes"
|
|
70
71
|
import { intentRoutes } from "./intent-routes"
|
|
71
72
|
import { contextCardRoutes } from "../context-cards/routes"
|
|
72
73
|
import type { ContextCardService } from "../context-cards/service"
|
|
@@ -97,7 +98,10 @@ export interface AgentRoute {
|
|
|
97
98
|
|
|
98
99
|
/** What the agent API needs from the hub. */
|
|
99
100
|
export interface AgentDeps {
|
|
101
|
+
readonly agentLimits?: { view(): Promise<unknown> }
|
|
100
102
|
readonly store: Store
|
|
103
|
+
/** Owner's connected launch nodes: read-only user route; never expose connection IDs or launch internals. */
|
|
104
|
+
readonly nodes?: Pick<NodeRegistry, "list">
|
|
101
105
|
/** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
|
|
102
106
|
readonly connect?: AppConnect
|
|
103
107
|
/** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
|
|
@@ -238,6 +242,15 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
238
242
|
const feed = deps.feed ?? new MessageFeed()
|
|
239
243
|
const appDecisions = appDecisionParts(deps, feed)
|
|
240
244
|
const routes: AgentRoute[] = [
|
|
245
|
+
...(deps.nodes ? [{
|
|
246
|
+
method: "GET", path: `${AGENT_PREFIX}/launch-nodes`, allow: {},
|
|
247
|
+
handle: ({ user }) => Response.json({ nodes: deps.nodes!.list(user.id).map((node) => ({
|
|
248
|
+
machineID: node.machineID, machineName: node.machineName, workspaceRoots: node.workspaceRoots,
|
|
249
|
+
capacity: node.capacity, used: node.used, free: node.free, runtimes: node.runtimes,
|
|
250
|
+
labels: node.labels, draining: node.draining,
|
|
251
|
+
})) }, { headers: { "cache-control": "no-store" } }),
|
|
252
|
+
} satisfies AgentRoute] : []),
|
|
253
|
+
...(deps.agentLimits ? [{ method: "GET", path: `${AGENT_PREFIX}/agent-limits`, allow: { session: true, app: true }, handle: async () => Response.json(await deps.agentLimits!.view(), { headers: { "cache-control": "no-store" } }) }] : []),
|
|
241
254
|
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
|
|
242
255
|
...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
243
256
|
...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
@@ -54,7 +54,7 @@ import type { AgentActor, AppActor, SessionActor } from "./actor"
|
|
|
54
54
|
import { sanitizeLabel } from "./envelope"
|
|
55
55
|
import { AgentError } from "./errors"
|
|
56
56
|
import { parsePosition } from "./event-cursor"
|
|
57
|
-
import type
|
|
57
|
+
import { stateChanged, type AgentEvent, type AgentEvents, type EventInput, type EventRecord } from "./events"
|
|
58
58
|
import { visible as actorSees } from "./events-routes"
|
|
59
59
|
import { HOP_CAP } from "./message-limits"
|
|
60
60
|
import { parseAddress } from "./names"
|
|
@@ -245,6 +245,9 @@ export interface SubscriptionsDeps {
|
|
|
245
245
|
readonly send: SubscriptionSend
|
|
246
246
|
/** The session's message hop depth (HopTracker.depth). */
|
|
247
247
|
readonly hopDepth: (userID: string, sessionID: string) => Promise<number>
|
|
248
|
+
/** Hub-wide hop ceiling (team notices are not session-to-session messages). */
|
|
249
|
+
readonly hopLimit?: number
|
|
250
|
+
readonly currentHopLimit?: () => number
|
|
248
251
|
/** Directory facts of these sessions (deleted and unknown ones absent). */
|
|
249
252
|
readonly entries: (userID: string, sessionIDs: readonly string[]) => ReadonlyMap<string, SubscriptionEntry>
|
|
250
253
|
/** The sessions an address names for the subscriber (its visibility); throws not_found / invalid / ambiguous. */
|
|
@@ -307,7 +310,7 @@ export function typeMatches(patterns: readonly string[], event: Pick<AgentEvent,
|
|
|
307
310
|
return patterns.some((p) => {
|
|
308
311
|
if (p === "*") return true
|
|
309
312
|
if (Object.hasOwn(SUBSCRIPTION_FAMILIES, p)) return SUBSCRIPTION_FAMILIES[p]!.includes(type)
|
|
310
|
-
if (p.startsWith("state:")) return type === "session.changed" && event.data.state === p.slice(6)
|
|
313
|
+
if (p.startsWith("state:")) return type === "session.changed" && event.data.state === p.slice(6) && stateChanged(event.data)
|
|
311
314
|
return p.endsWith(".*") ? type.startsWith(p.slice(0, -1)) : p === type
|
|
312
315
|
})
|
|
313
316
|
}
|
|
@@ -318,12 +321,20 @@ export function typeMatches(patterns: readonly string[], event: Pick<AgentEvent,
|
|
|
318
321
|
const tok = (v: unknown): string => (typeof v === "string" && /^[A-Za-z0-9_.:@#-]{1,80}$/.test(v) ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : "other")
|
|
319
322
|
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
320
323
|
|
|
324
|
+
/** (SS-1) " (quota until 2026-11-18 10:00 UTC)", " (retrying)", " (ok)": the health a session.changed carries, else "". */
|
|
325
|
+
function healthNote(d: Readonly<Record<string, unknown>>): string {
|
|
326
|
+
if (d.healthKind === undefined) return ""
|
|
327
|
+
const at = typeof d.healthResetAt === "number" && Number.isFinite(d.healthResetAt) ? new Date(d.healthResetAt) : undefined
|
|
328
|
+
const until = at && !Number.isNaN(at.getTime()) ? ` until ${at.toISOString().slice(0, 16).replace("T", " ")} UTC` : ""
|
|
329
|
+
return ` (${tok(d.healthKind)}${until})`
|
|
330
|
+
}
|
|
331
|
+
|
|
321
332
|
function describe(event: AgentEvent): string {
|
|
322
333
|
const d = event.data
|
|
323
334
|
const s = event.subject
|
|
324
335
|
switch (event.type) {
|
|
325
336
|
case "session.changed":
|
|
326
|
-
return `${tok(d.state)}${d.state === "idle" && d.prev === "working" ? " (turn done)" : ""}`
|
|
337
|
+
return `${tok(d.state)}${d.state === "idle" && d.prev === "working" ? " (turn done)" : ""}${healthNote(d)}`
|
|
327
338
|
case "session.added":
|
|
328
339
|
return "online"
|
|
329
340
|
case "session.removed":
|
|
@@ -858,7 +869,7 @@ export class Subscriptions {
|
|
|
858
869
|
if (scoped && !scoped(subject)) continue
|
|
859
870
|
const cause = isMessage && subject.senderID !== undefined && subject.senderID.startsWith("ses_") ? subject.senderID : (subject.sessionID ?? target)
|
|
860
871
|
const depth = await this.depth(ctx, cause)
|
|
861
|
-
if (depth + 1 > HOP_CAP) {
|
|
872
|
+
if (depth + 1 > (this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP)) {
|
|
862
873
|
suppressed++
|
|
863
874
|
continue
|
|
864
875
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The /api/v1 boundary (docs/plans/2026-11-12-api-v1-contract-and-native-app-design.md §1.1, §1.2), without hub
|
|
2
|
+
// state. /api/v1/<rest> is an alias of /api/<rest> in the console and agent route tables: handle() in http.ts gives
|
|
3
|
+
// the handlers a copy of the URL on the unversioned path (never another Request), so routes, access checks and
|
|
4
|
+
// idempotency keys are the unversioned ones. Every error answered under /api/v1 is the agent API's envelope
|
|
5
|
+
// {error: {code, message, ...extra}}: console handlers keep answering {error: string, code?, ...extra} (the web
|
|
6
|
+
// console reads it on the unversioned paths) and apiV1Response rewrites that on the way out. Answers below 400 go
|
|
7
|
+
// out as they are, unread: the live stream and streamed audio are never buffered.
|
|
8
|
+
import { API_V1_PREFIX } from "../protocol"
|
|
9
|
+
|
|
10
|
+
const API_PREFIX = "/api"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The unversioned path a /api/v1 path is an alias of, or undefined outside /api/v1. One prefix only: /api/v1/v1/me
|
|
14
|
+
* is /api/v1/me, which no table has; the bare prefix is /api or /api/, which no table has either.
|
|
15
|
+
*/
|
|
16
|
+
export function unversionedPath(pathname: string): string | undefined {
|
|
17
|
+
if (pathname !== API_V1_PREFIX && !pathname.startsWith(`${API_V1_PREFIX}/`)) return undefined
|
|
18
|
+
return `${API_PREFIX}${pathname.slice(API_V1_PREFIX.length)}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A copy of a /api/v1 URL on its unversioned path (query string kept), or undefined outside /api/v1. */
|
|
22
|
+
export function unversionedURL(url: URL): URL | undefined {
|
|
23
|
+
const pathname = unversionedPath(url.pathname)
|
|
24
|
+
if (pathname === undefined) return undefined
|
|
25
|
+
const copy = new URL(url)
|
|
26
|
+
copy.pathname = pathname
|
|
27
|
+
return copy
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The code of a console error that names none, by status (design §1.2). */
|
|
31
|
+
const STATUS_CODES: Readonly<Record<number, string>> = {
|
|
32
|
+
400: "invalid",
|
|
33
|
+
401: "unauthorized",
|
|
34
|
+
403: "forbidden",
|
|
35
|
+
404: "not_found",
|
|
36
|
+
408: "timeout",
|
|
37
|
+
409: "conflict",
|
|
38
|
+
410: "gone",
|
|
39
|
+
413: "too_large",
|
|
40
|
+
422: "unsupported",
|
|
41
|
+
429: "rate_limited",
|
|
42
|
+
500: "internal",
|
|
43
|
+
502: "upstream_failed",
|
|
44
|
+
503: "unavailable",
|
|
45
|
+
504: "timeout",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const apiV1ErrorCode = (status: number): string => STATUS_CODES[status] ?? (status >= 500 ? "internal" : "invalid")
|
|
49
|
+
|
|
50
|
+
/** The message of an error whose answer had none to keep, by code. */
|
|
51
|
+
const MESSAGES: Readonly<Record<string, string>> = {
|
|
52
|
+
invalid: "Bad request",
|
|
53
|
+
unauthorized: "Unauthorized",
|
|
54
|
+
forbidden: "Forbidden",
|
|
55
|
+
not_found: "Not found",
|
|
56
|
+
timeout: "Timed out",
|
|
57
|
+
conflict: "Conflict",
|
|
58
|
+
gone: "Gone",
|
|
59
|
+
too_large: "Too large",
|
|
60
|
+
unsupported: "Unsupported",
|
|
61
|
+
rate_limited: "Too many requests",
|
|
62
|
+
internal: "Internal error",
|
|
63
|
+
upstream_failed: "Upstream failed",
|
|
64
|
+
unavailable: "Unavailable",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A text/* error body longer than this is not a message (a page, a trace). */
|
|
68
|
+
const TEXT_MESSAGE_MAX_CHARS = 300
|
|
69
|
+
|
|
70
|
+
export interface ApiV1Error {
|
|
71
|
+
readonly error: { readonly code: string; readonly message: string; readonly [extra: string]: unknown }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The v1 envelope of an error answer: `body` is its parsed JSON (if it was JSON), `text` its text/* body. An
|
|
78
|
+
* envelope is returned as it is (the same object); a console error keeps its message, its code (else the
|
|
79
|
+
* status's) and moves its other fields inside `error`; anything else gets the status's code and a generic message.
|
|
80
|
+
*/
|
|
81
|
+
export function apiV1ErrorBody(status: number, body: unknown, text?: string): ApiV1Error {
|
|
82
|
+
if (isRecord(body)) {
|
|
83
|
+
const { error, code, ...extra } = body
|
|
84
|
+
if (isRecord(error) && typeof error.code === "string" && typeof error.message === "string") return body as unknown as ApiV1Error
|
|
85
|
+
if (typeof error === "string") return { error: { ...extra, code: typeof code === "string" && code ? code : apiV1ErrorCode(status), message: error } }
|
|
86
|
+
}
|
|
87
|
+
const code = apiV1ErrorCode(status)
|
|
88
|
+
const short = text?.trim()
|
|
89
|
+
return { error: { code, message: short && short.length <= TEXT_MESSAGE_MAX_CHARS ? short : MESSAGES[code] ?? "Request failed" } }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An answer on its way out of /api/v1: errors (400 and up) in the v1 envelope, same status and headers. */
|
|
93
|
+
export async function apiV1Response(response: Response): Promise<Response> {
|
|
94
|
+
if (response.status < 400) return response
|
|
95
|
+
const type = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase()
|
|
96
|
+
const json = type === "application/json" || type.endsWith("+json")
|
|
97
|
+
// An event stream has no end to read to.
|
|
98
|
+
const text = type.startsWith("text/") && type !== "text/event-stream"
|
|
99
|
+
// A copy is read, so an answer that is already the envelope (every agent route's) goes out as its handler made it.
|
|
100
|
+
const raw = json || text ? await response.clone().text() : undefined
|
|
101
|
+
let parsed: unknown
|
|
102
|
+
if (json) {
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(raw!)
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
const body = apiV1ErrorBody(response.status, parsed, text ? raw : undefined)
|
|
108
|
+
if (body === parsed) return response
|
|
109
|
+
void response.body?.cancel().catch(() => {})
|
|
110
|
+
const bytes = new TextEncoder().encode(JSON.stringify(body))
|
|
111
|
+
const headers = new Headers(response.headers)
|
|
112
|
+
headers.set("content-type", "application/json;charset=utf-8")
|
|
113
|
+
headers.set("content-length", String(bytes.length))
|
|
114
|
+
return new Response(bytes, { status: response.status, statusText: response.statusText, headers })
|
|
115
|
+
}
|
|
@@ -95,6 +95,8 @@ export interface ArtifactEvent {
|
|
|
95
95
|
readonly number?: number
|
|
96
96
|
/** artifact.archived: the state it moved to (archive and unarchive share the event). */
|
|
97
97
|
readonly state?: RecordState
|
|
98
|
+
/** created/revised by a session (in-process only: never journaled; TA2-5 progress credit). */
|
|
99
|
+
readonly authorSessionID?: string
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface ArtifactStoreOptions {
|
|
@@ -393,7 +395,7 @@ export class ArtifactStore {
|
|
|
393
395
|
if (!written.ok) throw conflict("Artifact ID collision; retry", undefined)
|
|
394
396
|
return artifact
|
|
395
397
|
})
|
|
396
|
-
this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1 })
|
|
398
|
+
this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1, ...(published.revision.author.kind === "session" ? { authorSessionID: published.revision.author.sessionID } : {}) })
|
|
397
399
|
return published
|
|
398
400
|
}
|
|
399
401
|
|
|
@@ -426,7 +428,7 @@ export class ArtifactStore {
|
|
|
426
428
|
}
|
|
427
429
|
throw conflict("The artifact kept changing; retry", undefined)
|
|
428
430
|
})
|
|
429
|
-
this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number })
|
|
431
|
+
this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number, ...(published.revision.author.kind === "session" ? { authorSessionID: published.revision.author.sessionID } : {}) })
|
|
430
432
|
return published
|
|
431
433
|
}
|
|
432
434
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// stalled store further results are dropped. observe() never throws or rejects (it is called with `void`).
|
|
18
18
|
import type { ConciergeResultOutcome } from "../../../packages/protocol/src/concierge"
|
|
19
19
|
import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
|
|
20
|
-
import type
|
|
20
|
+
import { stateChanged, type AgentEvent } from "../agent/events"
|
|
21
21
|
import { updateVersioned } from "../agent/versioned"
|
|
22
22
|
import type { Clock, StateStore } from "../backend/types"
|
|
23
23
|
import type { LastResult } from "./board"
|
|
@@ -56,6 +56,7 @@ export function resultOf(event: Pick<AgentEvent, "type" | "subject" | "data"> |
|
|
|
56
56
|
if (!isHubSessionID(sessionID)) return undefined
|
|
57
57
|
if (event.type === "session.changed") {
|
|
58
58
|
if (subject.rootID !== undefined && subject.rootID !== sessionID) return undefined
|
|
59
|
+
if (!stateChanged(data)) return undefined
|
|
59
60
|
if (data.state === "error") return { sessionID, outcome: "error" }
|
|
60
61
|
if (data.state === "idle" && (data.prev === "working" || data.prev === "blocked")) return { sessionID, outcome: "turn_done" }
|
|
61
62
|
return undefined
|