@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,273 @@
|
|
|
1
|
+
// CON-01 watchlist (contract: docs/plans/2026-11-01-concierge-contracts.md, "Watchlist (CON-01)"): which of a user's
|
|
2
|
+
// sessions the Concierge watches. Pure parts only: the resolution (resolveWatchlist) over facts gathered elsewhere
|
|
3
|
+
// (watchlist-routes.ts), the default config and the validation of an update. Nothing here reads a store or a spoke.
|
|
4
|
+
//
|
|
5
|
+
// Resolution: root sessions only (subagents roll up into their root: state, blockers, Decisions, activity, and the
|
|
6
|
+
// IDs in pins, explicit sessions and mutes). Included by reason:
|
|
7
|
+
// active the tree is working, blocked or in error, has an open blocker or an open Decision about it, or was
|
|
8
|
+
// active within activeWithinHours (autoActive)
|
|
9
|
+
// pinned viewPrefs.pinnedSessions (usePins)
|
|
10
|
+
// pinned_project viewPrefs.pinnedWorkspaces: `herdr:<id>` = the terminal workspace, else a folder matched as a
|
|
11
|
+
// project/directory prefix on path boundaries (inProjects) (usePins)
|
|
12
|
+
// explicit config.sessions
|
|
13
|
+
// project config.projects (inProjects)
|
|
14
|
+
// label a config.labels filter whose pairs all equal the launch's labels
|
|
15
|
+
// minus muted / mutedProjects (an offline session without a known directory: by its project slug), archived, deleted or
|
|
16
|
+
// gone sessions (CON-12: its machine is connected but no longer reports it) and every hidden (Concierge) session. Ordered by priority
|
|
17
|
+
// (error, blocked, open Decision, working, then most recent activity, then ID) and cut at WATCHLIST_LIMITS.entries.
|
|
18
|
+
// Notification mutes (store mutedSessions) are not inherited: they silence pushes, they do not mean "don't track".
|
|
19
|
+
import { WATCHLIST_DEFAULTS, WATCHLIST_LIMITS, type WatchlistConfig, type WatchlistEntry, type WatchReason } from "../../../packages/protocol/src/concierge"
|
|
20
|
+
import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
|
|
21
|
+
import { AgentError } from "../agent/errors"
|
|
22
|
+
import { LABEL_KEY_MAX, LABEL_VALUE_MAX, LABELS_MAX } from "../agent/launch-spec"
|
|
23
|
+
import { canonicalProject, inProjects } from "../agent/policy"
|
|
24
|
+
import { slugify } from "../agent/names"
|
|
25
|
+
import path from "node:path"
|
|
26
|
+
|
|
27
|
+
const HOUR_MS = 3_600_000
|
|
28
|
+
|
|
29
|
+
/** One session of the user (root or subagent) as the resolution sees it. */
|
|
30
|
+
export interface WatchFact {
|
|
31
|
+
readonly sessionID: string
|
|
32
|
+
readonly parentID?: string
|
|
33
|
+
/** The session's own state (idle, working, blocked, error, offline, …). */
|
|
34
|
+
readonly state?: string
|
|
35
|
+
/** Open blockers (permissions, questions) of this session. */
|
|
36
|
+
readonly blockers: number
|
|
37
|
+
readonly directory?: string
|
|
38
|
+
/** The project (workspace) root directory, when it differs from the directory. */
|
|
39
|
+
readonly project?: string
|
|
40
|
+
/**
|
|
41
|
+
* The directory's project slug (AgentSession.project), known offline too: when no directory is known, mutedProjects
|
|
42
|
+
* match on it (a basename slug, so it may over-mute; it never includes anything).
|
|
43
|
+
*/
|
|
44
|
+
readonly projectSlug?: string
|
|
45
|
+
/** The Herdr workspace it is open in. */
|
|
46
|
+
readonly herdrWorkspace?: string
|
|
47
|
+
readonly archived?: boolean
|
|
48
|
+
readonly deleted?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* (CON-12) Gone: its machine is connected and no longer reports it (deleted or closed there; the console no longer
|
|
51
|
+
* lists it). A session of an offline machine is only offline (it may come back) and stays watchable.
|
|
52
|
+
*/
|
|
53
|
+
readonly gone?: boolean
|
|
54
|
+
readonly lastActiveAt?: number
|
|
55
|
+
/** Labels of the launch that started it. */
|
|
56
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WatchFacts {
|
|
60
|
+
readonly sessions: readonly WatchFact[]
|
|
61
|
+
/** The console's pins (viewPrefs.pinnedSessions / pinnedWorkspaces). */
|
|
62
|
+
readonly pins: { readonly sessions: readonly string[]; readonly workspaces: readonly string[] }
|
|
63
|
+
/** Sessions an open Decision is about (or was asked by). */
|
|
64
|
+
readonly decisions: readonly string[]
|
|
65
|
+
/** The Concierge's own sessions (current and previous): never watched. */
|
|
66
|
+
readonly hidden: readonly string[]
|
|
67
|
+
readonly now: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ResolvedWatchlist {
|
|
71
|
+
readonly entries: WatchlistEntry[]
|
|
72
|
+
readonly truncated: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function defaultWatchlist(): WatchlistConfig {
|
|
76
|
+
return { ...WATCHLIST_DEFAULTS, sessions: [], projects: [], labels: [], muted: [], mutedProjects: [], revision: 0 }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const REASON_ORDER: readonly WatchReason[] = ["active", "pinned", "pinned_project", "explicit", "project", "label"]
|
|
80
|
+
|
|
81
|
+
interface Tree {
|
|
82
|
+
readonly root: WatchFact
|
|
83
|
+
error: boolean
|
|
84
|
+
blocked: boolean
|
|
85
|
+
decision: boolean
|
|
86
|
+
working: boolean
|
|
87
|
+
lastActiveAt?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The root of each session: parents followed while known (a cycle stops at the first repeat). */
|
|
91
|
+
export function rootsOf(sessions: readonly WatchFact[]): Map<string, string> {
|
|
92
|
+
const byID = new Map(sessions.map((s) => [s.sessionID, s]))
|
|
93
|
+
const roots = new Map<string, string>()
|
|
94
|
+
for (const s of sessions) {
|
|
95
|
+
const seen = new Set<string>([s.sessionID])
|
|
96
|
+
let current = s
|
|
97
|
+
while (current.parentID !== undefined) {
|
|
98
|
+
const parent = byID.get(current.parentID)
|
|
99
|
+
if (!parent || seen.has(parent.sessionID)) break
|
|
100
|
+
seen.add(parent.sessionID)
|
|
101
|
+
current = parent
|
|
102
|
+
}
|
|
103
|
+
roots.set(s.sessionID, current.sessionID)
|
|
104
|
+
}
|
|
105
|
+
return roots
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const dirsOf = (s: WatchFact): string[] => [s.project, s.directory].filter((d): d is string => typeof d === "string" && d.length > 0)
|
|
109
|
+
const inAny = (s: WatchFact, projects: readonly string[]) => projects.length > 0 && dirsOf(s).some((dir) => inProjects(dir, projects))
|
|
110
|
+
|
|
111
|
+
/** The project slug of a directory, as the directory derives it (slug of the folder name, else lowercased). */
|
|
112
|
+
const slugOf = (dir: string) => {
|
|
113
|
+
const name = path.posix.basename(dir)
|
|
114
|
+
return name ? (slugify(name) ?? name.toLowerCase()) : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Muted by project: a known directory decides; without one (offline), the project slug matches a muted folder's. */
|
|
118
|
+
const inMutedProject = (s: WatchFact, muted: readonly string[]) => {
|
|
119
|
+
if (dirsOf(s).length > 0) return inAny(s, muted)
|
|
120
|
+
return s.projectSlug !== undefined && muted.some((dir) => slugOf(dir) === s.projectSlug)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function inPinnedWorkspace(s: WatchFact, workspaces: readonly string[]): boolean {
|
|
124
|
+
const folders: string[] = []
|
|
125
|
+
for (const key of workspaces) {
|
|
126
|
+
if (key.startsWith("herdr:")) {
|
|
127
|
+
if (s.herdrWorkspace !== undefined && key.slice("herdr:".length) === s.herdrWorkspace) return true
|
|
128
|
+
} else folders.push(key)
|
|
129
|
+
}
|
|
130
|
+
return inAny(s, folders)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const labelsMatch = (labels: Readonly<Record<string, string>> | undefined, filter: Readonly<Record<string, string>>) => {
|
|
134
|
+
const pairs = Object.entries(filter)
|
|
135
|
+
return labels !== undefined && pairs.length > 0 && pairs.every(([k, v]) => Object.hasOwn(labels, k) && labels[k] === v)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const rank = (t: Tree) => (t.error ? 0 : t.blocked ? 1 : t.decision ? 2 : t.working ? 3 : 4)
|
|
139
|
+
|
|
140
|
+
export function resolveWatchlist(config: WatchlistConfig, facts: WatchFacts): ResolvedWatchlist {
|
|
141
|
+
const roots = rootsOf(facts.sessions)
|
|
142
|
+
const rootID = (id: string) => roots.get(id) ?? id
|
|
143
|
+
const decisions = new Set(facts.decisions)
|
|
144
|
+
const trees = new Map<string, Tree>()
|
|
145
|
+
const byID = new Map(facts.sessions.map((s) => [s.sessionID, s]))
|
|
146
|
+
for (const s of facts.sessions) {
|
|
147
|
+
const id = rootID(s.sessionID)
|
|
148
|
+
let tree = trees.get(id)
|
|
149
|
+
if (!tree) trees.set(id, (tree = { root: byID.get(id)!, error: false, blocked: false, decision: false, working: false }))
|
|
150
|
+
if (s.state === "error") tree.error = true
|
|
151
|
+
if (s.state === "blocked" || s.blockers > 0) tree.blocked = true
|
|
152
|
+
if (s.state === "working") tree.working = true
|
|
153
|
+
if (decisions.has(s.sessionID)) tree.decision = true
|
|
154
|
+
if (s.lastActiveAt !== undefined && (tree.lastActiveAt === undefined || s.lastActiveAt > tree.lastActiveAt)) tree.lastActiveAt = s.lastActiveAt
|
|
155
|
+
}
|
|
156
|
+
const rootSet = (ids: readonly string[]) => new Set(ids.map(rootID))
|
|
157
|
+
const hidden = rootSet(facts.hidden)
|
|
158
|
+
const muted = rootSet(config.muted)
|
|
159
|
+
const explicit = rootSet(config.sessions)
|
|
160
|
+
const pinned = config.usePins ? rootSet(facts.pins.sessions) : new Set<string>()
|
|
161
|
+
const since = facts.now - config.activeWithinHours * HOUR_MS
|
|
162
|
+
|
|
163
|
+
const out: { tree: Tree; entry: WatchlistEntry }[] = []
|
|
164
|
+
for (const [id, tree] of trees) {
|
|
165
|
+
const root = tree.root
|
|
166
|
+
if (root.archived || root.deleted || root.gone || hidden.has(id) || muted.has(id) || inMutedProject(root, config.mutedProjects)) continue
|
|
167
|
+
const reasons = new Set<WatchReason>()
|
|
168
|
+
const recent = tree.lastActiveAt !== undefined && tree.lastActiveAt >= since
|
|
169
|
+
if (config.autoActive && (tree.error || tree.blocked || tree.decision || tree.working || recent)) reasons.add("active")
|
|
170
|
+
if (pinned.has(id)) reasons.add("pinned")
|
|
171
|
+
if (config.usePins && inPinnedWorkspace(root, facts.pins.workspaces)) reasons.add("pinned_project")
|
|
172
|
+
if (explicit.has(id)) reasons.add("explicit")
|
|
173
|
+
if (inAny(root, config.projects)) reasons.add("project")
|
|
174
|
+
if (config.labels.some((filter) => labelsMatch(root.labels, filter))) reasons.add("label")
|
|
175
|
+
if (reasons.size === 0) continue
|
|
176
|
+
out.push({
|
|
177
|
+
tree,
|
|
178
|
+
entry: { sessionID: id, reasons: REASON_ORDER.filter((r) => reasons.has(r)), ...(tree.lastActiveAt !== undefined ? { lastActiveAt: tree.lastActiveAt } : {}) },
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
out.sort((a, b) => rank(a.tree) - rank(b.tree) || (b.tree.lastActiveAt ?? -1) - (a.tree.lastActiveAt ?? -1) || (a.entry.sessionID < b.entry.sessionID ? -1 : a.entry.sessionID > b.entry.sessionID ? 1 : 0))
|
|
182
|
+
return { entries: out.slice(0, WATCHLIST_LIMITS.entries).map((o) => o.entry), truncated: out.length > WATCHLIST_LIMITS.entries }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- Update validation ----------------------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
const FIELDS = new Set(["autoActive", "activeWithinHours", "usePins", "sessions", "projects", "labels", "muted", "mutedProjects", "revision", "expectedRevision"])
|
|
188
|
+
const LABEL_KEY = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9])?$/
|
|
189
|
+
/** C0 (tab and newline included), DEL, C1, Arabic letter mark, zero-width and bidi marks, separators, embeddings and overrides, word joiner, invisible operators, bidi isolates, BOM. */
|
|
190
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/
|
|
191
|
+
|
|
192
|
+
const invalid = (message: string) => new AgentError("invalid", message)
|
|
193
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
194
|
+
const isRevision = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
|
|
195
|
+
|
|
196
|
+
function boolField(value: unknown, name: string): boolean {
|
|
197
|
+
if (typeof value !== "boolean") throw invalid(`${name} is true or false`)
|
|
198
|
+
return value
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stringList(value: unknown, name: string): string[] {
|
|
202
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw invalid(`${name} is a list of strings`)
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function capped<T>(list: T[], max: number, name: string): T[] {
|
|
207
|
+
if (list.length > max) throw invalid(`${name} has at most ${max} entries`)
|
|
208
|
+
return list
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sessionList(value: unknown, name: string, max: number): string[] {
|
|
212
|
+
const list = stringList(value, name)
|
|
213
|
+
for (const id of list) if (!isHubSessionID(id)) throw invalid(`${name}: ${String(id).slice(0, 40)} is not a session ID (ses_…)`)
|
|
214
|
+
return capped([...new Set(list)], max, name)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function projectList(value: unknown, name: string): string[] {
|
|
218
|
+
const out: string[] = []
|
|
219
|
+
for (const dir of stringList(value, name)) {
|
|
220
|
+
if (UNSAFE.test(dir)) throw invalid(`${name}: a directory has control, bidi or zero-width characters`)
|
|
221
|
+
const canonical = canonicalProject(dir)
|
|
222
|
+
if (canonical === undefined) throw invalid(`${name}: ${dir.slice(0, 80)} is not an absolute project directory`)
|
|
223
|
+
out.push(canonical)
|
|
224
|
+
}
|
|
225
|
+
return capped([...new Set(out)], WATCHLIST_LIMITS.projects, name)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function labelFilters(value: unknown): Record<string, string>[] {
|
|
229
|
+
if (!Array.isArray(value)) throw invalid("labels is a list of {key: value} filters")
|
|
230
|
+
capped(value, WATCHLIST_LIMITS.labels, "labels")
|
|
231
|
+
return value.map((filter, i) => {
|
|
232
|
+
if (!isObject(filter)) throw invalid(`labels[${i}] is a {key: value} filter`)
|
|
233
|
+
const pairs = Object.entries(filter)
|
|
234
|
+
if (pairs.length === 0 || pairs.length > LABELS_MAX) throw invalid(`labels[${i}] has 1–${LABELS_MAX} pairs`)
|
|
235
|
+
const out: Record<string, string> = {}
|
|
236
|
+
for (const [key, text] of pairs) {
|
|
237
|
+
if (UNSAFE.test(key)) throw invalid(`labels[${i}]: a key has control, bidi or zero-width characters`)
|
|
238
|
+
if (key.length > LABEL_KEY_MAX || !LABEL_KEY.test(key)) throw invalid(`labels[${i}]: ${key.slice(0, LABEL_KEY_MAX)} is not a label key (letters, digits, . _ / -; up to ${LABEL_KEY_MAX})`)
|
|
239
|
+
if (typeof text !== "string" || text.length > LABEL_VALUE_MAX || UNSAFE.test(text)) throw invalid(`labels[${i}].${key} is a string of up to ${LABEL_VALUE_MAX} characters`)
|
|
240
|
+
out[key] = text
|
|
241
|
+
}
|
|
242
|
+
return out
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The config a PUT body asks for: any subset of the config fields plus the required expectedRevision (the
|
|
248
|
+
* `revision` read earlier may be echoed back, but must agree). Unknown fields, wrong types and caps are 400 invalid;
|
|
249
|
+
* a stale expectedRevision is 409 conflict {currentRevision}. The answer keeps `current.revision` (the store bumps it).
|
|
250
|
+
*/
|
|
251
|
+
export function parseWatchlistUpdate(body: unknown, current: WatchlistConfig): WatchlistConfig {
|
|
252
|
+
if (!isObject(body)) throw invalid("The body is a JSON object: watchlist fields and expectedRevision")
|
|
253
|
+
const unknown = Object.keys(body).find((k) => !FIELDS.has(k))
|
|
254
|
+
if (unknown !== undefined) throw invalid(`Unknown field ${unknown.slice(0, 40)}`)
|
|
255
|
+
const expected = body.expectedRevision
|
|
256
|
+
if (!isRevision(expected)) throw invalid("expectedRevision (the revision you read) is required")
|
|
257
|
+
if (body.revision !== undefined && body.revision !== expected) throw invalid("revision and expectedRevision disagree: send the revision you read as expectedRevision")
|
|
258
|
+
const next: { -readonly [K in keyof WatchlistConfig]: WatchlistConfig[K] } = { ...current }
|
|
259
|
+
if (body.autoActive !== undefined) next.autoActive = boolField(body.autoActive, "autoActive")
|
|
260
|
+
if (body.usePins !== undefined) next.usePins = boolField(body.usePins, "usePins")
|
|
261
|
+
if (body.activeWithinHours !== undefined) {
|
|
262
|
+
const hours = body.activeWithinHours
|
|
263
|
+
if (typeof hours !== "number" || !Number.isInteger(hours) || hours < 1 || hours > 24) throw invalid("activeWithinHours is a whole number of hours, 1–24")
|
|
264
|
+
next.activeWithinHours = hours
|
|
265
|
+
}
|
|
266
|
+
if (body.sessions !== undefined) next.sessions = sessionList(body.sessions, "sessions", WATCHLIST_LIMITS.sessions)
|
|
267
|
+
if (body.muted !== undefined) next.muted = sessionList(body.muted, "muted", WATCHLIST_LIMITS.muted)
|
|
268
|
+
if (body.projects !== undefined) next.projects = projectList(body.projects, "projects")
|
|
269
|
+
if (body.mutedProjects !== undefined) next.mutedProjects = projectList(body.mutedProjects, "mutedProjects")
|
|
270
|
+
if (body.labels !== undefined) next.labels = labelFilters(body.labels)
|
|
271
|
+
if (expected !== current.revision) throw new AgentError("conflict", `The watchlist changed since you read it (revision ${current.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: current.revision } })
|
|
272
|
+
return next
|
|
273
|
+
}
|
package/src/central/config.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface ClusterConfig {
|
|
|
81
81
|
readonly databaseURL: string
|
|
82
82
|
/** redis://, rediss://, valkey:// or valkeys:// (non-enumerable). */
|
|
83
83
|
readonly valkeyURL: string
|
|
84
|
+
/** HUB_DATABASE_STATEMENT_TIMEOUT_MS: Postgres statement_timeout in ms, 0 off (unset: the stores' 30 s default). */
|
|
85
|
+
readonly statementTimeoutMs?: number
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/** hub.json `cluster` (env HUB_DATABASE_URL / HUB_VALKEY_URL win). */
|
|
@@ -433,7 +435,11 @@ function clusterConfig(env: Env, auth: AuthConfig, file: HubCluster = {}): Clust
|
|
|
433
435
|
if (!POD_ID.test(podID)) {
|
|
434
436
|
throw new Error(`HUB_POD_ID must be 1-128 letters, digits, '.', '_' or '-' (starting with a letter or digit), got ${JSON.stringify(podID)}`)
|
|
435
437
|
}
|
|
436
|
-
const
|
|
438
|
+
const rawTimeout = env.HUB_DATABASE_STATEMENT_TIMEOUT_MS?.trim()
|
|
439
|
+
if (rawTimeout && !/^\d{1,9}$/.test(rawTimeout)) {
|
|
440
|
+
throw new Error(`HUB_DATABASE_STATEMENT_TIMEOUT_MS must be a whole number of milliseconds (0 turns it off), got ${JSON.stringify(rawTimeout)}`)
|
|
441
|
+
}
|
|
442
|
+
const cluster = { podID, ...(rawTimeout ? { statementTimeoutMs: Number(rawTimeout) } : {}) }
|
|
437
443
|
// Getters: printed as nothing, skipped by JSON.stringify.
|
|
438
444
|
Object.defineProperty(cluster, "databaseURL", { get: () => databaseURL, enumerable: false })
|
|
439
445
|
Object.defineProperty(cluster, "valkeyURL", { get: () => valkeyURL, enumerable: false })
|