@huynle/hub 0.7.4 → 0.8.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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- 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/paths.ts +3 -3
- 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 +6871 -3460
- 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/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- 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 +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -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 +175 -6
- package/packages/protocol/src/launch.ts +36 -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 +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- 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 +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- 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 +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- 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 +776 -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 +350 -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 +86 -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 +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- 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 +69 -7
- 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 +786 -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 +910 -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 +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- 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-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -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,350 @@
|
|
|
1
|
+
// CON-03 situation board routes on /api/agent (contract: docs/plans/2026-11-01-concierge-contracts.md, "Situation
|
|
2
|
+
// board (CON-03)"):
|
|
3
|
+
//
|
|
4
|
+
// GET /api/agent/concierge/board user; the Concierge → {board, rendered}
|
|
5
|
+
// GET /api/agent/concierge/decisions[?state&limit&cursor] user; the Concierge → {decisions, cursor?} (full records)
|
|
6
|
+
// GET /api/agent/concierge/decisions/:id user; the Concierge → {decision}
|
|
7
|
+
//
|
|
8
|
+
// Who: users (their own), and the user's CURRENT Concierge session through its act token (canActAsSession and
|
|
9
|
+
// ConciergeIdentity.current, checked on every call). Every other credential (app tokens of any scope, shell tokens,
|
|
10
|
+
// other sessions, a previous Concierge) gets 404 before anything is read: the routes do not reveal themselves. The
|
|
11
|
+
// Decisions routes are the one new read of Decision text (question, options, recommendation): the Concierge needs it
|
|
12
|
+
// to explain a Decision, and its user opted in by enabling it. The board itself never carries that text.
|
|
13
|
+
//
|
|
14
|
+
// One render reads: the watchlist config, ONE catalog snapshot (shared with the watchlist resolution), the user's
|
|
15
|
+
// directory records, the activity tracker, ONE open-Decisions list (shared too), launch labels, then for the shown
|
|
16
|
+
// sessions only (≤ CONCIERGE_BOARD_LIMITS.sessions) one batched directory lookup, the stored intents (many) and the
|
|
17
|
+
// last results (many): never the whole intents collection, never a spoke, never a model. Online state comes from the
|
|
18
|
+
// registry (this hub's catalog), so a session the directory evicted (it keeps at most 2000 records per user) is
|
|
19
|
+
// still on the board while it is reported. The only write is the board's revision record (board.ts), and only when
|
|
20
|
+
// the content changed. No journal event.
|
|
21
|
+
import { LIST_LIMIT_MAX } from "../../protocol"
|
|
22
|
+
import { CONCIERGE_BOARD_LIMITS, CONCIERGE_BOARD_PENDING_MAX, type ConciergeBoardMachine, type ConciergeBoardState, type ConciergeBoardView } from "../../../packages/protocol/src/concierge"
|
|
23
|
+
import { type AgentActor, canActAsSession } from "../agent/actor"
|
|
24
|
+
import { DECISION_STATES, type Decision, type Decisions, type DecisionState } from "../agent/decisions"
|
|
25
|
+
import type { Directory } from "../agent/directory"
|
|
26
|
+
import { AgentError } from "../agent/errors"
|
|
27
|
+
import type { SessionIntents } from "../agent/intents"
|
|
28
|
+
import type { ReadRegistry } from "../agent/read-routes"
|
|
29
|
+
import type { AgentRoute } from "../agent/routes"
|
|
30
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
31
|
+
import type { CatalogMachine, CatalogSession } from "../registry"
|
|
32
|
+
import type { Store } from "../store"
|
|
33
|
+
import type { ActivityTracker } from "./activity"
|
|
34
|
+
import { BoardRevisions, buildBoard, type BoardContent, type BoardDecisionInput, type BoardInput, type BoardRoot } from "./board"
|
|
35
|
+
import type { ResultTracker } from "./board-results"
|
|
36
|
+
import { frame, renderBoard } from "./board-render"
|
|
37
|
+
import { resolveWatchlist, rootsOf, type WatchFact } from "./watchlist"
|
|
38
|
+
import { type ConciergeIdentity, NO_CONCIERGE, watchFacts, WatchlistStore } from "./watchlist-routes"
|
|
39
|
+
|
|
40
|
+
/** Literal, not from AGENT_PREFIX (routes.ts imports this module). */
|
|
41
|
+
const PREFIX = "/api/agent/concierge"
|
|
42
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
43
|
+
const DECISIONS_PAGE = 50
|
|
44
|
+
|
|
45
|
+
export interface BoardRouteDeps {
|
|
46
|
+
readonly state: StateStore
|
|
47
|
+
readonly activity: Pick<ActivityTracker, "list">
|
|
48
|
+
readonly directory: Pick<Directory, "entries">
|
|
49
|
+
readonly store: Pick<Store, "agentData" | "viewPrefs" | "machines">
|
|
50
|
+
readonly registry: Pick<ReadRegistry, "catalog" | "sessionTenant">
|
|
51
|
+
readonly decisions?: Pick<Decisions, "list" | "get">
|
|
52
|
+
/** Launched sessions' labels (the latest launch wins), for label filters. */
|
|
53
|
+
readonly labels?: (userID: string) => Promise<ReadonlyMap<string, Readonly<Record<string, string>>>>
|
|
54
|
+
readonly intents?: Pick<SessionIntents, "many">
|
|
55
|
+
readonly results?: Pick<ResultTracker, "many">
|
|
56
|
+
readonly identity?: ConciergeIdentity
|
|
57
|
+
/** (CON-06) The Decision armed now by the current Concierge; a failure shows none armed. */
|
|
58
|
+
readonly armed?: (userID: string) => Promise<string | undefined>
|
|
59
|
+
/** (CON-10) The user's time zone (Concierge settings): the rendered board's time is local. */
|
|
60
|
+
readonly timeZone?: (userID: string) => Promise<string | undefined>
|
|
61
|
+
/**
|
|
62
|
+
* (CON-13) Whether the current Concierge's grant would approve this pending request once (`inGrant`, Acting.covers:
|
|
63
|
+
* the act verb's own check, without acting), and whether it may do so without the user (`auto`: not the tap risk
|
|
64
|
+
* tier, Acting.coverage). Absent, or no current Concierge: every permission reads inGrant=no, auto=no.
|
|
65
|
+
*/
|
|
66
|
+
readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
|
|
67
|
+
readonly now?: Clock
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const notFound = () => new AgentError("not_found", "Not found")
|
|
71
|
+
const STATES: ReadonlySet<string> = new Set<ConciergeBoardState>(["idle", "working", "blocked", "error"])
|
|
72
|
+
const RANK: Record<string, number> = { error: 4, blocked: 3, working: 2, idle: 1 }
|
|
73
|
+
|
|
74
|
+
/** The live catalog session of each wanted ID this user owns (the most recently updated report wins), with its machine. */
|
|
75
|
+
function liveSessions(catalog: readonly CatalogMachine[], wanted: ReadonlySet<string>, owns: (id: string) => boolean) {
|
|
76
|
+
const out = new Map<string, { session: CatalogSession; machine: string }>()
|
|
77
|
+
for (const machine of catalog) {
|
|
78
|
+
for (const process of machine.processes) {
|
|
79
|
+
for (const s of process.sessions) {
|
|
80
|
+
if (!wanted.has(s.id)) continue
|
|
81
|
+
const known = out.get(s.id)
|
|
82
|
+
if (known && known.session.updated >= s.updated) continue
|
|
83
|
+
if (!owns(s.id)) continue
|
|
84
|
+
out.set(s.id, { session: s, machine: machine.name })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Builds one user's board. Read-only except for the revision record. */
|
|
92
|
+
export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevisions, userID: string, hidden: readonly string[]): Promise<ConciergeBoardView> {
|
|
93
|
+
const now = (deps.now ?? Date.now)()
|
|
94
|
+
const catalog = deps.registry.catalog()
|
|
95
|
+
// One open-Decisions list for both the resolution and the board; a failure fails the board's section only.
|
|
96
|
+
const open: Promise<{ ok: true; list: Decision[] } | { ok: false }> = deps.decisions
|
|
97
|
+
? deps.decisions.list(userID, { state: "open" }).then(
|
|
98
|
+
(list) => ({ ok: true as const, list }),
|
|
99
|
+
(cause: unknown) => (console.error(`[hub] concierge board: decisions failed: ${(cause as Error).message}`), { ok: false as const }),
|
|
100
|
+
)
|
|
101
|
+
: Promise.resolve({ ok: true as const, list: [] })
|
|
102
|
+
const [config, facts] = await Promise.all([
|
|
103
|
+
new WatchlistStore(deps.state).get(userID),
|
|
104
|
+
watchFacts({ ...deps, decisions: { list: async () => { const r = await open; return r.ok ? r.list : [] } } }, userID, hidden, catalog),
|
|
105
|
+
])
|
|
106
|
+
const { entries, truncated } = resolveWatchlist(config, facts)
|
|
107
|
+
|
|
108
|
+
// Tree states of every watched root (for the counts), from the same facts.
|
|
109
|
+
const roots = rootsOf(facts.sessions)
|
|
110
|
+
const rootOf = (id: string) => roots.get(id) ?? id
|
|
111
|
+
const treeStates = new Map<string, ConciergeBoardState>()
|
|
112
|
+
for (const f of facts.sessions) {
|
|
113
|
+
const id = rootOf(f.sessionID)
|
|
114
|
+
const own = f.blockers > 0 ? "blocked" : f.state
|
|
115
|
+
if (own === undefined || !STATES.has(own)) continue
|
|
116
|
+
const known = treeStates.get(id)
|
|
117
|
+
if (known === undefined || (RANK[own] ?? 0) > (RANK[known] ?? 0)) treeStates.set(id, own as ConciergeBoardState)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Facts of the shown roots: one directory lookup, the live catalog sessions, intents and results (many).
|
|
121
|
+
const shown = entries.slice(0, CONCIERGE_BOARD_LIMITS.sessions).map((e) => e.sessionID)
|
|
122
|
+
const wanted = new Set(shown)
|
|
123
|
+
const live = liveSessions(catalog, wanted, (id) => deps.registry.sessionTenant(id) === userID)
|
|
124
|
+
const records = deps.directory.entries(userID, shown)
|
|
125
|
+
const ownMachines = deps.store.machines().filter((m) => m.ownerID === userID && !m.revoked)
|
|
126
|
+
const machineName = new Map(ownMachines.map((m) => [m.id, m.name]))
|
|
127
|
+
const slugs = new Map(facts.sessions.filter((f) => f.projectSlug !== undefined).map((f) => [f.sessionID, f.projectSlug!]))
|
|
128
|
+
const coverage = await coverageOf(deps, userID, live)
|
|
129
|
+
const [intents, results, decisions, armed] = await Promise.all([
|
|
130
|
+
deps.intents ? deps.intents.many(userID, shown) : new Map(),
|
|
131
|
+
deps.results ? deps.results.many(userID, shown) : new Map(),
|
|
132
|
+
open,
|
|
133
|
+
deps.armed ? deps.armed(userID).catch((cause: unknown) => (console.error(`[hub] concierge board: armed failed: ${(cause as Error).message}`), undefined)) : undefined,
|
|
134
|
+
])
|
|
135
|
+
const rootFacts = new Map<string, BoardRoot>()
|
|
136
|
+
for (const id of shown) {
|
|
137
|
+
const record = records.get(id)
|
|
138
|
+
const reported = live.get(id)
|
|
139
|
+
const common = {
|
|
140
|
+
sessionID: id,
|
|
141
|
+
...(record?.name ? { name: `@${record.name}` } : {}),
|
|
142
|
+
...((record?.project ?? slugs.get(id)) ? { project: record?.project ?? slugs.get(id)! } : {}),
|
|
143
|
+
}
|
|
144
|
+
if (reported) {
|
|
145
|
+
const s = reported.session
|
|
146
|
+
const blockers = s.blockers.map((b) => ({ kind: b.kind, id: b.id, ...(b.action !== undefined ? { action: b.action } : {}), ...(b.kind === "permission" ? { inGrant: coverage.get(b.id)?.inGrant === true, auto: coverage.get(b.id)?.auto === true } : {}) }))
|
|
147
|
+
rootFacts.set(id, { ...common, machine: reported.machine, state: STATES.has(s.state) ? (s.state as ConciergeBoardState) : "idle", online: true, title: s.title, blockers })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
const machine = record?.machineID !== undefined ? machineName.get(record.machineID) : undefined
|
|
151
|
+
const base = { ...common, ...(machine ? { machine } : {}), ...(record?.title ? { title: record.title } : {}) }
|
|
152
|
+
// Online per the (shared) directory but not in this hub's catalog: state as last known, blockers not reported.
|
|
153
|
+
if (record?.online) rootFacts.set(id, { ...base, state: record.state && STATES.has(record.state) ? (record.state as ConciergeBoardState) : "idle", online: true })
|
|
154
|
+
else rootFacts.set(id, { ...base, state: "offline", online: false, ...(record?.lastSeen !== undefined ? { lastSeen: record.lastSeen } : {}) })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const onlineMachines = new Set(catalog.filter((m) => m.processes.length > 0).map((m) => m.id))
|
|
158
|
+
const machines: ConciergeBoardMachine[] = ownMachines
|
|
159
|
+
.map((m) => ({ id: m.id, name: m.name, online: onlineMachines.has(m.id) }))
|
|
160
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.id < b.id ? -1 : 1))
|
|
161
|
+
|
|
162
|
+
const input: BoardInput = {
|
|
163
|
+
now,
|
|
164
|
+
entries,
|
|
165
|
+
truncated,
|
|
166
|
+
roots: rootFacts,
|
|
167
|
+
treeStates,
|
|
168
|
+
rootOf,
|
|
169
|
+
intents,
|
|
170
|
+
results,
|
|
171
|
+
decisions: !deps.decisions ? { status: "unsupported", feature: "decisions" } : decisions.ok ? (decisions.list as BoardDecisionInput[]) : { status: "failed" },
|
|
172
|
+
hidden,
|
|
173
|
+
machines,
|
|
174
|
+
...(armed !== undefined ? { armed } : {}),
|
|
175
|
+
}
|
|
176
|
+
const content: BoardContent = buildBoard(input)
|
|
177
|
+
const board = { revision: await revisions.stamp(userID, content), ...content }
|
|
178
|
+
const timeZone = deps.timeZone ? await deps.timeZone(userID).catch(() => undefined) : undefined
|
|
179
|
+
const rendered = renderBoard(board, timeZone !== undefined ? { timeZone } : {})
|
|
180
|
+
const extra = [
|
|
181
|
+
...(entries.length ? [] : [allSessionsLine(facts.sessions, rootOf, treeStates, hidden)]),
|
|
182
|
+
...mutedLine(deps, userID, config.muted, config.mutedProjects.length, hidden),
|
|
183
|
+
]
|
|
184
|
+
return { board, rendered: [rendered, ...extra].join("\n") }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* (CON-13) inGrant of the shown sessions' pending permissions (at most CONCIERGE_BOARD_PENDING_MAX each), by request
|
|
189
|
+
* ID: the current Concierge's grant would approve it once. A failure reads as not covered.
|
|
190
|
+
*/
|
|
191
|
+
async function coverageOf(deps: BoardRouteDeps, userID: string, live: ReadonlyMap<string, { session: CatalogSession }>): Promise<Map<string, { inGrant: boolean; auto: boolean }>> {
|
|
192
|
+
const out = new Map<string, { inGrant: boolean; auto: boolean }>()
|
|
193
|
+
if (!deps.coverage || !deps.identity) return out
|
|
194
|
+
const conciergeID = await deps.identity.current(userID).then((c) => c.sessionID, () => undefined)
|
|
195
|
+
if (conciergeID === undefined) return out
|
|
196
|
+
const asks: Array<Promise<void>> = []
|
|
197
|
+
for (const { session } of live.values()) {
|
|
198
|
+
for (const b of session.blockers.filter((x) => x.kind === "permission").slice(0, CONCIERGE_BOARD_PENDING_MAX)) {
|
|
199
|
+
asks.push(deps.coverage(userID, conciergeID, b.sessionID ?? session.id, b.id).then((c) => void out.set(b.id, c), () => void out.set(b.id, { inGrant: false, auto: false })))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
await Promise.all(asks)
|
|
203
|
+
return out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Muted sessions named on the line (the rest counted). */
|
|
207
|
+
const MUTED_SHOWN = 20
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* (CON-10) The user's muted sessions, so the Concierge says "muted" rather than that it cannot see them: the session
|
|
211
|
+
* ID and its framed @name (the directory's, this user's records only; never a title), at most MUTED_SHOWN, and the
|
|
212
|
+
* number of muted projects (never their paths). Never the Concierge's own sessions. No line when nothing is muted.
|
|
213
|
+
*/
|
|
214
|
+
function mutedLine(deps: BoardRouteDeps, userID: string, muted: readonly string[], projects: number, hidden: readonly string[]): string[] {
|
|
215
|
+
const own = new Set(hidden)
|
|
216
|
+
const records = deps.directory.entries(userID, muted.filter((id) => !own.has(id)))
|
|
217
|
+
const ids = muted.filter((id) => records.has(id))
|
|
218
|
+
if (!ids.length && !projects) return []
|
|
219
|
+
const all = ids.slice(0, MUTED_SHOWN).map((id) => {
|
|
220
|
+
const name = records.get(id)?.name
|
|
221
|
+
return name ? `${id} ${frame(`@${name}`, 80)}` : id
|
|
222
|
+
})
|
|
223
|
+
const head = "Muted by the user (not watched; say they are muted): "
|
|
224
|
+
const tail = (shown: number) => [
|
|
225
|
+
...(ids.length > shown ? [`${shown ? " " : ""}(${ids.length - shown} more)`] : []),
|
|
226
|
+
...(projects ? [`${shown || ids.length ? "; " : ""}${projects} muted project${projects === 1 ? "" : "s"}`] : []),
|
|
227
|
+
".",
|
|
228
|
+
].join("")
|
|
229
|
+
// Names go first when the line is too long: the count of the rest and the muted projects always stay.
|
|
230
|
+
let named = all
|
|
231
|
+
while (named.length && head.length + named.join(", ").length + tail(named.length).length > MUTED_LINE_MAX) named = named.slice(0, -1)
|
|
232
|
+
return [`${head}${named.join(", ")}${tail(named.length)}`]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The muted line's length (whole names only). */
|
|
236
|
+
const MUTED_LINE_MAX = 1_000
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* (CON-09a) Nothing watched: one line of counts over ALL the user's root sessions (not archived or deleted, never the
|
|
240
|
+
* Concierge's own), so "what is running?" has an answer. Counts only: no names, titles or IDs.
|
|
241
|
+
*/
|
|
242
|
+
function allSessionsLine(sessions: readonly WatchFact[], rootOf: (id: string) => string, treeStates: ReadonlyMap<string, ConciergeBoardState>, hidden: readonly string[]): string {
|
|
243
|
+
const own = new Set(hidden.map(rootOf))
|
|
244
|
+
const counts = new Map<string, number>()
|
|
245
|
+
let total = 0
|
|
246
|
+
for (const f of sessions) {
|
|
247
|
+
if (rootOf(f.sessionID) !== f.sessionID || own.has(f.sessionID) || f.archived || f.deleted) continue
|
|
248
|
+
total++
|
|
249
|
+
const state = treeStates.get(f.sessionID) ?? (f.state === "offline" || f.state === undefined ? "offline" : "idle")
|
|
250
|
+
counts.set(state, (counts.get(state) ?? 0) + 1)
|
|
251
|
+
}
|
|
252
|
+
if (total === 0) return "All your sessions: none; none watched."
|
|
253
|
+
if ((counts.get("idle") ?? 0) === total) return `All your sessions: ${total}, all idle; none watched.`
|
|
254
|
+
const parts = (["working", "blocked", "error", "offline"] as const).filter((k) => counts.get(k)).map((k) => `${counts.get(k)} ${k === "error" ? "in error" : k}`)
|
|
255
|
+
return `All your sessions: ${total} (${parts.join(", ")}); none watched.`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- Decisions paging ----------------------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
const encodeCursor = (d: Decision) => Buffer.from(JSON.stringify([d.at, d.id])).toString("base64url")
|
|
261
|
+
|
|
262
|
+
function decodeCursor(raw: string): [number, string] {
|
|
263
|
+
try {
|
|
264
|
+
const value: unknown = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"))
|
|
265
|
+
if (Array.isArray(value) && value.length === 2 && Number.isSafeInteger(value[0]) && typeof value[1] === "string") return [value[0], value[1]]
|
|
266
|
+
} catch {}
|
|
267
|
+
throw new AgentError("invalid", "cursor is not one this route gave")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function pageQuery(url: URL): { state?: DecisionState; limit: number; after?: [number, string] } {
|
|
271
|
+
const state = url.searchParams.get("state")
|
|
272
|
+
if (state !== null && state !== "" && !DECISION_STATES.includes(state as DecisionState)) throw new AgentError("invalid", `state is one of ${DECISION_STATES.join(", ")}`)
|
|
273
|
+
const rawLimit = url.searchParams.get("limit")
|
|
274
|
+
const limit = rawLimit === null ? DECISIONS_PAGE : Number(rawLimit)
|
|
275
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit is 1–${LIST_LIMIT_MAX}`)
|
|
276
|
+
const cursor = url.searchParams.get("cursor")
|
|
277
|
+
return { ...(state ? { state: state as DecisionState } : {}), limit, ...(cursor !== null ? { after: decodeCursor(cursor) } : {}) }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Newest first (at desc, then id desc, as Decisions.list sorts): the items strictly after the cursor. */
|
|
281
|
+
const isAfter = (d: Decision, [at, id]: [number, string]) => d.at < at || (d.at === at && d.id < id)
|
|
282
|
+
|
|
283
|
+
// ---- Routes ----------------------------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
export function boardRoutes(deps: BoardRouteDeps): AgentRoute[] {
|
|
286
|
+
const identity = deps.identity ?? NO_CONCIERGE
|
|
287
|
+
const revisions = new BoardRevisions(deps.state)
|
|
288
|
+
|
|
289
|
+
/** Users, and the user's current Concierge through its act token; anyone else is 404. */
|
|
290
|
+
const admit = async (actor: AgentActor, userID: string) => {
|
|
291
|
+
if (actor.kind === "app") throw notFound()
|
|
292
|
+
const current = await identity.current(userID)
|
|
293
|
+
if (actor.kind === "session" && !(canActAsSession(actor) && current.sessionID !== undefined && current.sessionID === actor.sessionID)) throw notFound()
|
|
294
|
+
return current
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const routes: AgentRoute[] = [
|
|
298
|
+
{
|
|
299
|
+
method: "GET",
|
|
300
|
+
path: `${PREFIX}/board`,
|
|
301
|
+
allow: { session: true, app: true },
|
|
302
|
+
async handle({ actor, user }) {
|
|
303
|
+
const current = await admit(actor, user.id)
|
|
304
|
+
return Response.json(await conciergeBoard(deps, revisions, user.id, current.hiddenSessionIDs), { headers: NO_STORE })
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
]
|
|
308
|
+
const { decisions } = deps
|
|
309
|
+
if (decisions) {
|
|
310
|
+
routes.push(
|
|
311
|
+
{
|
|
312
|
+
method: "GET",
|
|
313
|
+
path: `${PREFIX}/decisions`,
|
|
314
|
+
allow: { session: true, app: true },
|
|
315
|
+
async handle({ actor, user, url }) {
|
|
316
|
+
await admit(actor, user.id)
|
|
317
|
+
const query = pageQuery(url)
|
|
318
|
+
const all = await decisions.list(user.id, query.state ? { state: query.state } : {})
|
|
319
|
+
const after = query.after ? all.filter((d) => isAfter(d, query.after!)) : all
|
|
320
|
+
const page = after.slice(0, query.limit)
|
|
321
|
+
const more = after.length > page.length
|
|
322
|
+
return Response.json({ decisions: page.map(conciergeDecisionView), ...(more ? { cursor: encodeCursor(page.at(-1)!) } : {}) }, { headers: NO_STORE })
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
method: "GET",
|
|
327
|
+
path: `${PREFIX}/decisions/:id`,
|
|
328
|
+
allow: { session: true, app: true },
|
|
329
|
+
async handle({ actor, user, params }) {
|
|
330
|
+
await admit(actor, user.id)
|
|
331
|
+
const decision = await decisions.get(user.id, params.id!)
|
|
332
|
+
if (!decision) throw new AgentError("not_found", "No such decision")
|
|
333
|
+
return Response.json({ decision: conciergeDecisionView(decision) }, { headers: NO_STORE })
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
return routes
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* (FX-05, coordinator decision: Concierge prompt-injection surface) A Decision as these routes return it: exactly
|
|
343
|
+
* the record they returned before FX-05, without the structured request and reason the hub now stores on its action
|
|
344
|
+
* for the user's console (GET /api/decisions) only.
|
|
345
|
+
*/
|
|
346
|
+
export function conciergeDecisionView(decision: Decision): Decision {
|
|
347
|
+
if (!decision.action || (!("request" in decision.action) && !("reason" in decision.action))) return decision
|
|
348
|
+
const { request: _request, reason: _reason, ...action } = decision.action as typeof decision.action & { request?: unknown; reason?: unknown }
|
|
349
|
+
return { ...decision, action: action as Decision["action"] }
|
|
350
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// CON-03 situation board (contract: docs/plans/2026-11-01-concierge-contracts.md, "Situation board (CON-03)"): the
|
|
2
|
+
// pure builder over the watchlist and the hub's own facts, and the per-user revision record. Modelled on
|
|
3
|
+
// workspaces/board.ts: the same section envelope (ok | omitted | unsupported | failed) and truthfulness rules
|
|
4
|
+
// (blockers `{reported: false}` when this hub's catalog does not report the session now, never "none").
|
|
5
|
+
//
|
|
6
|
+
// What a session item carries: IDs, the @name, project and machine labels, the tree state, when it began, blocker
|
|
7
|
+
// count and kinds, open Decision IDs, the stored intent (else the non-placeholder title), the last result and the
|
|
8
|
+
// watch reasons. What it never carries: prompts, message bodies, blocker summaries, Decision questions, options,
|
|
9
|
+
// recommendations or answers, launch reasons, notes, intent writers. Every string from a session (intent, title,
|
|
10
|
+
// machine and party labels) is untrusted: sanitised (boardText: control, ANSI, bidi, zero-width, tag and filler
|
|
11
|
+
// characters go), one line and cut here; the renderer frames it in «…».
|
|
12
|
+
//
|
|
13
|
+
// Decisions shown: open ones about a watched tree (about or the asker, rolled up to the root), sessionless ones (an
|
|
14
|
+
// app's or the user's, about nothing) and the Concierge's own (asked by a hidden session). One only about sessions
|
|
15
|
+
// the watchlist leaves out (muted, unwatched) is not shown: the narrower reading of the contract (CON-03
|
|
16
|
+
// clarification). Newest first, CONCIERGE_BOARD_LIMITS.decisions; per session at most decisionsPerSession IDs.
|
|
17
|
+
//
|
|
18
|
+
// Revision: monotonic per user, stored in StateStore collection `concierge-board` key `revision` as {revision,
|
|
19
|
+
// digest} (a SHA-256 of the content without `at`), version-checked, so it changes only when the content changes and
|
|
20
|
+
// concurrent renders of one change bump it once. Nothing else is written.
|
|
21
|
+
import { createHash } from "node:crypto"
|
|
22
|
+
import {
|
|
23
|
+
CONCIERGE_BOARD_LIMITS,
|
|
24
|
+
CONCIERGE_BOARD_PENDING_MAX,
|
|
25
|
+
INTENT_MAX,
|
|
26
|
+
type ConciergeBoard,
|
|
27
|
+
type ConciergeBoardBlocker,
|
|
28
|
+
type ConciergeBoardDecision,
|
|
29
|
+
type ConciergeBoardMachine,
|
|
30
|
+
type ConciergeBoardSection,
|
|
31
|
+
type ConciergeBoardSession,
|
|
32
|
+
type ConciergeBoardState,
|
|
33
|
+
type ConciergeResultOutcome,
|
|
34
|
+
type SessionIntent,
|
|
35
|
+
type WatchlistEntry,
|
|
36
|
+
} from "../../../packages/protocol/src/concierge"
|
|
37
|
+
import type { Decision } from "../agent/decisions"
|
|
38
|
+
import { partyLabel } from "../agent/envelope"
|
|
39
|
+
import { isPlaceholderTitle } from "../agent/names"
|
|
40
|
+
import { updateVersioned } from "../agent/versioned"
|
|
41
|
+
import type { StateStore } from "../backend/types"
|
|
42
|
+
import { boardText } from "./board-render"
|
|
43
|
+
|
|
44
|
+
export const BOARD_COLLECTION = "concierge-board"
|
|
45
|
+
const REVISION_KEY = "revision"
|
|
46
|
+
/** Party labels on the board (sanitised, one line). */
|
|
47
|
+
const FROM_MAX = 80
|
|
48
|
+
/** Machine, project and name labels. */
|
|
49
|
+
const LABEL_CHARS = 80
|
|
50
|
+
|
|
51
|
+
/** What the hub knows about a shown root session now (directory and catalog; never a spoke). */
|
|
52
|
+
export interface BoardRoot {
|
|
53
|
+
readonly sessionID: string
|
|
54
|
+
/** "@name". */
|
|
55
|
+
readonly name?: string
|
|
56
|
+
readonly project?: string
|
|
57
|
+
readonly machine?: string
|
|
58
|
+
/** The tree state (the catalog rolls blocked/error/working up to the root); offline when no spoke reports it. */
|
|
59
|
+
readonly state: ConciergeBoardState
|
|
60
|
+
readonly online: boolean
|
|
61
|
+
/** The directory's lastSeen (offline sessions). */
|
|
62
|
+
readonly lastSeen?: number
|
|
63
|
+
readonly title?: string
|
|
64
|
+
/** Open blockers in the tree when this hub's catalog reports it; absent: not reported. Summaries are never read. */
|
|
65
|
+
readonly blockers?: readonly { readonly kind: string; readonly id?: string; readonly action?: string; readonly inGrant?: boolean; readonly auto?: boolean }[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The Decision fields the board reads (never question, options, recommendation or answer). */
|
|
69
|
+
export type BoardDecisionInput = Pick<Decision, "id" | "from" | "about" | "urgency" | "tier" | "at" | "deadline" | "state">
|
|
70
|
+
|
|
71
|
+
export interface LastResult {
|
|
72
|
+
readonly at: number
|
|
73
|
+
readonly outcome: ConciergeResultOutcome
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BoardInput {
|
|
77
|
+
readonly now: number
|
|
78
|
+
/** The resolved watchlist (≤ 200, priority order). */
|
|
79
|
+
readonly entries: readonly WatchlistEntry[]
|
|
80
|
+
readonly truncated: boolean
|
|
81
|
+
/** Facts of the shown roots (the first CONCIERGE_BOARD_LIMITS.sessions entries). */
|
|
82
|
+
readonly roots: ReadonlyMap<string, BoardRoot>
|
|
83
|
+
/** Tree state of every watched root (for the counts). */
|
|
84
|
+
readonly treeStates: ReadonlyMap<string, ConciergeBoardState>
|
|
85
|
+
/** The root of a session (subagents roll up). */
|
|
86
|
+
readonly rootOf: (sessionID: string) => string
|
|
87
|
+
/** Stored intents of the shown roots. */
|
|
88
|
+
readonly intents: ReadonlyMap<string, SessionIntent>
|
|
89
|
+
/** Last results of the shown roots. */
|
|
90
|
+
readonly results: ReadonlyMap<string, LastResult>
|
|
91
|
+
/** The user's open Decisions, or why there are none to show. */
|
|
92
|
+
readonly decisions: readonly BoardDecisionInput[] | { readonly status: "unsupported"; readonly feature: string } | { readonly status: "failed" }
|
|
93
|
+
/** The Concierge's own sessions (current and previous). */
|
|
94
|
+
readonly hidden: readonly string[]
|
|
95
|
+
readonly machines: readonly ConciergeBoardMachine[]
|
|
96
|
+
/** The Decision armed now (CON-06): its board item carries `armed: true`. */
|
|
97
|
+
readonly armed?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type BoardContent = Omit<ConciergeBoard, "revision">
|
|
101
|
+
|
|
102
|
+
/** Sanitised, one line, at most `max` characters (code points); undefined when nothing is left. */
|
|
103
|
+
function label(text: string | undefined, max: number): string | undefined {
|
|
104
|
+
if (text === undefined) return undefined
|
|
105
|
+
return boardText(text, max) || undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Blocker kinds are hub enums (permission, form, …); anything else is shown as "other", never as free text. */
|
|
109
|
+
const BLOCKER_KIND = /^[a-z][a-z_]{0,31}$/
|
|
110
|
+
|
|
111
|
+
/** (CON-13) Blocker IDs the board names (spoke IDs: per_…, frm_…, que_…). */
|
|
112
|
+
const BLOCKER_ID = /^[A-Za-z0-9_-]{1,64}$/
|
|
113
|
+
/** Permission actions are tool or permission names (edit, shell, external_directory, an MCP tool…). */
|
|
114
|
+
const ACTION = /^[A-Za-z][A-Za-z0-9_.:-]{0,39}$/
|
|
115
|
+
|
|
116
|
+
/** (CON-13) The pending permissions (with the hub's inGrant) and forms the Concierge may act on, by ID. */
|
|
117
|
+
function pendingOf(blockers: NonNullable<BoardRoot["blockers"]>): ConciergeBoardBlocker[] {
|
|
118
|
+
const out: ConciergeBoardBlocker[] = []
|
|
119
|
+
for (const b of blockers) {
|
|
120
|
+
if (out.length >= CONCIERGE_BOARD_PENDING_MAX) break
|
|
121
|
+
if (b.id === undefined || !BLOCKER_ID.test(b.id)) continue
|
|
122
|
+
if (b.kind === "permission") out.push({ kind: "permission", id: b.id, action: b.action !== undefined && ACTION.test(b.action) ? b.action : "other", inGrant: b.inGrant === true, auto: b.inGrant === true && b.auto === true })
|
|
123
|
+
else if (b.kind === "form") out.push({ kind: "form", id: b.id })
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const COUNTED: ReadonlySet<ConciergeBoardState> = new Set(["working", "blocked", "error"])
|
|
129
|
+
|
|
130
|
+
function sessionItem(entry: WatchlistEntry, input: BoardInput, decisions: readonly string[]): ConciergeBoardSession {
|
|
131
|
+
const r = input.roots.get(entry.sessionID)
|
|
132
|
+
const state: ConciergeBoardState = r?.state ?? "offline"
|
|
133
|
+
const result = input.results.get(entry.sessionID)
|
|
134
|
+
const since = state === "offline" ? r?.lastSeen : state === "idle" ? result?.at : entry.lastActiveAt
|
|
135
|
+
const stored = input.intents.get(entry.sessionID)
|
|
136
|
+
const intentText = stored ? label(stored.text, INTENT_MAX) : undefined
|
|
137
|
+
const title = intentText === undefined && r && !isPlaceholderTitle(r.title) ? label(r.title, CONCIERGE_BOARD_LIMITS.titleChars) : undefined
|
|
138
|
+
const blockers = r?.blockers
|
|
139
|
+
const name = label(r?.name, LABEL_CHARS)
|
|
140
|
+
const project = label(r?.project, LABEL_CHARS)
|
|
141
|
+
const machine = label(r?.machine, LABEL_CHARS)
|
|
142
|
+
return {
|
|
143
|
+
sessionID: entry.sessionID,
|
|
144
|
+
...(name ? { name } : {}),
|
|
145
|
+
...(project ? { project } : {}),
|
|
146
|
+
...(machine ? { machine } : {}),
|
|
147
|
+
state,
|
|
148
|
+
...(since !== undefined ? { since } : {}),
|
|
149
|
+
blockers: blockers ? { reported: true, count: blockers.length, kinds: [...new Set(blockers.map((b) => (BLOCKER_KIND.test(b.kind) ? b.kind : "other")))].sort(), ...pendingField(blockers) } : { reported: false },
|
|
150
|
+
decisions,
|
|
151
|
+
...(intentText !== undefined && stored ? { intent: { text: intentText, source: stored.source } } : {}),
|
|
152
|
+
...(title !== undefined ? { title } : {}),
|
|
153
|
+
...(result ? { lastResult: { at: result.at, outcome: result.outcome } } : {}),
|
|
154
|
+
reasons: [...entry.reasons],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pendingField = (blockers: NonNullable<BoardRoot["blockers"]>) => {
|
|
159
|
+
const pending = pendingOf(blockers)
|
|
160
|
+
return pending.length ? { pending } : {}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Builds the board's content (everything but the revision). Pure. */
|
|
164
|
+
export function buildBoard(input: BoardInput): BoardContent {
|
|
165
|
+
const limits = CONCIERGE_BOARD_LIMITS
|
|
166
|
+
const watched = new Set(input.entries.map((e) => e.sessionID))
|
|
167
|
+
const hidden = new Set(input.hidden)
|
|
168
|
+
|
|
169
|
+
// ---------- Decisions ----------
|
|
170
|
+
const perRoot = new Map<string, string[]>()
|
|
171
|
+
let decisions: ConciergeBoardSection<ConciergeBoardDecision>
|
|
172
|
+
let openDecisions = 0
|
|
173
|
+
if (!Array.isArray(input.decisions)) decisions = input.decisions as ConciergeBoardSection<ConciergeBoardDecision>
|
|
174
|
+
else {
|
|
175
|
+
const open = (input.decisions as readonly BoardDecisionInput[]).filter((d) => d.state === "open").sort((a, b) => b.at - a.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
176
|
+
const shown: ConciergeBoardDecision[] = []
|
|
177
|
+
for (const d of open) {
|
|
178
|
+
const asker = d.from.kind === "session" ? d.from.sessionID : undefined
|
|
179
|
+
const concerned = [...new Set([...(asker !== undefined ? [asker] : []), ...d.about])]
|
|
180
|
+
const roots = [...new Set(concerned.map(input.rootOf))].filter((id) => watched.has(id))
|
|
181
|
+
const own = asker !== undefined && hidden.has(asker)
|
|
182
|
+
if (roots.length === 0 && concerned.length > 0 && !own) continue
|
|
183
|
+
openDecisions++
|
|
184
|
+
for (const id of roots) {
|
|
185
|
+
const list = perRoot.get(id) ?? []
|
|
186
|
+
if (list.length < limits.decisionsPerSession) list.push(d.id)
|
|
187
|
+
perRoot.set(id, list)
|
|
188
|
+
}
|
|
189
|
+
if (shown.length >= limits.decisions) continue
|
|
190
|
+
shown.push({
|
|
191
|
+
id: d.id,
|
|
192
|
+
from: own ? "@@concierge" : (label(partyLabel(d.from), FROM_MAX) ?? "unknown"),
|
|
193
|
+
about: d.about.slice(0, limits.decisionsPerSession),
|
|
194
|
+
urgency: d.urgency,
|
|
195
|
+
tier: d.tier,
|
|
196
|
+
at: d.at,
|
|
197
|
+
...(d.deadline !== undefined ? { deadline: d.deadline } : {}),
|
|
198
|
+
...(input.armed === d.id ? { armed: true as const } : {}),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
decisions = { status: "ok", items: shown, truncated: openDecisions > shown.length }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------- sessions ----------
|
|
205
|
+
const items = input.entries.slice(0, limits.sessions).map((entry) => sessionItem(entry, input, perRoot.get(entry.sessionID) ?? []))
|
|
206
|
+
const counts = { watched: input.entries.length, working: 0, blocked: 0, error: 0, openDecisions }
|
|
207
|
+
for (const entry of input.entries) {
|
|
208
|
+
const state = input.treeStates.get(entry.sessionID) ?? input.roots.get(entry.sessionID)?.state
|
|
209
|
+
if (state !== undefined && COUNTED.has(state)) counts[state as "working" | "blocked" | "error"]++
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------- machines ----------
|
|
213
|
+
const machines = input.machines.slice(0, limits.machines).map((m) => ({ id: m.id, name: label(m.name, LABEL_CHARS) ?? m.id, online: m.online }))
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
at: input.now,
|
|
217
|
+
sessions: { status: "ok", items, truncated: input.truncated || input.entries.length > items.length },
|
|
218
|
+
decisions,
|
|
219
|
+
machines: { status: "ok", items: machines, truncated: input.machines.length > machines.length },
|
|
220
|
+
counts,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface RevisionRecord {
|
|
225
|
+
readonly revision: number
|
|
226
|
+
readonly digest: string
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The board content's digest: everything but `at`. */
|
|
230
|
+
const digestOf = (board: BoardContent) => {
|
|
231
|
+
const { at: _at, ...content } = board
|
|
232
|
+
return createHash("sha256").update(JSON.stringify(content)).digest("base64url")
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The per-user board revision: bumped (version-checked) only when the content's digest changes. */
|
|
236
|
+
export class BoardRevisions {
|
|
237
|
+
constructor(private readonly state: StateStore) {}
|
|
238
|
+
|
|
239
|
+
async stamp(userID: string, board: BoardContent): Promise<number> {
|
|
240
|
+
const digest = digestOf(board)
|
|
241
|
+
let kept = 0
|
|
242
|
+
const written = await updateVersioned<RevisionRecord>(this.state, userID, BOARD_COLLECTION, REVISION_KEY, (current) => {
|
|
243
|
+
kept = current?.revision ?? 0
|
|
244
|
+
if (current?.digest === digest) return undefined
|
|
245
|
+
return { revision: kept + 1, digest }
|
|
246
|
+
})
|
|
247
|
+
return written?.revision ?? kept
|
|
248
|
+
}
|
|
249
|
+
}
|