@huynle/hub 0.7.3 → 0.8.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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Route-level Idempotency-Key handling shared by the /api/agent create routes (contract: IDEMPOTENCY_KEY,
|
|
2
|
+
// IDEMPOTENCY_TTL_MS; per user and per route). Two compatible modes:
|
|
3
|
+
//
|
|
4
|
+
// - Answer replay (RouteIdempotency.run, extracted from the profile routes of HWA-14): the first request with a
|
|
5
|
+
// key reserves it (ifVersion 0), runs, and stores its answer; a repeat with the same body within the TTL gets
|
|
6
|
+
// that answer again; another body under the key is 409 conflict, and so is a repeat while the first is still
|
|
7
|
+
// running. A failed request frees its key. Records live in a StateStore collection of the caller's choosing,
|
|
8
|
+
// keyed by sha256(route, key); expired ones are pruned as keys are used.
|
|
9
|
+
// - Store-level (the workspace, assignment and launch stores keep the key next to the record they create, so the
|
|
10
|
+
// claim and the record cannot drift apart): the route only reads and validates the header (idempotencyKeyOf)
|
|
11
|
+
// and answers with storeAnswer.
|
|
12
|
+
//
|
|
13
|
+
// Both modes look the same to clients: the header is `Idempotency-Key`, a malformed key is 400 invalid with the
|
|
14
|
+
// same wording, another body under a live key is 409 conflict, and a replay carries `idempotent-replayed: true`.
|
|
15
|
+
import { createHash } from "node:crypto"
|
|
16
|
+
import { IDEMPOTENCY_KEY, IDEMPOTENCY_TTL_MS } from "../../protocol"
|
|
17
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
18
|
+
import { AgentError } from "./errors"
|
|
19
|
+
|
|
20
|
+
export const IDEMPOTENCY_HEADER = "idempotency-key"
|
|
21
|
+
export const REPLAYED_HEADER = "idempotent-replayed"
|
|
22
|
+
const BAD_KEY = "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -"
|
|
23
|
+
|
|
24
|
+
export const IDEMPOTENCY_LIMITS = {
|
|
25
|
+
ttlMs: IDEMPOTENCY_TTL_MS,
|
|
26
|
+
/** A reservation whose request never finished is taken over after this long. */
|
|
27
|
+
pendingMs: 60_000,
|
|
28
|
+
/** Live keys per user (per collection). */
|
|
29
|
+
keysPerUser: 1_000,
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
interface Record_ {
|
|
33
|
+
readonly v: 1
|
|
34
|
+
readonly hash: string
|
|
35
|
+
readonly at: number
|
|
36
|
+
readonly state: "pending" | "done"
|
|
37
|
+
readonly status?: number
|
|
38
|
+
readonly body?: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Answer {
|
|
42
|
+
readonly status: number
|
|
43
|
+
readonly body: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
47
|
+
const sha256 = (text: string) => createHash("sha256").update(text).digest("hex")
|
|
48
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
49
|
+
|
|
50
|
+
/** JSON with sorted keys and undefined members dropped (the same body in any key order compares equal). */
|
|
51
|
+
export function canonicalBody(value: unknown): string {
|
|
52
|
+
if (Array.isArray(value)) return `[${value.map(canonicalBody).join(",")}]`
|
|
53
|
+
if (isRecord(value)) {
|
|
54
|
+
const keys = Object.keys(value)
|
|
55
|
+
.filter((k) => value[k] !== undefined)
|
|
56
|
+
.sort()
|
|
57
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalBody(value[k])}`).join(",")}}`
|
|
58
|
+
}
|
|
59
|
+
return JSON.stringify(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The request's Idempotency-Key (undefined when absent; 400 invalid when malformed). */
|
|
63
|
+
export function idempotencyKeyOf(request: Request): string | undefined {
|
|
64
|
+
const key = request.headers.get(IDEMPOTENCY_HEADER)
|
|
65
|
+
if (key === null) return undefined
|
|
66
|
+
if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", BAD_KEY)
|
|
67
|
+
return key
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The answer of a store-level idempotent create: `status` when it created, 200 + the replay header when not. */
|
|
71
|
+
export function storeAnswer(created: boolean, body: unknown, status = 201): Response {
|
|
72
|
+
return respond({ status: created ? status : 200, body }, !created)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class RouteIdempotency {
|
|
76
|
+
private readonly state: StateStore
|
|
77
|
+
private readonly collection: string
|
|
78
|
+
private readonly now: Clock
|
|
79
|
+
|
|
80
|
+
constructor(options: { readonly state: StateStore; readonly collection: string; readonly now?: Clock }) {
|
|
81
|
+
this.state = options.state
|
|
82
|
+
this.collection = options.collection
|
|
83
|
+
this.now = options.now ?? Date.now
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Runs `perform` once per (user, route, key, body); without a key it simply runs. */
|
|
87
|
+
async run(userID: string, route: string, key: string | null, body: unknown, perform: () => Promise<Answer>): Promise<Response> {
|
|
88
|
+
if (key === null) return respond(await perform(), false)
|
|
89
|
+
if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", BAD_KEY)
|
|
90
|
+
const id = sha256(`${route}\n${key}`)
|
|
91
|
+
const hash = sha256(canonicalBody(body))
|
|
92
|
+
let reserved: number | undefined
|
|
93
|
+
for (let attempt = 0; attempt < 4 && reserved === undefined; attempt++) {
|
|
94
|
+
const existing = await this.state.get<Record_>(userID, this.collection, id)
|
|
95
|
+
const now = this.now()
|
|
96
|
+
const live = existing && now - existing.value.at < (existing.value.state === "pending" ? IDEMPOTENCY_LIMITS.pendingMs : IDEMPOTENCY_LIMITS.ttlMs)
|
|
97
|
+
if (existing && live) {
|
|
98
|
+
if (existing.value.hash !== hash) throw new AgentError("conflict", "This Idempotency-Key was used with another body", { hint: "Use a new key" })
|
|
99
|
+
if (existing.value.state === "done") return respond({ status: existing.value.status!, body: existing.value.body }, true)
|
|
100
|
+
throw new AgentError("conflict", "A request with this Idempotency-Key is still running", { hint: "Retry shortly" })
|
|
101
|
+
}
|
|
102
|
+
if (!existing) await this.prune(userID)
|
|
103
|
+
const put = await this.state.put<Record_>(userID, this.collection, id, { v: 1, hash, at: now, state: "pending" }, { ifVersion: existing?.version ?? 0 })
|
|
104
|
+
if (put.ok) reserved = put.version
|
|
105
|
+
}
|
|
106
|
+
if (reserved === undefined) throw new AgentError("conflict", "A request with this Idempotency-Key is still running", { hint: "Retry shortly" })
|
|
107
|
+
let answer: Answer
|
|
108
|
+
try {
|
|
109
|
+
answer = await perform()
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
await this.state.delete(userID, this.collection, id, { ifVersion: reserved }).catch(() => {})
|
|
112
|
+
throw cause
|
|
113
|
+
}
|
|
114
|
+
await this.state.put<Record_>(userID, this.collection, id, { v: 1, hash, at: this.now(), state: "done", status: answer.status, body: answer.body }, { ifVersion: reserved })
|
|
115
|
+
return respond(answer, false)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Drops expired keys; refuses a new one past keysPerUser live keys. */
|
|
119
|
+
private async prune(userID: string): Promise<void> {
|
|
120
|
+
const now = this.now()
|
|
121
|
+
let live = 0
|
|
122
|
+
for (const entry of await this.state.list<Record_>(userID, this.collection)) {
|
|
123
|
+
const ttl = entry.value.state === "pending" ? IDEMPOTENCY_LIMITS.pendingMs : IDEMPOTENCY_LIMITS.ttlMs
|
|
124
|
+
if (now - entry.value.at >= ttl) await this.state.delete(userID, this.collection, entry.key, { ifVersion: entry.version })
|
|
125
|
+
else live++
|
|
126
|
+
}
|
|
127
|
+
if (live >= IDEMPOTENCY_LIMITS.keysPerUser) throw new AgentError("rate_limited", `At most ${IDEMPOTENCY_LIMITS.keysPerUser} live Idempotency-Keys per user`, { extra: { retryAfter: 3600 } })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function respond(answer: Answer, replayed: boolean): Response {
|
|
132
|
+
return Response.json(answer.body, { status: answer.status, headers: { ...NO_STORE, ...(replayed ? { [REPLAYED_HEADER]: "true" } : {}) } })
|
|
133
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Session intent routes (CON-02; contract: "Session intent (CON-02)" in docs/plans/2026-11-01-concierge-contracts.md).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/agent/sessions/:addr/intent → {intent: SessionIntent | null}
|
|
4
|
+
// users; app tokens holding sessions:read; session tokens (the session read rules: any of the user's
|
|
5
|
+
// sessions, scoped by team discovery when the hub has it)
|
|
6
|
+
// PUT /api/agent/sessions/:addr/intent {text: string | null, expectedRevision} → {intent: SessionIntent | null}
|
|
7
|
+
// users (source user); app tokens holding sessions:manage (source user, `by` the app); a session token for
|
|
8
|
+
// ITS OWN session only (source self; another session is 403 forbidden). null clears the writer's layer, down
|
|
9
|
+
// to the next source. A stale expectedRevision is 409 conflict {currentRevision}. (CON-10) Users only:
|
|
10
|
+
// {source: "prompt", text: null, expectedRevision} clears the first-prompt intent (a wrong one), down to the
|
|
11
|
+
// next source; the first prompt stays used up, so a later prompt never refills it.
|
|
12
|
+
//
|
|
13
|
+
// Answers carry the effective intent: the stored one, else the session's non-placeholder title (source title, the
|
|
14
|
+
// record's revision, 0 when nothing was ever stored), else null. Another user's session is 404 not_found (address
|
|
15
|
+
// resolution is per caller). The text is untrusted data: bounded, sanitised and never interpreted. Each change is
|
|
16
|
+
// in the admin audit (no text) and the journal (`session.intent {source, revision}`, from SessionIntents).
|
|
17
|
+
import type { SessionIntent } from "../../protocol"
|
|
18
|
+
import type { Registry } from "../registry"
|
|
19
|
+
import type { Store } from "../store"
|
|
20
|
+
import type { AgentActor } from "./actor"
|
|
21
|
+
import { boundedJson } from "./body"
|
|
22
|
+
import type { Directory, DirectoryEntry } from "./directory"
|
|
23
|
+
import { AgentError } from "./errors"
|
|
24
|
+
import { type IntentWrite, type SessionIntents, titleIntent } from "./intents"
|
|
25
|
+
import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
|
|
26
|
+
import type { TeamDiscovery } from "../teams/discovery"
|
|
27
|
+
|
|
28
|
+
/** A PUT body is one short line of text and a revision. */
|
|
29
|
+
const BODY_MAX = 16 * 1024
|
|
30
|
+
const FIELDS = new Set(["text", "expectedRevision", "source"])
|
|
31
|
+
|
|
32
|
+
export interface IntentRoutesDeps {
|
|
33
|
+
readonly intents: SessionIntents
|
|
34
|
+
readonly directory: Directory
|
|
35
|
+
/** The live title (the read fallback); the directory's last known title otherwise. */
|
|
36
|
+
readonly registry?: Pick<Registry, "sessionTenant" | "session">
|
|
37
|
+
readonly store: Pick<Store, "audit">
|
|
38
|
+
/** Team-aware discovery (HWA-40): team-scoped callers read only sessions they may see. */
|
|
39
|
+
readonly discovery?: TeamDiscovery
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
43
|
+
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
44
|
+
|
|
45
|
+
/** Who wrote: user, app:<tokenID>, or the session's ID (as launches record `by`). */
|
|
46
|
+
function writer(actor: AgentActor): string {
|
|
47
|
+
switch (actor.kind) {
|
|
48
|
+
case "user":
|
|
49
|
+
return "user"
|
|
50
|
+
case "app":
|
|
51
|
+
return `app:${actor.tokenID}`
|
|
52
|
+
case "session":
|
|
53
|
+
return actor.sessionID
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readWrite(request: Request): Promise<Pick<IntentWrite, "text" | "expectedRevision"> & { readonly clearPrompt?: true }> {
|
|
58
|
+
const body = await boundedJson(request, BODY_MAX)
|
|
59
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object {text, expectedRevision}")
|
|
60
|
+
const fields = body as Record<string, unknown>
|
|
61
|
+
for (const key of Object.keys(fields)) if (!FIELDS.has(key)) throw new AgentError("invalid", `Unknown field ${key}`)
|
|
62
|
+
const { text, expectedRevision } = fields
|
|
63
|
+
if (text !== null && typeof text !== "string") throw new AgentError("invalid", "text is a string, or null to clear")
|
|
64
|
+
if (!Number.isSafeInteger(expectedRevision) || (expectedRevision as number) < 0) {
|
|
65
|
+
throw new AgentError("invalid", "expectedRevision is the intent's revision you read (0 when none was stored)")
|
|
66
|
+
}
|
|
67
|
+
// (CON-10) {source: "prompt", text: null}: the user clears the first-prompt intent (down to the title).
|
|
68
|
+
if (fields.source !== undefined && (fields.source !== "prompt" || text !== null)) throw new AgentError("invalid", 'source is only "prompt", with text null (clears the first-prompt intent)')
|
|
69
|
+
return { text, expectedRevision: expectedRevision as number, ...(fields.source === "prompt" ? { clearPrompt: true as const } : {}) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function intentRoutes(deps: IntentRoutesDeps): AgentRoute[] {
|
|
73
|
+
const { intents, directory } = deps
|
|
74
|
+
|
|
75
|
+
/** The session an address names for this caller (team-scoped when discovery is given); not_found otherwise. */
|
|
76
|
+
async function resolve(userID: string, actor: AgentActor, addr: string): Promise<DirectoryEntry> {
|
|
77
|
+
if (deps.discovery) return deps.discovery.resolveOne(await deps.discovery.census(userID, actor), addr)
|
|
78
|
+
return directory.resolveOne(userID, addr)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The stored intent, else the title fallback, else null. */
|
|
82
|
+
async function effective(userID: string, entry: DirectoryEntry): Promise<SessionIntent | null> {
|
|
83
|
+
const stored = await intents.read(userID, entry.sessionID)
|
|
84
|
+
if (stored) return stored
|
|
85
|
+
const live = deps.registry && deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
|
|
86
|
+
const title = live?.title ?? entry.title
|
|
87
|
+
return titleIntent(entry.sessionID, title, await intents.revision(userID, entry.sessionID), live?.updated ?? entry.lastSeen ?? 0) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
method: "GET",
|
|
93
|
+
path: `${AGENT_PREFIX}/sessions/:addr/intent`,
|
|
94
|
+
allow: { session: true, scope: "sessions:read" },
|
|
95
|
+
handle: async ({ user, actor, params }) => ok({ intent: await effective(user.id, await resolve(user.id, actor, params.addr!)) }),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
method: "PUT",
|
|
99
|
+
path: `${AGENT_PREFIX}/sessions/:addr/intent`,
|
|
100
|
+
allow: { session: true, scope: "sessions:manage" },
|
|
101
|
+
async handle(context: AgentContext) {
|
|
102
|
+
const { user, actor, params } = context
|
|
103
|
+
const { clearPrompt, ...write } = await readWrite(context.request)
|
|
104
|
+
const entry = await resolve(user.id, actor, params.addr!)
|
|
105
|
+
if (actor.kind === "session" && entry.sessionID !== actor.sessionID) throw new AgentError("forbidden", "A session token can only set its own session's intent")
|
|
106
|
+
if (clearPrompt && actor.kind !== "user") throw new AgentError("forbidden", "Only the user clears a session's first-prompt intent")
|
|
107
|
+
const before = await intents.revision(user.id, entry.sessionID)
|
|
108
|
+
await intents.set(user.id, entry.sessionID, { ...write, source: clearPrompt ? "prompt" : actor.kind === "session" ? "self" : "user", by: writer(actor) })
|
|
109
|
+
if ((await intents.revision(user.id, entry.sessionID)) !== before) {
|
|
110
|
+
await deps.store.audit({ actor: user.id, action: write.text === null ? "session.intent.clear" : "session.intent", target: entry.sessionID, detail: actor.kind })
|
|
111
|
+
}
|
|
112
|
+
return ok({ intent: await effective(user.id, entry) })
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
}
|