@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
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
|
|
12
12
|
// ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
|
|
13
13
|
// (its creator crashed) blocks the key for RESERVATION_MS, then is taken over.
|
|
14
|
+
// - Waiting (FX-02): a queued launch carries why it waits (`waiting`, from placement): setWaiting writes it only
|
|
15
|
+
// while the launch is queued and only when it changed (no history step; `since` kept while the wait kind is the
|
|
16
|
+
// same), and mirrors it into `reason`; every transition clears it.
|
|
14
17
|
// - Pruning: ended launches older than endedKeepMs, and all but the newest endedMax ended ones, are removed and
|
|
15
18
|
// their keys released. Live launches are never pruned.
|
|
16
19
|
import { randomBytes } from "node:crypto"
|
|
@@ -26,6 +29,8 @@ export const ENDED_KEEP_MS = 7 * 86_400_000
|
|
|
26
29
|
export const ENDED_MAX = 500
|
|
27
30
|
/** How long a key claim without its launch (a creator that may still be writing) blocks the key. */
|
|
28
31
|
export const RESERVATION_MS = 60_000
|
|
32
|
+
/** The longest launcher label kept on a launch. */
|
|
33
|
+
const LABEL_MAX = 100
|
|
29
34
|
/** Reads of a key holder's launch: its creator may be between the claim and the write, so a miss is re-read. */
|
|
30
35
|
const READS = 5
|
|
31
36
|
/** Claim attempts (a stale claim is taken over between them). */
|
|
@@ -45,6 +50,21 @@ const NEXT: Record<LaunchState, readonly LaunchState[]> = {
|
|
|
45
50
|
ended: [],
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** Why a queued launch waits (placement's Waiting without the launch ID). */
|
|
54
|
+
export interface LaunchWaitReason {
|
|
55
|
+
/** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
|
|
56
|
+
readonly wait: "capacity" | "no_match" | "active"
|
|
57
|
+
/** The full text, for the user (names other launchers and machines). */
|
|
58
|
+
readonly reason: string
|
|
59
|
+
/** The text for the launch's own launcher (an app or session) when it differs: counts only, no other launchers or machine names. */
|
|
60
|
+
readonly shared?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A queued launch's wait, since it first waited for this kind of reason. */
|
|
64
|
+
export interface LaunchWaiting extends LaunchWaitReason {
|
|
65
|
+
readonly since: number
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
export interface LaunchStep {
|
|
49
69
|
readonly state: LaunchState
|
|
50
70
|
readonly reason: string
|
|
@@ -59,6 +79,8 @@ export interface Launch {
|
|
|
59
79
|
readonly spec: LaunchSpec
|
|
60
80
|
/** Who launched it, as the caller describes them (e.g. `app:<token id>`, `ses_…`, `user`). */
|
|
61
81
|
readonly by?: string
|
|
82
|
+
/** The launcher's label when it launched (an app token's label, a session's @name); never a secret. */
|
|
83
|
+
readonly byLabel?: string
|
|
62
84
|
readonly state: LaunchState
|
|
63
85
|
readonly reason: string
|
|
64
86
|
/** When it entered its state. */
|
|
@@ -68,6 +90,8 @@ export interface Launch {
|
|
|
68
90
|
readonly sessionID?: string
|
|
69
91
|
readonly name?: string
|
|
70
92
|
readonly artifacts?: Readonly<Record<string, unknown>>
|
|
93
|
+
/** Only while queued: why it waits (placement). */
|
|
94
|
+
readonly waiting?: LaunchWaiting
|
|
71
95
|
readonly created: number
|
|
72
96
|
readonly history: readonly LaunchStep[]
|
|
73
97
|
}
|
|
@@ -138,7 +162,7 @@ export class LaunchStore {
|
|
|
138
162
|
* Stores a new queued launch, or (same key, equivalent body) returns the existing one with created false.
|
|
139
163
|
* Throws invalid (key reused with another body) or rate_limited (the key's launch is still being created).
|
|
140
164
|
*/
|
|
141
|
-
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
165
|
+
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string; readonly byLabel?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
142
166
|
const id = this.newID()
|
|
143
167
|
if (spec.key !== undefined) {
|
|
144
168
|
const existing = await this.claimKey(userID, spec.key, id)
|
|
@@ -157,6 +181,7 @@ export class LaunchStore {
|
|
|
157
181
|
owner: userID,
|
|
158
182
|
spec,
|
|
159
183
|
...(options.by !== undefined ? { by: options.by } : {}),
|
|
184
|
+
...(options.byLabel ? { byLabel: cleanReason(options.byLabel).slice(0, LABEL_MAX) } : {}),
|
|
160
185
|
state: "queued",
|
|
161
186
|
reason: "Queued",
|
|
162
187
|
at,
|
|
@@ -190,8 +215,9 @@ export class LaunchStore {
|
|
|
190
215
|
if (!NEXT[current.state].includes(to) || (input.from && !input.from.includes(current.state))) throw new LaunchTransitionError(current.state, to)
|
|
191
216
|
checkDetails(to, input)
|
|
192
217
|
const step: LaunchStep = { state: to, reason, at, ...(input.outcome !== undefined ? { outcome: input.outcome } : {}) }
|
|
218
|
+
const { waiting: _cleared, ...rest } = current
|
|
193
219
|
return {
|
|
194
|
-
...
|
|
220
|
+
...rest,
|
|
195
221
|
state: to,
|
|
196
222
|
reason,
|
|
197
223
|
at,
|
|
@@ -206,6 +232,33 @@ export class LaunchStore {
|
|
|
206
232
|
return next!
|
|
207
233
|
}
|
|
208
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Records why a queued launch waits (undefined: it no longer waits). Writes nothing (resolves undefined) when the
|
|
237
|
+
* launch is not queued or the wait and reason are unchanged; else resolves with the launch as written. The
|
|
238
|
+
* reason shows as the launch's reason; `since` stays while the wait kind stays the same.
|
|
239
|
+
*/
|
|
240
|
+
async setWaiting(userID: string, id: string, waiting: LaunchWaitReason | undefined): Promise<Launch | undefined> {
|
|
241
|
+
const reason = waiting ? cleanReason(waiting.reason) : undefined
|
|
242
|
+
const shared = waiting?.shared !== undefined ? cleanReason(waiting.shared) : undefined
|
|
243
|
+
let written = false
|
|
244
|
+
const next = await updateVersioned<Launch>(this.deps.state, userID, LAUNCHES, id, (current) => {
|
|
245
|
+
written = false
|
|
246
|
+
if (!current || current.state !== "queued") return undefined
|
|
247
|
+
const was = current.waiting
|
|
248
|
+
if (!waiting) {
|
|
249
|
+
if (!was) return undefined
|
|
250
|
+
const { waiting: _cleared, ...rest } = current
|
|
251
|
+
written = true
|
|
252
|
+
return { ...rest, reason: "Queued" }
|
|
253
|
+
}
|
|
254
|
+
if (was && was.wait === waiting.wait && was.reason === reason && was.shared === shared) return undefined
|
|
255
|
+
written = true
|
|
256
|
+
const since = was?.wait === waiting.wait ? was.since : this.now()
|
|
257
|
+
return { ...current, reason: reason!, waiting: { wait: waiting.wait, reason: reason!, ...(shared !== undefined ? { shared } : {}), since } }
|
|
258
|
+
})
|
|
259
|
+
return written ? next : undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
209
262
|
/** Removes ended launches past endedKeepMs or beyond the newest endedMax; resolves with the removed ids. */
|
|
210
263
|
async prune(userID: string): Promise<string[]> {
|
|
211
264
|
const { state } = this.deps
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
// Loop protection for agent messages (design §3.4), over the backend interfaces with an injected clock.
|
|
2
2
|
//
|
|
3
|
-
// - HopTracker: every message carries a hop. A session's
|
|
4
|
-
//
|
|
5
|
-
// human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and
|
|
6
|
-
// is alerted (the delivery engine does both).
|
|
3
|
+
// - HopTracker: every message carries a hop. A session's depth is the deepest hop that reached it since its last
|
|
4
|
+
// human turn; what it sends (msg, ask, reply, decline) carries depth + 1. The user sends hop 0, and a hop-0
|
|
5
|
+
// message (or a human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and
|
|
6
|
+
// the user is alerted (the delivery engine does both).
|
|
7
|
+
// - Apps (app:<tokenID>, including the hub's own app:concierge, the workspace coordinator and team intake) keep NO
|
|
8
|
+
// depth (S1, bug wge0vjdz; decision in Brain, tag fixup-0.8.1): one token serves many unrelated conversations and
|
|
9
|
+
// has no human turn to reset it, so a shared depth grew by 2 per ask/reply round trip and silenced the app for
|
|
10
|
+
// 24 h after 4 of them. Instead the hop is per conversation: a NEW msg or ask from an app is hop 1, a reply or
|
|
11
|
+
// decline from an app is the hop of the message it answers + 1, and messages to an app raise nothing. Reply
|
|
12
|
+
// ping-pong through an app still counts up (the session side keeps its depth) and hits HOP_CAP. Session-to-session
|
|
13
|
+
// behaviour is unchanged.
|
|
14
|
+
// - Relay depth (0.8.1 verify finding 1): so an app cannot launder a chain by relaying between sessions as NEW
|
|
15
|
+
// messages (A → app → B → app → A, each hop 1), an app remembers, per (user, app) and per sending session, the
|
|
16
|
+
// deepest hop it RECEIVED from a session in the last RELAY_WINDOW_MS (5 min; refreshed by each message, then it
|
|
17
|
+
// fades). Its new msg/ask carries that relay depth + 1 (else 1), counting only what OTHER sessions sent it: what
|
|
18
|
+
// the recipient itself sent is the same conversation, not a relay, so a one-to-one app conversation stays at hop 1
|
|
19
|
+
// at any pace (the S1 case; an app answering one session with new messages forever is held by the pair limit and
|
|
20
|
+
// the recipient's wake budget instead). A relay loop through an app thus reaches HOP_CAP after ~8 hops. Only
|
|
21
|
+
// session senders count (the user's hop 0 and other apps do not), and the hub's own parties (HUB_APP_TOKENS:
|
|
22
|
+
// app:concierge, the workspace coordinator, team intake) keep none: they send on events and submissions, not on
|
|
23
|
+
// the messages they receive (team intake carries the submitter's depth + 1 itself, SendOptions.chainHop). It is
|
|
24
|
+
// not a persistent depth: messages to an app still raise nothing beyond this time-boxed memory.
|
|
7
25
|
// - MessageLimiter: fixed windows aligned to the clock (so retryAfter is exact): messages per sender→recipient
|
|
8
26
|
// pair and per user (refused with rate_limited), and wakes per recipient (exhausted: deliver without waking).
|
|
9
27
|
import type { Clock, Counters, StateStore } from "../backend/types"
|
|
@@ -18,26 +36,67 @@ export const HOP_TTL_MS = 24 * 60 * 60_000
|
|
|
18
36
|
|
|
19
37
|
export const overHopCap = (hop: number): boolean => hop > HOP_CAP
|
|
20
38
|
|
|
39
|
+
/** How long what an app received from a session raises the app's new messages (relay depth). */
|
|
40
|
+
export const RELAY_WINDOW_MS = 5 * 60_000
|
|
41
|
+
/** Sending sessions remembered per (user, app); the oldest go first. */
|
|
42
|
+
export const RELAY_SOURCES_MAX = 32
|
|
43
|
+
/** The hub's own app parties (app:<tokenID>): no relay depth (see the header). */
|
|
44
|
+
export const HUB_APP_TOKENS: ReadonlySet<string> = new Set(["concierge", "workspace-coordinator", "team-intake"])
|
|
45
|
+
|
|
21
46
|
/** StateStore collection: partyKey → HopDepth. */
|
|
22
47
|
const HOPS = "message-hops"
|
|
48
|
+
/** StateStore collection: partyKey of an app → RelayDepth. */
|
|
49
|
+
const RELAYS = "message-relays"
|
|
23
50
|
|
|
24
51
|
interface HopDepth {
|
|
25
52
|
readonly depth: number
|
|
26
53
|
readonly at: number
|
|
27
54
|
}
|
|
28
55
|
|
|
56
|
+
interface RelayDepth {
|
|
57
|
+
/** Sending session → the deepest hop it sent the app, and when it last sent one. */
|
|
58
|
+
readonly sources: Readonly<Record<string, HopDepth>>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a message continues: the message a reply or decline answers, and its recipient. */
|
|
62
|
+
export interface HopContext {
|
|
63
|
+
readonly answering?: { readonly hop: number }
|
|
64
|
+
readonly to?: Party
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const relays = (party: Party): party is Extract<Party, { kind: "app" }> => party.kind === "app" && !HUB_APP_TOKENS.has(party.tokenID)
|
|
68
|
+
|
|
29
69
|
export class HopTracker {
|
|
30
70
|
private readonly state: StateStore
|
|
31
71
|
private readonly now: Clock
|
|
72
|
+
private readonly relayWindowMs: number
|
|
32
73
|
|
|
33
|
-
constructor(deps: { readonly state: StateStore; readonly now?: Clock }) {
|
|
74
|
+
constructor(deps: { readonly state: StateStore; readonly now?: Clock; readonly relayWindowMs?: number }) {
|
|
34
75
|
this.state = deps.state
|
|
35
76
|
this.now = deps.now ?? Date.now
|
|
77
|
+
this.relayWindowMs = deps.relayWindowMs ?? RELAY_WINDOW_MS
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The hop a message from `from` carries. The user: 0. An app: a reply or decline (`answering`) is the answered
|
|
82
|
+
* message's hop + 1; a new msg or ask is its relay depth toward `to` + 1 (see the header). A session: its depth + 1.
|
|
83
|
+
*/
|
|
84
|
+
async next(userID: string, from: Party, context: HopContext = {}): Promise<number> {
|
|
85
|
+
if (from.kind === "user") return 0
|
|
86
|
+
if (from.kind === "app") return (context.answering ? context.answering.hop : await this.relayDepth(userID, from, context.to)) + 1
|
|
87
|
+
return (await this.depth(userID, from)) + 1
|
|
36
88
|
}
|
|
37
89
|
|
|
38
|
-
/** The hop
|
|
39
|
-
async
|
|
40
|
-
|
|
90
|
+
/** The deepest hop sessions other than `to` sent the app within the relay window (0: none; hub parties: 0). */
|
|
91
|
+
async relayDepth(userID: string, app: Party, to?: Party): Promise<number> {
|
|
92
|
+
if (!relays(app)) return 0
|
|
93
|
+
const entry = (await this.state.get<RelayDepth>(userID, RELAYS, partyKey(app)))?.value
|
|
94
|
+
const skip = to?.kind === "session" ? to.sessionID : undefined
|
|
95
|
+
let deepest = 0
|
|
96
|
+
for (const [sessionID, source] of Object.entries(entry?.sources ?? {})) {
|
|
97
|
+
if (sessionID !== skip && this.relayLive(source)) deepest = Math.max(deepest, source.depth)
|
|
98
|
+
}
|
|
99
|
+
return deepest
|
|
41
100
|
}
|
|
42
101
|
|
|
43
102
|
/** The deepest hop that reached the party since its last human turn (0: none, or forgotten). */
|
|
@@ -45,9 +104,13 @@ export class HopTracker {
|
|
|
45
104
|
return this.live((await this.state.get<HopDepth>(userID, HOPS, partyKey(party)))?.value)
|
|
46
105
|
}
|
|
47
106
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
107
|
+
/**
|
|
108
|
+
* A message with `hop` from `from` reached the party. Sessions keep a depth; an app (not a hub party) only
|
|
109
|
+
* remembers, for RELAY_WINDOW_MS, what each session sent it (relay depth); the user keeps nothing.
|
|
110
|
+
*/
|
|
111
|
+
async received(userID: string, to: Party, hop: number, from?: Party): Promise<void> {
|
|
112
|
+
if (relays(to) && from?.kind === "session") return this.relayed(userID, to, from.sessionID, hop)
|
|
113
|
+
if (to.kind !== "session") return
|
|
51
114
|
const at = this.now()
|
|
52
115
|
await updateVersioned<HopDepth>(this.state, userID, HOPS, partyKey(to), (current) => ({ depth: hop === 0 ? 0 : Math.max(this.live(current), hop), at }))
|
|
53
116
|
}
|
|
@@ -57,6 +120,21 @@ export class HopTracker {
|
|
|
57
120
|
await this.state.delete(userID, HOPS, partyKey({ kind: "session", sessionID }))
|
|
58
121
|
}
|
|
59
122
|
|
|
123
|
+
private async relayed(userID: string, app: Party, sessionID: string, hop: number): Promise<void> {
|
|
124
|
+
const at = this.now()
|
|
125
|
+
await updateVersioned<RelayDepth>(this.state, userID, RELAYS, partyKey(app), (current) => {
|
|
126
|
+
const live = Object.entries(current?.sources ?? {}).filter(([id, source]) => id !== sessionID && this.relayLive(source))
|
|
127
|
+
const previous = current?.sources[sessionID]
|
|
128
|
+
const depth = Math.max(previous && this.relayLive(previous) ? previous.depth : 0, hop)
|
|
129
|
+
const kept = live.sort((a, b) => b[1].at - a[1].at).slice(0, RELAY_SOURCES_MAX - 1)
|
|
130
|
+
return { sources: Object.fromEntries([[sessionID, { depth, at }], ...kept]) }
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private relayLive(source: HopDepth): boolean {
|
|
135
|
+
return this.now() - source.at < this.relayWindowMs
|
|
136
|
+
}
|
|
137
|
+
|
|
60
138
|
private live(entry: HopDepth | undefined): number {
|
|
61
139
|
return entry && this.now() - entry.at < HOP_TTL_MS ? entry.depth : 0
|
|
62
140
|
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// user, who owns every party); others get not_found.
|
|
26
26
|
// - Messaging off (UserRecord.agent.messagingPaused): msg, ask, reply and decline answer paused; reading works.
|
|
27
27
|
// - Every stored message is published on the MessageFeed (GET /api/agent/watch 'message' events, waiters).
|
|
28
|
+
import { boundedJson } from "./body"
|
|
28
29
|
import { Feed } from "../cluster/feed"
|
|
29
30
|
import type { Store } from "../store"
|
|
30
31
|
import type { AgentActor } from "./actor"
|
|
@@ -70,7 +71,7 @@ const READ = { session: true, scope: "messages:read" } as const
|
|
|
70
71
|
type Body = Record<string, unknown>
|
|
71
72
|
|
|
72
73
|
async function bodyOf(request: Request): Promise<Body> {
|
|
73
|
-
const body = await request
|
|
74
|
+
const body = await boundedJson(request)
|
|
74
75
|
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
75
76
|
return body as Body
|
|
76
77
|
}
|
|
@@ -153,7 +154,13 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
|
|
|
153
154
|
return out as Omit<AskInput, "question" | "about" | "expiresInMs"> & { urgency?: Urgency; tier?: Tier; about?: string[] }
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
|
|
157
|
+
/** The stored message, how it was delivered and, for a session recipient, whether it woke it (and why not). */
|
|
158
|
+
const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
|
|
159
|
+
message,
|
|
160
|
+
...(delivery ? { delivery } : {}),
|
|
161
|
+
...(woke === undefined ? {} : { woke }),
|
|
162
|
+
...(wakeSkipped ? { wakeSkipped } : {}),
|
|
163
|
+
})
|
|
157
164
|
|
|
158
165
|
export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
159
166
|
const { delivery, feed } = deps
|
|
@@ -269,8 +276,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
269
276
|
if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
|
|
270
277
|
const sent = await send(user.id, actor, to, "ask", text, options, all)
|
|
271
278
|
if (all) return ok({ sent: sent.map(sentView) }, 201)
|
|
272
|
-
|
|
273
|
-
return ok({ ...sentView({ message, ...(how ? { delivery: how } : {}) }), ...(await settled(user.id, message, wait, request.signal)) }, 201)
|
|
279
|
+
return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
|
|
274
280
|
},
|
|
275
281
|
},
|
|
276
282
|
{
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// - Caps: each party (session, the user, an app) keeps its latest SESSION_MESSAGES; a message no party keeps
|
|
8
8
|
// is deleted. Per user, at most USER_MESSAGES messages / USER_BYTES; over either, the oldest go (down to
|
|
9
9
|
// 90%). Eviction keeps open asks while anything else can go.
|
|
10
|
+
// Deleting a message records the newest evicted `at` (retention(): the graph's `retention.truncated`, HWA-31).
|
|
10
11
|
// - Asks: open → answered (a reply) | declined | expired (at expiresAt, seen on read; expire() persists it).
|
|
11
12
|
// The first answer wins (version-checked write); later ones get `gone`.
|
|
12
13
|
// - Replies and declines come only from the recipient (anyone else: not_found, like a foreign id) and go to
|
|
@@ -39,8 +40,12 @@ export const INBOX_RECENT = 50
|
|
|
39
40
|
export type MessageKind = "msg" | "ask" | "reply" | "decline"
|
|
40
41
|
/** `sent` for msg/reply/decline; asks are open until answered, declined or expired. */
|
|
41
42
|
export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
|
|
42
|
-
/**
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* How a message reached its recipient (design §3.3), recorded by the delivery engine. (CON-10) "dropped": it was
|
|
45
|
+
* pending for a session that ended for good (a retired Concierge session) and will never be delivered. "expired": it
|
|
46
|
+
* was pending for PENDING_MS (its recipient stayed offline or busy) and will never be delivered; its sender was told.
|
|
47
|
+
*/
|
|
48
|
+
export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
|
|
44
49
|
|
|
45
50
|
export interface Message {
|
|
46
51
|
readonly id: string
|
|
@@ -117,11 +122,18 @@ export interface Usage {
|
|
|
117
122
|
readonly bytes: number
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
/** What the store retains (the interaction graph's retention disclosure, HWA-31). */
|
|
126
|
+
export interface Retention extends Usage {
|
|
127
|
+
/** The newest `at` of a message the caps deleted; absent while nothing was evicted. */
|
|
128
|
+
readonly evictedAt?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
const MESSAGES = "messages"
|
|
121
132
|
const INDEX = "message-index"
|
|
122
133
|
const USAGE = "message-usage"
|
|
123
134
|
const MUTES = "message-mutes"
|
|
124
135
|
const USAGE_KEY = "total"
|
|
136
|
+
const EVICTED_KEY = "evicted"
|
|
125
137
|
/** Version-checked attempts to detach a contended message. */
|
|
126
138
|
const DETACH_ATTEMPTS = 16
|
|
127
139
|
|
|
@@ -261,6 +273,18 @@ export class Messages {
|
|
|
261
273
|
return (await this.state.get<Usage>(userID, USAGE, USAGE_KEY))?.value ?? { count: 0, bytes: 0 }
|
|
262
274
|
}
|
|
263
275
|
|
|
276
|
+
/** Every retained message of the user, oldest first, as of now: one scan, for whole-user projections. */
|
|
277
|
+
async all(userID: string): Promise<Message[]> {
|
|
278
|
+
const entries = await this.state.list<StoredMessage>(userID, MESSAGES)
|
|
279
|
+
return entries.map((entry) => this.view(entry.value.message)).sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Usage and the newest evicted message's time (older interactions may be missing). */
|
|
283
|
+
async retention(userID: string): Promise<Retention> {
|
|
284
|
+
const [usage, evicted] = await Promise.all([this.usage(userID), this.state.get<number>(userID, USAGE, EVICTED_KEY)])
|
|
285
|
+
return { ...usage, ...(evicted ? { evictedAt: evicted.value } : {}) }
|
|
286
|
+
}
|
|
287
|
+
|
|
264
288
|
/** Records how the message was delivered; undefined when there is no such message. */
|
|
265
289
|
async setDelivery(userID: string, id: string, delivery: Delivery): Promise<Message | undefined> {
|
|
266
290
|
const at = this.now()
|
|
@@ -431,6 +455,8 @@ export class Messages {
|
|
|
431
455
|
if ((await this.state.put<StoredMessage>(userID, MESSAGES, id, { ...current.value, indexed }, { ifVersion: current.version })).ok) return
|
|
432
456
|
} else if ((await this.state.delete(userID, MESSAGES, id, { ifVersion: current.version })).ok) {
|
|
433
457
|
await this.addUsage(userID, -1, -current.value.bytes)
|
|
458
|
+
const at = current.value.message.at
|
|
459
|
+
await updateVersioned<number>(this.state, userID, USAGE, EVICTED_KEY, (marked) => (marked !== undefined && marked >= at ? undefined : at))
|
|
434
460
|
return
|
|
435
461
|
}
|
|
436
462
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { NAME_MAX, validName } from "../../../packages/sdk/src/address.ts"
|
|
6
6
|
|
|
7
|
-
export { NAME_MAX, parseAddress, validName, type Address } from "../../../packages/sdk/src/address.ts"
|
|
7
|
+
export { addressProblem, NAME_MAX, parseAddress, validName, type Address, type AddressProblem } from "../../../packages/sdk/src/address.ts"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* OpenCode's fallback titles: "New session" / "Child session" (display labels) and the historical
|
|
@@ -43,10 +43,20 @@ export function withSuffix(base: string, taken: (name: string) => boolean): stri
|
|
|
43
43
|
export const isReservedName = (name: string): boolean => name === "me"
|
|
44
44
|
|
|
45
45
|
/** Roles only the user may claim in phase 1 (grants including `roles` come in phase 3). */
|
|
46
|
-
const RESERVED_ROLES = ["orchestrator"]
|
|
46
|
+
const RESERVED_ROLES = ["orchestrator", "concierge"]
|
|
47
47
|
|
|
48
48
|
export const isReservedRole = (role: string): boolean => RESERVED_ROLES.some((prefix) => role.startsWith(prefix))
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* (CON-04) Roles only the hub itself claims and releases (reserved too): not the user, a session, an app under a
|
|
52
|
+
* `roles` grant or a launch spec. `concierge`: the Concierge service holds it for the user's current Concierge.
|
|
53
|
+
*/
|
|
54
|
+
const MANAGED_ROLES = ["concierge"]
|
|
55
|
+
|
|
56
|
+
export const isManagedRole = (role: string): boolean => MANAGED_ROLES.some((prefix) => role.startsWith(prefix))
|
|
57
|
+
|
|
58
|
+
export const MANAGED_ROLE_MESSAGE = (role: string) => `@@${role} is managed by the hub: use Settings → Concierge`
|
|
59
|
+
|
|
50
60
|
/** The longest derived name: a few words, short enough to type and read in an address (@send-hello-to-session). */
|
|
51
61
|
export const DERIVED_NAME_MAX = 24
|
|
52
62
|
/** At most this many words of the title make a derived name. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// announcing "launch" (each harness has its own service spoke: a Claude Code one never serves an OpenCode launch),
|
|
6
6
|
// and for openshell launches the node feature "sandbox" (it starts and stops the harness itself; the sandbox's
|
|
7
7
|
// launch-bound spoke creates the session, so no service spoke is needed)), then the placement's machine and labels (mode require: hard; prefer: ranking only), then
|
|
8
|
-
// free slots (capacity minus used minus reservations). The best node has the preference, then most free slots,
|
|
8
|
+
// free slots (capacity minus used minus reservations; a capacity wait gives each full node's counts). The best node has the preference, then most free slots,
|
|
9
9
|
// then the lowest machine ID. A launch that cannot be placed waits for capacity (a fitting node is full) or for a
|
|
10
10
|
// match (no node fits; the reason names each node's first problem).
|
|
11
11
|
// - Admission (per user, hub.json agents.launches): admit() refuses a new launch with rate_limited while maxQueued
|
|
@@ -17,13 +17,17 @@
|
|
|
17
17
|
// the end. A pass first returns launches left placing by a dead holder to the queue, then walks queued launches
|
|
18
18
|
// oldest first: while fewer than maxActive are placing/provisioning/running, each is matched against the
|
|
19
19
|
// user's nodes and moved queued → placing → provisioning on the machine. A launch that cannot be placed now is
|
|
20
|
-
// reported as waiting and does not hold back later ones; earlier launches always pick first.
|
|
20
|
+
// reported as waiting and does not hold back later ones; earlier launches always pick first. The active wait names
|
|
21
|
+
// who holds the cap (counts by launcher label; a revoked app token's launches are flagged). The Concierge's own
|
|
22
|
+
// launch (by app:concierge with label hub/system=concierge) neither counts toward maxActive nor waits for it; it
|
|
23
|
+
// still takes a node slot. Reservations are
|
|
21
24
|
// launches provisioning or running on a machine that its node does not report (yet); slots taken in the pass
|
|
22
25
|
// count at once. Reasons go on the launch only when it moves; waiting reasons are returned for the engine.
|
|
23
26
|
// - PlacementLoop runs passes: kick(user) when something changed (a launch queued or ended, a node connected or
|
|
24
27
|
// reported capacity, a service spoke came), coalescing kicks during a pass into one more, plus a sweep over
|
|
25
28
|
// users() every intervalMs for whatever a kick missed (e.g. another process held the lease). Each placed launch
|
|
26
|
-
// goes to onPlaced (the launch engine starts provisioning)
|
|
29
|
+
// goes to onPlaced (the launch engine starts provisioning), then the pass's waiting launches to onWaiting (the
|
|
30
|
+
// engine stores the reason on the launch); failures go to onError and never stop the loop.
|
|
27
31
|
// - Node and spoke info come through PlacementNodes / PlacementSpokes, which the launch engine adapts from the
|
|
28
32
|
// NodeRegistry and the Registry; memory is not matched (a spec has no memory need yet).
|
|
29
33
|
import type { NodeCapacity, NodeRuntimeInfo } from "../../protocol"
|
|
@@ -62,7 +66,7 @@ export interface Candidate extends PlacementNode {
|
|
|
62
66
|
|
|
63
67
|
export type Match =
|
|
64
68
|
| { readonly ok: true; readonly node: PlacementNode; readonly preferred: boolean }
|
|
65
|
-
| { readonly ok: false; readonly wait: "capacity" | "no_match"; readonly reason: string }
|
|
69
|
+
| { readonly ok: false; readonly wait: "capacity" | "no_match"; readonly reason: string; readonly shared?: string }
|
|
66
70
|
|
|
67
71
|
const label = (node: PlacementNode) => node.name ?? node.machineID
|
|
68
72
|
|
|
@@ -102,13 +106,13 @@ export function matchNode(userID: string, spec: LaunchSpec, candidates: readonly
|
|
|
102
106
|
const own = candidates.filter((node) => node.owner === userID)
|
|
103
107
|
if (own.length === 0) return { ok: false, wait: "no_match", reason: "No node is connected" }
|
|
104
108
|
const require = spec.placement.mode === "require"
|
|
105
|
-
const problems: string[] = []
|
|
109
|
+
const problems: { node: string; problem: string }[] = []
|
|
106
110
|
const full: Candidate[] = []
|
|
107
111
|
const free: { node: Candidate; preferred: boolean }[] = []
|
|
108
112
|
for (const node of own) {
|
|
109
113
|
const placement = placementProblem(spec, node)
|
|
110
114
|
const problem = hardProblem(spec, node) ?? (require ? placement : undefined)
|
|
111
|
-
if (problem) problems.push(
|
|
115
|
+
if (problem) problems.push({ node: label(node), problem })
|
|
112
116
|
else if (freeSlots(node) <= 0) full.push(node)
|
|
113
117
|
else free.push({ node, preferred: placement === undefined })
|
|
114
118
|
}
|
|
@@ -122,8 +126,17 @@ export function matchNode(userID: string, spec: LaunchSpec, candidates: readonly
|
|
|
122
126
|
const best = free[0]!
|
|
123
127
|
return { ok: true, node: best.node, preferred: best.preferred }
|
|
124
128
|
}
|
|
125
|
-
if (full.length > 0)
|
|
126
|
-
|
|
129
|
+
if (full.length > 0) {
|
|
130
|
+
const used = (node: Candidate) => `${node.used.slots}/${node.capacity.slots} used, ${node.reserved} reserved`
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
wait: "capacity",
|
|
134
|
+
reason: `Waiting for a free slot on ${full.map((node) => `${label(node)} (${used(node)})`).join(", ")}`,
|
|
135
|
+
// An app or session may not see the user's machines: no names.
|
|
136
|
+
shared: full.length === 1 ? `Waiting for a free slot on the fitting machine (${used(full[0]!)})` : `Waiting for a free slot on ${full.length} fitting machines (all full)`,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ok: false, wait: "no_match", reason: `No node fits: ${problems.map((p) => `${p.node} ${p.problem}`).join("; ")}`, shared: `No machine fits: ${[...new Set(problems.map((p) => p.problem))].join("; ")}` }
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
|
|
@@ -157,6 +170,27 @@ export interface PlacementSpokes {
|
|
|
157
170
|
launchHarnesses(userID: string, machineID: string): readonly string[] | Promise<readonly string[]>
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
/** Who launched what (the launch engine adapts the user's app tokens). */
|
|
174
|
+
export interface PlacementLaunchers {
|
|
175
|
+
/** Whether the launcher (`app:<token id>`) was revoked: its launches still count, flagged. */
|
|
176
|
+
revoked(userID: string, by: string): boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The hub's own Concierge party (packages/protocol concierge.ts CONCIERGE_PARTY) and its launch label. */
|
|
180
|
+
const CONCIERGE_BY = "app:concierge"
|
|
181
|
+
/** The Concierge's own launch: neither counts toward nor waits for maxActive (it still takes a node slot). */
|
|
182
|
+
export const isSystemLaunch = (launch: Pick<Launch, "by" | "spec">): boolean => launch.by === CONCIERGE_BY && launch.spec.labels["hub/system"] === "concierge"
|
|
183
|
+
|
|
184
|
+
/** A launcher as a waiting reason names it: you, app "label" (or its token ID), a session's label or ID. */
|
|
185
|
+
function launcherText(launch: Pick<Launch, "by" | "byLabel">, revoked: boolean): string {
|
|
186
|
+
const { by, byLabel } = launch
|
|
187
|
+
let text: string
|
|
188
|
+
if (by === undefined || by === "user") text = "you"
|
|
189
|
+
else if (by.startsWith("app:")) text = byLabel ? `app ${JSON.stringify(byLabel)}` : `app ${by.slice(4)}`
|
|
190
|
+
else text = byLabel ?? by
|
|
191
|
+
return revoked ? `${text} (launcher revoked)` : text
|
|
192
|
+
}
|
|
193
|
+
|
|
160
194
|
export interface PlacementDeps {
|
|
161
195
|
readonly store: LaunchStore
|
|
162
196
|
readonly nodes: PlacementNodes
|
|
@@ -168,6 +202,8 @@ export interface PlacementDeps {
|
|
|
168
202
|
readonly caps?: HubLaunches
|
|
169
203
|
readonly now?: Clock
|
|
170
204
|
readonly leaseMs?: number
|
|
205
|
+
/** Revoked launchers (flagged in the active wait); without it none is. */
|
|
206
|
+
readonly launchers?: PlacementLaunchers
|
|
171
207
|
}
|
|
172
208
|
|
|
173
209
|
/** A launch the pass placed: it is now provisioning on the node's machine. */
|
|
@@ -184,7 +220,10 @@ export interface Waiting {
|
|
|
184
220
|
readonly launchID: string
|
|
185
221
|
/** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
|
|
186
222
|
readonly wait: "capacity" | "no_match" | "active"
|
|
223
|
+
/** For the user: names other launchers and machines. */
|
|
187
224
|
readonly reason: string
|
|
225
|
+
/** For the launch's own launcher (app or session), when it differs: counts only, no names. */
|
|
226
|
+
readonly shared?: string
|
|
188
227
|
}
|
|
189
228
|
|
|
190
229
|
/** One pass. leased false: another holder has the user's lease, or it was lost mid-pass (placed: before that). */
|
|
@@ -251,16 +290,18 @@ export class Placement {
|
|
|
251
290
|
launches = await store.list(userID)
|
|
252
291
|
const queued = launches.filter((l) => l.state === "queued")
|
|
253
292
|
if (queued.length === 0) return { leased: true, placed, waiting }
|
|
254
|
-
|
|
293
|
+
const counted = launches.filter((l) => ACTIVE.includes(l.state) && !isSystemLaunch(l))
|
|
294
|
+
let active = counted.length
|
|
255
295
|
const candidates = await this.candidates(userID, launches)
|
|
256
296
|
for (const launch of queued) {
|
|
257
|
-
|
|
258
|
-
|
|
297
|
+
const system = isSystemLaunch(launch)
|
|
298
|
+
if (!system && active >= this.caps.maxActive) {
|
|
299
|
+
waiting.push({ launchID: launch.id, wait: "active", ...this.activeReason(userID, counted, launch) })
|
|
259
300
|
continue
|
|
260
301
|
}
|
|
261
302
|
const match = matchNode(userID, launch.spec, candidates)
|
|
262
303
|
if (!match.ok) {
|
|
263
|
-
waiting.push({ launchID: launch.id, wait: match.wait, reason: match.reason })
|
|
304
|
+
waiting.push({ launchID: launch.id, wait: match.wait, reason: match.reason, ...(match.shared !== undefined ? { shared: match.shared } : {}) })
|
|
264
305
|
continue
|
|
265
306
|
}
|
|
266
307
|
const name = match.node.name ?? match.node.machineID
|
|
@@ -268,14 +309,21 @@ export class Placement {
|
|
|
268
309
|
const placing = await skipGone(store.transition(userID, launch.id, "placing", { reason: `Placing on ${name}`, from: ["queued"] }))
|
|
269
310
|
if (!placing) continue
|
|
270
311
|
if (!(await held())) return lost()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
312
|
+
// A stop between the two steps (e.g. the Concierge reaping an orphan) wins: skipped, not an error.
|
|
313
|
+
const provisioning = await skipGone(
|
|
314
|
+
store.transition(userID, launch.id, "provisioning", {
|
|
315
|
+
reason: `Placed on ${name}${match.preferred ? "" : " (preferred placement not available)"}`,
|
|
316
|
+
machineID: match.node.machineID,
|
|
317
|
+
from: ["placing"],
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
if (!provisioning) continue
|
|
276
321
|
const index = candidates.findIndex((c) => c.machineID === match.node.machineID)
|
|
277
322
|
candidates[index] = { ...candidates[index]!, reserved: candidates[index]!.reserved + 1 }
|
|
278
|
-
|
|
323
|
+
if (!system) {
|
|
324
|
+
active++
|
|
325
|
+
counted.push(provisioning)
|
|
326
|
+
}
|
|
279
327
|
placed.push({ userID, launch: provisioning, node: match.node, preferred: match.preferred, fence: lease.fence })
|
|
280
328
|
}
|
|
281
329
|
return { leased: true, placed, waiting }
|
|
@@ -284,6 +332,25 @@ export class Placement {
|
|
|
284
332
|
}
|
|
285
333
|
}
|
|
286
334
|
|
|
335
|
+
/**
|
|
336
|
+
* The user's text names who holds the cap, most first ("Waiting: 4 of 4 active launches: 3 by app "ci", 1 by you;
|
|
337
|
+
* …"); the launcher's own text gives counts only: its own launches and the others'.
|
|
338
|
+
*/
|
|
339
|
+
private activeReason(userID: string, active: readonly Launch[], waiting: Launch): { reason: string; shared: string } {
|
|
340
|
+
const own = active.filter((l) => (l.by ?? "user") === (waiting.by ?? "user")).length
|
|
341
|
+
const others = active.length - own
|
|
342
|
+
const split = [own ? `${own} yours` : undefined, others ? `${others} by others` : undefined].filter(Boolean).join(", ")
|
|
343
|
+
const shared = `Waiting: ${active.length} of ${this.caps.maxActive} active launches (${split}); ask the user to stop one or raise agents.launches.maxActive`
|
|
344
|
+
const counts = new Map<string, number>()
|
|
345
|
+
for (const launch of active) {
|
|
346
|
+
const revoked = launch.by !== undefined && launch.by.startsWith("app:") && this.deps.launchers?.revoked(userID, launch.by) === true
|
|
347
|
+
const text = launcherText(launch, revoked)
|
|
348
|
+
counts.set(text, (counts.get(text) ?? 0) + 1)
|
|
349
|
+
}
|
|
350
|
+
const holders = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(([text, n]) => `${n} by ${text}`)
|
|
351
|
+
return { reason: `Waiting: ${active.length} of ${this.caps.maxActive} active launches: ${holders.join(", ")}; stop one or raise agents.launches.maxActive`, shared }
|
|
352
|
+
}
|
|
353
|
+
|
|
287
354
|
/** The user's nodes with their service spoke and reservations (placed launches the node does not report yet). */
|
|
288
355
|
private async candidates(userID: string, launches: readonly Launch[]): Promise<Candidate[]> {
|
|
289
356
|
const nodes = (await this.deps.nodes.nodes(userID)).filter((node) => node.owner === userID)
|
|
@@ -323,6 +390,8 @@ export interface PlacementLoopDeps {
|
|
|
323
390
|
readonly users: () => Promise<readonly string[]>
|
|
324
391
|
/** The engine takes a placed (provisioning) launch from here. */
|
|
325
392
|
readonly onPlaced: (placed: Placed) => void | Promise<void>
|
|
393
|
+
/** The launches the pass could not place, with why (only for a pass that held the user's lease). */
|
|
394
|
+
readonly onWaiting?: (userID: string, waiting: readonly Waiting[]) => void | Promise<void>
|
|
326
395
|
readonly onError?: (cause: unknown, userID?: string) => void
|
|
327
396
|
readonly timers?: Timers
|
|
328
397
|
readonly intervalMs?: number
|
|
@@ -415,5 +484,11 @@ export class PlacementLoop {
|
|
|
415
484
|
this.onError(cause, userID)
|
|
416
485
|
}
|
|
417
486
|
}
|
|
487
|
+
if (!result.leased || !this.deps.onWaiting) return
|
|
488
|
+
try {
|
|
489
|
+
await this.deps.onWaiting(userID, result.waiting)
|
|
490
|
+
} catch (cause) {
|
|
491
|
+
this.onError(cause, userID)
|
|
492
|
+
}
|
|
418
493
|
}
|
|
419
494
|
}
|