@pinet/broker-core 0.2.4 → 0.2.7
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/dist/agent-messaging.d.ts +27 -4
- package/dist/hibernation-commands.d.ts +123 -0
- package/dist/hibernation-commands.js +287 -0
- package/dist/hibernation-orchestrator.d.ts +327 -0
- package/dist/hibernation-orchestrator.js +1096 -0
- package/dist/hibernation-projection.d.ts +20 -0
- package/dist/hibernation-projection.js +60 -0
- package/dist/hibernation-status.d.ts +141 -0
- package/dist/hibernation-status.js +390 -0
- package/dist/hibernation-telemetry.d.ts +54 -0
- package/dist/hibernation-telemetry.js +119 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/leader.d.ts +134 -5
- package/dist/leader.js +359 -26
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.js +59 -0
- package/dist/mail-classification.d.ts +6 -1
- package/dist/message-send.d.ts +4 -3
- package/dist/router.d.ts +14 -1
- package/dist/router.js +15 -0
- package/dist/schema.d.ts +181 -2
- package/dist/schema.js +1243 -17
- package/dist/types.d.ts +288 -1
- package/dist/types.js +6 -0
- package/package.json +5 -5
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AgentLifecycleStatus } from "./hibernation-status.js";
|
|
2
|
+
import type { BrokerDB } from "./schema.js";
|
|
3
|
+
import type { AgentInfo } from "./types.js";
|
|
4
|
+
/** True when an agent's lifecycle state is worth surfacing in operator reads. */
|
|
5
|
+
export declare function isHibernationRelevantAgent(agent: Pick<AgentInfo, "lifecycleState">): boolean;
|
|
6
|
+
export interface CollectLifecycleStatusesOptions {
|
|
7
|
+
now?: number;
|
|
8
|
+
/** Restrict to these agent ids (e.g. the currently visible set). */
|
|
9
|
+
agentIds?: string[];
|
|
10
|
+
/** Include live/active agents too. Defaults false (hibernation-relevant only). */
|
|
11
|
+
includeAll?: boolean;
|
|
12
|
+
/** Wake capacity ceilings; when provided, capacity counters are included. */
|
|
13
|
+
maxConcurrentWakes?: number;
|
|
14
|
+
maxConcurrentWakesPerRepo?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build lifecycle statuses from the broker DB. Pure read: performs no
|
|
18
|
+
* mutations and never activates hibernation.
|
|
19
|
+
*/
|
|
20
|
+
export declare function collectAgentLifecycleStatuses(db: BrokerDB, options?: CollectLifecycleStatusesOptions): AgentLifecycleStatus[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { buildAgentLifecycleStatus } from "./hibernation-status.js";
|
|
2
|
+
/**
|
|
3
|
+
* Read-only lifecycle projection for the existing `agents`/`sessions` operator
|
|
4
|
+
* read paths. Assembles the sanitized {@link AgentLifecycleStatus} for agents
|
|
5
|
+
* that are in a hibernation-relevant lifecycle state, using only durable broker
|
|
6
|
+
* reads. Every field is redaction-by-construction safe (see
|
|
7
|
+
* {@link buildAgentLifecycleStatus} / redactRuntimeSpec): no argv, env values,
|
|
8
|
+
* message bodies, tokens, or filesystem/socket paths.
|
|
9
|
+
*/
|
|
10
|
+
const HIBERNATION_RELEVANT_STATES = new Set([
|
|
11
|
+
"grace",
|
|
12
|
+
"idle",
|
|
13
|
+
"hibernating",
|
|
14
|
+
"hibernated",
|
|
15
|
+
"waking",
|
|
16
|
+
"reap-candidate",
|
|
17
|
+
]);
|
|
18
|
+
/** True when an agent's lifecycle state is worth surfacing in operator reads. */
|
|
19
|
+
export function isHibernationRelevantAgent(agent) {
|
|
20
|
+
return HIBERNATION_RELEVANT_STATES.has(agent.lifecycleState ?? "live");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build lifecycle statuses from the broker DB. Pure read: performs no
|
|
24
|
+
* mutations and never activates hibernation.
|
|
25
|
+
*/
|
|
26
|
+
export function collectAgentLifecycleStatuses(db, options = {}) {
|
|
27
|
+
const now = options.now ?? Date.now();
|
|
28
|
+
const idFilter = options.agentIds ? new Set(options.agentIds) : null;
|
|
29
|
+
const orderedWakeQueue = [...db.listWakeQueue("queued")].sort((a, b) => a.priority - b.priority || a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
30
|
+
const recentEvents = db.getRecentAgentLifecycleEvents(undefined, 200);
|
|
31
|
+
const globalInflight = db.countInflightWakes();
|
|
32
|
+
const includeCapacity = options.maxConcurrentWakes != null && options.maxConcurrentWakesPerRepo != null;
|
|
33
|
+
const statuses = [];
|
|
34
|
+
for (const agent of db.getAllAgents()) {
|
|
35
|
+
if (idFilter && !idFilter.has(agent.id))
|
|
36
|
+
continue;
|
|
37
|
+
if (!options.includeAll && !isHibernationRelevantAgent(agent))
|
|
38
|
+
continue;
|
|
39
|
+
const repoRootRaw = agent.metadata?.repoRoot;
|
|
40
|
+
const repoRoot = typeof repoRootRaw === "string" ? repoRootRaw : null;
|
|
41
|
+
const capacity = includeCapacity
|
|
42
|
+
? {
|
|
43
|
+
maxConcurrentWakes: options.maxConcurrentWakes,
|
|
44
|
+
inflightWakes: globalInflight,
|
|
45
|
+
maxConcurrentWakesPerRepo: options.maxConcurrentWakesPerRepo,
|
|
46
|
+
inflightWakesForRepo: db.countInflightWakes(repoRoot),
|
|
47
|
+
}
|
|
48
|
+
: undefined;
|
|
49
|
+
statuses.push(buildAgentLifecycleStatus({
|
|
50
|
+
agent,
|
|
51
|
+
now,
|
|
52
|
+
latestCheckpoint: db.getLatestAgentCheckpointReceipt(agent.id),
|
|
53
|
+
runtimeSpec: db.getAgentRuntimeSpec(agent.id),
|
|
54
|
+
orderedWakeQueue,
|
|
55
|
+
recentEvents,
|
|
56
|
+
capacity,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
return statuses;
|
|
60
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AgentCheckpointReceipt, AgentHibernatePolicy, AgentInfo, AgentLifecycleEvent, AgentLifecycleState, AgentRuntimeSpec, AgentWakeQueueEntry, RedactedAgentRuntimeSpec, WakeTriggerKind } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Short, stable, non-reversible FNV-1a digest. Lets operators correlate an
|
|
4
|
+
* opaque identity (session ref, unresolved command target) across reads without
|
|
5
|
+
* exposing the underlying payload — which may be a filesystem path or secret.
|
|
6
|
+
*/
|
|
7
|
+
export declare function fingerprintToken(value: string): string;
|
|
8
|
+
export declare function redactRuntimeSpec(spec: AgentRuntimeSpec): RedactedAgentRuntimeSpec;
|
|
9
|
+
export interface AgentLifecycleStatusCapacityInput {
|
|
10
|
+
maxConcurrentWakes: number;
|
|
11
|
+
inflightWakes: number;
|
|
12
|
+
maxConcurrentWakesPerRepo: number;
|
|
13
|
+
inflightWakesForRepo: number;
|
|
14
|
+
}
|
|
15
|
+
export interface AgentLifecycleStatusInput {
|
|
16
|
+
agent: Pick<AgentInfo, "id" | "lifecycleState" | "lifecycleVersion" | "runtimeGeneration" | "hibernatePolicy" | "hibernatedAt" | "graceUntil" | "idleEligibleAt" | "hibernateReason" | "lastWakeReason">;
|
|
17
|
+
/** Epoch ms used for age math. Defaults to Date.now(). */
|
|
18
|
+
now?: number;
|
|
19
|
+
latestCheckpoint?: AgentCheckpointReceipt | null;
|
|
20
|
+
runtimeSpec?: AgentRuntimeSpec | null;
|
|
21
|
+
/**
|
|
22
|
+
* Wake queue ordered exactly as the dispatcher consumes it (priority then
|
|
23
|
+
* oldest). Used to compute this agent's 1-based queue position.
|
|
24
|
+
*/
|
|
25
|
+
orderedWakeQueue?: AgentWakeQueueEntry[];
|
|
26
|
+
capacity?: AgentLifecycleStatusCapacityInput;
|
|
27
|
+
/** Recent lifecycle events (any order); used to surface refusal/quarantine cause. */
|
|
28
|
+
recentEvents?: AgentLifecycleEvent[];
|
|
29
|
+
}
|
|
30
|
+
export interface AgentLifecycleStatus {
|
|
31
|
+
agentId: string;
|
|
32
|
+
state: AgentLifecycleState;
|
|
33
|
+
lifecycleVersion: number;
|
|
34
|
+
runtimeGeneration: number | null;
|
|
35
|
+
hibernatePolicy: AgentHibernatePolicy | null;
|
|
36
|
+
hibernatedAt: string | null;
|
|
37
|
+
hibernateReason: string | null;
|
|
38
|
+
lastWakeReason: string | null;
|
|
39
|
+
graceUntil: string | null;
|
|
40
|
+
idleEligibleAt: string | null;
|
|
41
|
+
quarantined: boolean;
|
|
42
|
+
checkpoint: {
|
|
43
|
+
present: boolean;
|
|
44
|
+
hibernateSafe: boolean | null;
|
|
45
|
+
ageMs: number | null;
|
|
46
|
+
pendingInboxCount: number | null;
|
|
47
|
+
runtimeGeneration: number | null;
|
|
48
|
+
};
|
|
49
|
+
/** Presence + redacted summary of the durable runtime spec; never raw argv/env. */
|
|
50
|
+
runtimeSpec: RedactedAgentRuntimeSpec | null;
|
|
51
|
+
wake: {
|
|
52
|
+
queued: boolean;
|
|
53
|
+
position: number | null;
|
|
54
|
+
triggerKind: WakeTriggerKind | null;
|
|
55
|
+
reason: string | null;
|
|
56
|
+
attempt: number | null;
|
|
57
|
+
};
|
|
58
|
+
capacity: {
|
|
59
|
+
global: {
|
|
60
|
+
inflight: number;
|
|
61
|
+
max: number;
|
|
62
|
+
atCapacity: boolean;
|
|
63
|
+
};
|
|
64
|
+
repo: {
|
|
65
|
+
inflight: number;
|
|
66
|
+
max: number;
|
|
67
|
+
atCapacity: boolean;
|
|
68
|
+
};
|
|
69
|
+
} | null;
|
|
70
|
+
/** Most recent non-accepted lifecycle outcome (refusal/stale fence/abort). */
|
|
71
|
+
refusal: {
|
|
72
|
+
reason: string;
|
|
73
|
+
outcome: string;
|
|
74
|
+
at: string;
|
|
75
|
+
} | null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Bound and control-strip a free-form reason string for operator-safe display.
|
|
79
|
+
* Reasons can originate from operator input (hibernate/wake commands) and flow
|
|
80
|
+
* durably into lifecycle rows; this is the redaction-by-construction boundary
|
|
81
|
+
* that keeps such strings single-line, control-char free, and length-bounded
|
|
82
|
+
* before they reach any operator/status/JSON surface. Returns null for
|
|
83
|
+
* empty/whitespace-only input.
|
|
84
|
+
*/
|
|
85
|
+
export declare function sanitizeOperatorReason(value: string | null | undefined): string | null;
|
|
86
|
+
/**
|
|
87
|
+
* Sanitize an operator-authored hibernation/wake TARGET before it reaches the
|
|
88
|
+
* confirmation-policy / prompt surface. Unlike a free-form reason, a target can
|
|
89
|
+
* be a durable stable id (`host:session:<ref>`, `host:cwd:<path>`) whose tail
|
|
90
|
+
* embeds the session-resume identity — the same value {@link redactRuntimeSpec}
|
|
91
|
+
* fingerprints — so it must never be echoed verbatim. Fail-closed by shape: only
|
|
92
|
+
* a plain broker-safe identifier slug (an agent name/id with no separators that
|
|
93
|
+
* could embed a session/path/secret identity) is passed through; ANY other shape
|
|
94
|
+
* (stable-id triple, session/cwd/worktree token, path, `KEY=value`, quotes,
|
|
95
|
+
* whitespace) collapses to an opaque, non-reversible `target:#<fingerprint>`
|
|
96
|
+
* (matching {@link unknownHibernationTarget}). The RAW target is unaffected — the
|
|
97
|
+
* broker still resolves it server-side; only the operator-facing echo is redacted.
|
|
98
|
+
*/
|
|
99
|
+
export declare function sanitizeOperatorTarget(target: string | null | undefined): string;
|
|
100
|
+
/**
|
|
101
|
+
* Replace filesystem-path-like tokens with `<path>` so operator-authored
|
|
102
|
+
* free-form strings (reasons, echoed targets) can never surface private
|
|
103
|
+
* absolute paths, unix socket paths, or repo-relative paths in operator/JSON
|
|
104
|
+
* output. Conservative but fail-closed on ambiguous relative paths: any
|
|
105
|
+
* single-separator token that is not a known prose connective (see
|
|
106
|
+
* `PROSE_SLASH_TOKENS`) is redacted, so `accounts/acme` is caught while
|
|
107
|
+
* "and/or" survives. This is a redaction-by-construction boundary, not a
|
|
108
|
+
* security parser.
|
|
109
|
+
*/
|
|
110
|
+
export declare function redactPathLikeTokens(value: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* Redaction-by-construction for RUNTIME-authored checkpoint reasons. A checkpoint
|
|
113
|
+
* reason is authored by the worker runtime, so — unlike the trusted-operator
|
|
114
|
+
* reason path (which only redacts obvious path-like tokens) — it must NOT be able
|
|
115
|
+
* to smuggle argv, env assignments (`TOKEN=secret`), CLI flags (`--api-key x`),
|
|
116
|
+
* extensionless relative paths (`accounts/acme`), or other free prose onto any
|
|
117
|
+
* operator/telemetry/JSON surface. We therefore allowlist by *shape*: only a
|
|
118
|
+
* short single-token machine code (`active_port_lease`, `checkpoint_timeout`, …)
|
|
119
|
+
* is passed through; anything containing whitespace, separators, or exotic
|
|
120
|
+
* characters collapses to the static `unspecified` code. Diagnostic prose belongs
|
|
121
|
+
* in the worker's own logs, not the broker's operator surface.
|
|
122
|
+
*/
|
|
123
|
+
export declare function sanitizeCheckpointReasonCode(value: string | null | undefined): string;
|
|
124
|
+
/**
|
|
125
|
+
* Compose an operator-safe, actionable per-agent lifecycle status from already
|
|
126
|
+
* sanitized inputs. Pure: it performs no IO and never emits raw argv, env
|
|
127
|
+
* values, or filesystem/socket paths.
|
|
128
|
+
*/
|
|
129
|
+
export declare function buildAgentLifecycleStatus(input: AgentLifecycleStatusInput): AgentLifecycleStatus;
|
|
130
|
+
/**
|
|
131
|
+
* Render a single scannable, operator-safe lifecycle tag for inline use in the
|
|
132
|
+
* `agents`/`sessions` compact read paths. Sanitized: state, generation, queue
|
|
133
|
+
* position, checkpoint age/safety, and refusal/quarantine reason code only.
|
|
134
|
+
*/
|
|
135
|
+
export declare function formatAgentLifecycleTag(status: AgentLifecycleStatus): string;
|
|
136
|
+
/**
|
|
137
|
+
* Render a compact, operator-safe status block. Contains only sanitized
|
|
138
|
+
* lifecycle facts — never prompts, message bodies, tokens, argv, env values, or
|
|
139
|
+
* filesystem paths.
|
|
140
|
+
*/
|
|
141
|
+
export declare function formatAgentLifecycleStatus(status: AgentLifecycleStatus): string;
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short, stable, non-reversible FNV-1a digest. Lets operators correlate an
|
|
3
|
+
* opaque identity (session ref, unresolved command target) across reads without
|
|
4
|
+
* exposing the underlying payload — which may be a filesystem path or secret.
|
|
5
|
+
*/
|
|
6
|
+
export function fingerprintToken(value) {
|
|
7
|
+
let hash = 0x811c9dc5;
|
|
8
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
9
|
+
hash ^= value.charCodeAt(i);
|
|
10
|
+
hash = Math.imul(hash, 0x01000193);
|
|
11
|
+
}
|
|
12
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Redact a durable runtime spec into an operator-safe view: presence flags,
|
|
16
|
+
* counts, and opaque references only. Raw argv, environment values, filesystem
|
|
17
|
+
* paths, and private socket paths are never surfaced. This is the sanctioned
|
|
18
|
+
* redaction-by-construction boundary for any operator/status/inspect surface.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Fail-closed sanitizer for an otherwise-unconstrained runtime-spec facet
|
|
22
|
+
* (`configFingerprint`, `expectedHost`, `launchSource`). `AgentRuntimeSpec` puts
|
|
23
|
+
* no shape constraint on these adapter-authored strings, so a path, unix socket
|
|
24
|
+
* path, `KEY=value` assignment, quoted span, or other secret-bearing value could
|
|
25
|
+
* be persisted into one and then copied verbatim onto an operator/JSON surface,
|
|
26
|
+
* bypassing the redaction-by-construction boundary. Only a strict machine token
|
|
27
|
+
* (hostname / hash / short machine code: an alphanumeric-anchored run of
|
|
28
|
+
* `[A-Za-z0-9_.-]`, no separator/quote/assignment/whitespace) is passed through;
|
|
29
|
+
* ANY other shape collapses to an opaque, non-reversible `#<fingerprint>` so an
|
|
30
|
+
* operator can still correlate the value across reads without exposing it.
|
|
31
|
+
*/
|
|
32
|
+
function sanitizeSpecFacet(value) {
|
|
33
|
+
const raw = (value ?? "").trim();
|
|
34
|
+
if (raw.length === 0)
|
|
35
|
+
return "";
|
|
36
|
+
if (/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(raw))
|
|
37
|
+
return raw;
|
|
38
|
+
return `#${fingerprintToken(raw)}`;
|
|
39
|
+
}
|
|
40
|
+
export function redactRuntimeSpec(spec) {
|
|
41
|
+
const rawRef = spec.sessionResumeRef;
|
|
42
|
+
const separatorIndex = rawRef.indexOf(":");
|
|
43
|
+
const kindHint = separatorIndex > 0 ? rawRef.slice(0, separatorIndex) : "";
|
|
44
|
+
const knownKinds = ["session", "leaf", "cwd", "broker"];
|
|
45
|
+
const kind = knownKinds.includes(kindHint)
|
|
46
|
+
? kindHint
|
|
47
|
+
: "unknown";
|
|
48
|
+
// The session ref payload is NEVER surfaced verbatim: for `cwd`/`leaf` kinds
|
|
49
|
+
// (and unrecognized producers) it can be a filesystem path or otherwise
|
|
50
|
+
// sensitive. Instead emit `<kind>:#<fingerprint>` — a stable, short,
|
|
51
|
+
// non-reversible FNV-1a digest that lets operators correlate identity across
|
|
52
|
+
// reads without exposing the payload — and flag whether it looked path-like.
|
|
53
|
+
const payload = separatorIndex > 0 ? rawRef.slice(separatorIndex + 1) : rawRef;
|
|
54
|
+
const hasPath = /[\\/]/.test(payload) || payload.startsWith("~");
|
|
55
|
+
const ref = `${kind}:#${fingerprintToken(rawRef)}`;
|
|
56
|
+
// Repo is reported as a path-free basename so operators can group agents
|
|
57
|
+
// without exposing the worktree/repo-root filesystem path. Split on BOTH
|
|
58
|
+
// separators so a Windows repo root (e.g. `C:\\Users\\alice\\secret-repo`) is
|
|
59
|
+
// never emitted verbatim as its own "basename".
|
|
60
|
+
const repoSegments = spec.repoRoot.split(/[\\/]/).filter(Boolean);
|
|
61
|
+
const repo = repoSegments.length > 0 ? repoSegments[repoSegments.length - 1] : null;
|
|
62
|
+
// These adapter-authored facets are shape-unconstrained on the spec, so they
|
|
63
|
+
// are sanitized fail-closed before reaching any operator/JSON surface: a strict
|
|
64
|
+
// machine token passes through, anything else is fingerprinted (see
|
|
65
|
+
// `sanitizeSpecFacet`). Applies to the mirrored `session.host` too.
|
|
66
|
+
const expectedHost = sanitizeSpecFacet(spec.expectedHost);
|
|
67
|
+
return {
|
|
68
|
+
agentId: spec.agentId,
|
|
69
|
+
session: {
|
|
70
|
+
kind,
|
|
71
|
+
ref,
|
|
72
|
+
host: expectedHost,
|
|
73
|
+
hasPath,
|
|
74
|
+
},
|
|
75
|
+
repo,
|
|
76
|
+
hasWorktree: Boolean(spec.worktreePath),
|
|
77
|
+
runtimeKind: spec.runtimeKind,
|
|
78
|
+
hasTmuxSession: spec.runtimeKind === "tmux" && Boolean(spec.tmuxSession),
|
|
79
|
+
configFingerprint: sanitizeSpecFacet(spec.configFingerprint),
|
|
80
|
+
expectedHost,
|
|
81
|
+
launchSource: sanitizeSpecFacet(spec.launchSource),
|
|
82
|
+
envAllowlistCount: spec.envAllowlist.length,
|
|
83
|
+
updatedAt: spec.updatedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const DURABLE_HIBERNATION_STATES = new Set(["hibernated", "waking"]);
|
|
87
|
+
/**
|
|
88
|
+
* Bound and control-strip a free-form reason string for operator-safe display.
|
|
89
|
+
* Reasons can originate from operator input (hibernate/wake commands) and flow
|
|
90
|
+
* durably into lifecycle rows; this is the redaction-by-construction boundary
|
|
91
|
+
* that keeps such strings single-line, control-char free, and length-bounded
|
|
92
|
+
* before they reach any operator/status/JSON surface. Returns null for
|
|
93
|
+
* empty/whitespace-only input.
|
|
94
|
+
*/
|
|
95
|
+
export function sanitizeOperatorReason(value) {
|
|
96
|
+
if (value == null)
|
|
97
|
+
return null;
|
|
98
|
+
const cleaned = Array.from(value)
|
|
99
|
+
.map((ch) => {
|
|
100
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
101
|
+
return code <= 0x1f || code === 0x7f ? " " : ch;
|
|
102
|
+
})
|
|
103
|
+
.join("")
|
|
104
|
+
.replace(/\s+/g, " ")
|
|
105
|
+
.trim();
|
|
106
|
+
if (cleaned.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
// Two composed passes: first strip secret-bearing shapes (env assignments and
|
|
109
|
+
// CLI flag values) that are not paths, then redact path-like tokens. Ordering
|
|
110
|
+
// matters — a flag's value that is itself a path is caught by the first pass.
|
|
111
|
+
const redacted = redactPathLikeTokens(redactSecretAssignments(cleaned));
|
|
112
|
+
return redacted.length > 120 ? `${redacted.slice(0, 117)}\u2026` : redacted;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Sanitize an operator-authored hibernation/wake TARGET before it reaches the
|
|
116
|
+
* confirmation-policy / prompt surface. Unlike a free-form reason, a target can
|
|
117
|
+
* be a durable stable id (`host:session:<ref>`, `host:cwd:<path>`) whose tail
|
|
118
|
+
* embeds the session-resume identity — the same value {@link redactRuntimeSpec}
|
|
119
|
+
* fingerprints — so it must never be echoed verbatim. Fail-closed by shape: only
|
|
120
|
+
* a plain broker-safe identifier slug (an agent name/id with no separators that
|
|
121
|
+
* could embed a session/path/secret identity) is passed through; ANY other shape
|
|
122
|
+
* (stable-id triple, session/cwd/worktree token, path, `KEY=value`, quotes,
|
|
123
|
+
* whitespace) collapses to an opaque, non-reversible `target:#<fingerprint>`
|
|
124
|
+
* (matching {@link unknownHibernationTarget}). The RAW target is unaffected — the
|
|
125
|
+
* broker still resolves it server-side; only the operator-facing echo is redacted.
|
|
126
|
+
*/
|
|
127
|
+
export function sanitizeOperatorTarget(target) {
|
|
128
|
+
const raw = (target ?? "").trim();
|
|
129
|
+
if (raw.length === 0)
|
|
130
|
+
return "(unnamed)";
|
|
131
|
+
// Broker-safe plain identifier (agent name/id): a leading optional `@`, then an
|
|
132
|
+
// alphanumeric-anchored slug of `[A-Za-z0-9_.-]`. No colon, slash, backslash,
|
|
133
|
+
// `=`, quote, or whitespace — so it cannot carry a stable-id/session/path/secret
|
|
134
|
+
// identity and is safe to echo.
|
|
135
|
+
if (/^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(raw))
|
|
136
|
+
return raw;
|
|
137
|
+
return `target:#${fingerprintToken(raw)}`;
|
|
138
|
+
}
|
|
139
|
+
// Common prose that uses a single "/" and must NOT be treated as a path. Bare
|
|
140
|
+
// two-token connectives only; anything path-shaped (multi-segment, file-like,
|
|
141
|
+
// absolute, or relative-prefixed) is still redacted by `redactPathLikeTokens`.
|
|
142
|
+
const PROSE_SLASH_TOKENS = new Set([
|
|
143
|
+
"and/or",
|
|
144
|
+
"or/and",
|
|
145
|
+
"he/she",
|
|
146
|
+
"she/he",
|
|
147
|
+
"w/",
|
|
148
|
+
"w/o",
|
|
149
|
+
"n/a",
|
|
150
|
+
"i/o",
|
|
151
|
+
"km/h",
|
|
152
|
+
"tcp/ip",
|
|
153
|
+
"24/7",
|
|
154
|
+
]);
|
|
155
|
+
/**
|
|
156
|
+
* Redact secret-bearing NON-path shapes from an operator free-form string:
|
|
157
|
+
* environment/CLI assignments (`TOKEN=deadbeef` → `TOKEN=<redacted>`) and CLI
|
|
158
|
+
* flag values (`--api-key secret` / `--api-key=secret` → `--api-key <redacted>`).
|
|
159
|
+
* The env var name / flag name is kept (it is not itself the secret) so the
|
|
160
|
+
* reason stays actionable, while the value can never reach an operator surface.
|
|
161
|
+
*
|
|
162
|
+
* Whole-string, quote- and punctuation-aware, and fail-closed. Earlier
|
|
163
|
+
* whitespace tokenization leaked on quoted (`TOKEN="dead beef"`), spaced
|
|
164
|
+
* (`TOKEN = deadbeef`), and punctuation-wrapped (`(--api-key sk-123)`) secrets,
|
|
165
|
+
* and a naive per-token pass leaked the tail of a spaced value after a flag
|
|
166
|
+
* (`--api-key "sk live 123`). Two ordered passes consume each secret VALUE
|
|
167
|
+
* through its end — a possibly-unterminated quoted span OR a non-space run:
|
|
168
|
+
* 1. `key=value` / `key = value` keeps the key name and redacts the value; the
|
|
169
|
+
* key matcher starts at the first identifier char, so a leading `--` or
|
|
170
|
+
* punctuation (`(--api-key=…`, `TOKEN = …`) is ignored and still caught.
|
|
171
|
+
* 2. A flag followed by a separate value (`--flag value` / `-f value`, with any
|
|
172
|
+
* leading punctuation) redacts the value — including a quoted span with
|
|
173
|
+
* internal spaces, even if the closing quote is missing — unless the value
|
|
174
|
+
* is itself another flag.
|
|
175
|
+
* A value is matched by `VALUE` below: a single/double/back-quoted span whose
|
|
176
|
+
* closing quote is optional (so an unterminated quote is consumed to the value's
|
|
177
|
+
* end, never leaking its spaced tail), otherwise a run of non-space characters.
|
|
178
|
+
*/
|
|
179
|
+
// agent-standards-ignore prefer-inline-single-use-helper: distinct multi-pass
|
|
180
|
+
// secret-redaction (key=value assignments, flag values, quoted/unterminated
|
|
181
|
+
// spans); composed with the path-redaction pass in sanitizeOperatorReason and
|
|
182
|
+
// kept separate for readability of each fail-closed pass.
|
|
183
|
+
function redactSecretAssignments(value) {
|
|
184
|
+
// A secret value: a (possibly unterminated) quoted span, else a non-space run.
|
|
185
|
+
const VALUE = `(?:"(?:\\\\.|[^"])*"?|'(?:\\\\.|[^'])*'?|\`(?:\\\\.|[^\`])*\`?|\\S+)`;
|
|
186
|
+
let out = value;
|
|
187
|
+
// 1) key=value / key = value → keep the key name, redact the value.
|
|
188
|
+
out = out.replace(new RegExp(`([A-Za-z_][A-Za-z0-9_.-]*)\\s*=\\s*${VALUE}`, "g"), (_match, key) => `${key}=<redacted>`);
|
|
189
|
+
// 2) `--flag value` / `-f value` (any leading punctuation) → redact the value
|
|
190
|
+
// unless it is itself another flag.
|
|
191
|
+
out = out.replace(new RegExp(`(--?[A-Za-z][A-Za-z0-9_-]*)\\s+(?!--?[A-Za-z])${VALUE}`, "g"), (_match, flag) => `${flag} <redacted>`);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Replace filesystem-path-like tokens with `<path>` so operator-authored
|
|
196
|
+
* free-form strings (reasons, echoed targets) can never surface private
|
|
197
|
+
* absolute paths, unix socket paths, or repo-relative paths in operator/JSON
|
|
198
|
+
* output. Conservative but fail-closed on ambiguous relative paths: any
|
|
199
|
+
* single-separator token that is not a known prose connective (see
|
|
200
|
+
* `PROSE_SLASH_TOKENS`) is redacted, so `accounts/acme` is caught while
|
|
201
|
+
* "and/or" survives. This is a redaction-by-construction boundary, not a
|
|
202
|
+
* security parser.
|
|
203
|
+
*/
|
|
204
|
+
export function redactPathLikeTokens(value) {
|
|
205
|
+
return value
|
|
206
|
+
.split(/(\s+)/)
|
|
207
|
+
.map((token) => {
|
|
208
|
+
if (token.length === 0 || /\s/.test(token))
|
|
209
|
+
return token;
|
|
210
|
+
const slashCount = (token.match(/[/\\]/g) ?? []).length;
|
|
211
|
+
// Strip trailing sentence punctuation before the prose-allowlist check so
|
|
212
|
+
// "and/or," is still recognized as prose.
|
|
213
|
+
const bare = token.replace(/[.,;:!?)\]]+$/, "").toLowerCase();
|
|
214
|
+
const isProse = slashCount === 1 && PROSE_SLASH_TOKENS.has(bare);
|
|
215
|
+
const pathLike = !isProse &&
|
|
216
|
+
(/^[~/]/.test(token) || // /abs or ~/home
|
|
217
|
+
/^\.\.?[/\\]/.test(token) || // ./rel or ../rel
|
|
218
|
+
/^[A-Za-z]:[\\/]/.test(token) || // C:\ or C:/ (Windows)
|
|
219
|
+
slashCount >= 1); // any relative/absolute separator (fail-closed)
|
|
220
|
+
return pathLike ? "<path>" : token;
|
|
221
|
+
})
|
|
222
|
+
.join("");
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Redaction-by-construction for RUNTIME-authored checkpoint reasons. A checkpoint
|
|
226
|
+
* reason is authored by the worker runtime, so — unlike the trusted-operator
|
|
227
|
+
* reason path (which only redacts obvious path-like tokens) — it must NOT be able
|
|
228
|
+
* to smuggle argv, env assignments (`TOKEN=secret`), CLI flags (`--api-key x`),
|
|
229
|
+
* extensionless relative paths (`accounts/acme`), or other free prose onto any
|
|
230
|
+
* operator/telemetry/JSON surface. We therefore allowlist by *shape*: only a
|
|
231
|
+
* short single-token machine code (`active_port_lease`, `checkpoint_timeout`, …)
|
|
232
|
+
* is passed through; anything containing whitespace, separators, or exotic
|
|
233
|
+
* characters collapses to the static `unspecified` code. Diagnostic prose belongs
|
|
234
|
+
* in the worker's own logs, not the broker's operator surface.
|
|
235
|
+
*/
|
|
236
|
+
export function sanitizeCheckpointReasonCode(value) {
|
|
237
|
+
if (value == null)
|
|
238
|
+
return "unspecified";
|
|
239
|
+
const trimmed = value.trim();
|
|
240
|
+
return /^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(trimmed) ? trimmed.toLowerCase() : "unspecified";
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Compose an operator-safe, actionable per-agent lifecycle status from already
|
|
244
|
+
* sanitized inputs. Pure: it performs no IO and never emits raw argv, env
|
|
245
|
+
* values, or filesystem/socket paths.
|
|
246
|
+
*/
|
|
247
|
+
export function buildAgentLifecycleStatus(input) {
|
|
248
|
+
const now = input.now ?? Date.now();
|
|
249
|
+
const state = input.agent.lifecycleState ?? "live";
|
|
250
|
+
const checkpoint = input.latestCheckpoint;
|
|
251
|
+
const checkpointCreatedMs = checkpoint ? Date.parse(checkpoint.createdAt) : Number.NaN;
|
|
252
|
+
const checkpointAgeMs = checkpoint && Number.isFinite(checkpointCreatedMs)
|
|
253
|
+
? Math.max(now - checkpointCreatedMs, 0)
|
|
254
|
+
: null;
|
|
255
|
+
let queuePosition = null;
|
|
256
|
+
let queuedEntry = null;
|
|
257
|
+
const orderedQueue = input.orderedWakeQueue ?? [];
|
|
258
|
+
for (let index = 0; index < orderedQueue.length; index += 1) {
|
|
259
|
+
const entry = orderedQueue[index];
|
|
260
|
+
if (entry.agentId === input.agent.id && entry.status === "queued") {
|
|
261
|
+
queuePosition = index + 1;
|
|
262
|
+
queuedEntry = entry;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let refusal = null;
|
|
267
|
+
for (const event of input.recentEvents ?? []) {
|
|
268
|
+
if (event.agentId !== input.agent.id || event.outcome === "accepted")
|
|
269
|
+
continue;
|
|
270
|
+
if (refusal === null || event.createdAt > refusal.at) {
|
|
271
|
+
refusal = {
|
|
272
|
+
reason: sanitizeOperatorReason(event.errorCode ?? event.reason) ?? "unknown",
|
|
273
|
+
outcome: event.outcome,
|
|
274
|
+
at: event.createdAt,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const capacity = input.capacity
|
|
279
|
+
? {
|
|
280
|
+
global: {
|
|
281
|
+
inflight: input.capacity.inflightWakes,
|
|
282
|
+
max: input.capacity.maxConcurrentWakes,
|
|
283
|
+
atCapacity: input.capacity.inflightWakes >= input.capacity.maxConcurrentWakes,
|
|
284
|
+
},
|
|
285
|
+
repo: {
|
|
286
|
+
inflight: input.capacity.inflightWakesForRepo,
|
|
287
|
+
max: input.capacity.maxConcurrentWakesPerRepo,
|
|
288
|
+
atCapacity: input.capacity.inflightWakesForRepo >= input.capacity.maxConcurrentWakesPerRepo,
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
: null;
|
|
292
|
+
return {
|
|
293
|
+
agentId: input.agent.id,
|
|
294
|
+
state,
|
|
295
|
+
lifecycleVersion: input.agent.lifecycleVersion ?? 0,
|
|
296
|
+
runtimeGeneration: input.agent.runtimeGeneration ?? null,
|
|
297
|
+
hibernatePolicy: input.agent.hibernatePolicy ?? null,
|
|
298
|
+
hibernatedAt: input.agent.hibernatedAt ?? null,
|
|
299
|
+
hibernateReason: sanitizeOperatorReason(input.agent.hibernateReason),
|
|
300
|
+
lastWakeReason: sanitizeOperatorReason(input.agent.lastWakeReason),
|
|
301
|
+
graceUntil: input.agent.graceUntil ?? null,
|
|
302
|
+
idleEligibleAt: input.agent.idleEligibleAt ?? null,
|
|
303
|
+
quarantined: state === "reap-candidate",
|
|
304
|
+
checkpoint: {
|
|
305
|
+
present: Boolean(checkpoint),
|
|
306
|
+
hibernateSafe: checkpoint ? checkpoint.hibernateSafe : null,
|
|
307
|
+
ageMs: checkpointAgeMs,
|
|
308
|
+
pendingInboxCount: checkpoint ? checkpoint.pendingInboxCount : null,
|
|
309
|
+
runtimeGeneration: checkpoint ? checkpoint.runtimeGeneration : null,
|
|
310
|
+
},
|
|
311
|
+
runtimeSpec: input.runtimeSpec ? redactRuntimeSpec(input.runtimeSpec) : null,
|
|
312
|
+
wake: {
|
|
313
|
+
queued: queuedEntry !== null,
|
|
314
|
+
position: queuePosition,
|
|
315
|
+
triggerKind: queuedEntry ? queuedEntry.triggerKind : null,
|
|
316
|
+
reason: queuedEntry ? sanitizeOperatorReason(queuedEntry.reason) : null,
|
|
317
|
+
attempt: queuedEntry ? queuedEntry.attempt : null,
|
|
318
|
+
},
|
|
319
|
+
capacity,
|
|
320
|
+
refusal,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Render a single scannable, operator-safe lifecycle tag for inline use in the
|
|
325
|
+
* `agents`/`sessions` compact read paths. Sanitized: state, generation, queue
|
|
326
|
+
* position, checkpoint age/safety, and refusal/quarantine reason code only.
|
|
327
|
+
*/
|
|
328
|
+
export function formatAgentLifecycleTag(status) {
|
|
329
|
+
const parts = [status.state];
|
|
330
|
+
if (status.runtimeGeneration !== null)
|
|
331
|
+
parts.push(`gen${status.runtimeGeneration}`);
|
|
332
|
+
if (status.wake.queued && status.wake.position !== null)
|
|
333
|
+
parts.push(`q#${status.wake.position}`);
|
|
334
|
+
if (status.checkpoint.present) {
|
|
335
|
+
const age = status.checkpoint.ageMs === null ? "?" : `${Math.round(status.checkpoint.ageMs / 1000)}s`;
|
|
336
|
+
parts.push(`ckpt ${age}${status.checkpoint.hibernateSafe === false ? " unsafe" : ""}`);
|
|
337
|
+
}
|
|
338
|
+
if (status.quarantined) {
|
|
339
|
+
parts.push(`\u26a0${status.refusal ? ` ${status.refusal.reason}` : ""}`);
|
|
340
|
+
}
|
|
341
|
+
else if (status.refusal) {
|
|
342
|
+
parts.push(`refused:${status.refusal.reason}`);
|
|
343
|
+
}
|
|
344
|
+
return parts.join(" \u00b7 ");
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Render a compact, operator-safe status block. Contains only sanitized
|
|
348
|
+
* lifecycle facts — never prompts, message bodies, tokens, argv, env values, or
|
|
349
|
+
* filesystem paths.
|
|
350
|
+
*/
|
|
351
|
+
export function formatAgentLifecycleStatus(status) {
|
|
352
|
+
const lines = [];
|
|
353
|
+
const generation = status.runtimeGeneration === null ? "-" : String(status.runtimeGeneration);
|
|
354
|
+
lines.push(`${status.agentId}: ${status.state} (v${status.lifecycleVersion}, gen ${generation}, policy ${status.hibernatePolicy ?? "-"})`);
|
|
355
|
+
if (status.quarantined) {
|
|
356
|
+
lines.push(` \u26a0 quarantined as reap-candidate${status.hibernateReason ? ` — ${status.hibernateReason}` : ""}`);
|
|
357
|
+
}
|
|
358
|
+
if (status.checkpoint.present) {
|
|
359
|
+
const ageSeconds = status.checkpoint.ageMs === null ? "?" : Math.round(status.checkpoint.ageMs / 1000);
|
|
360
|
+
const safe = status.checkpoint.hibernateSafe === false ? "unsafe" : "safe";
|
|
361
|
+
lines.push(` checkpoint: ${safe}, age=${ageSeconds}s, pending_inbox=${status.checkpoint.pendingInboxCount ?? 0}, gen=${status.checkpoint.runtimeGeneration ?? "-"}`);
|
|
362
|
+
}
|
|
363
|
+
else if (DURABLE_HIBERNATION_STATES.has(status.state)) {
|
|
364
|
+
lines.push(" checkpoint: none recorded");
|
|
365
|
+
}
|
|
366
|
+
if (status.runtimeSpec) {
|
|
367
|
+
const spec = status.runtimeSpec;
|
|
368
|
+
lines.push(` runtime spec: present (repo=${spec.repo ?? "-"}, worktree=${spec.hasWorktree ? "yes" : "no"}, runtime=${spec.runtimeKind}, env_allow=${spec.envAllowlistCount}, fingerprint=${spec.configFingerprint})`);
|
|
369
|
+
}
|
|
370
|
+
else if (DURABLE_HIBERNATION_STATES.has(status.state)) {
|
|
371
|
+
lines.push(" runtime spec: MISSING");
|
|
372
|
+
}
|
|
373
|
+
if (status.wake.queued) {
|
|
374
|
+
const position = status.wake.position === null ? "?" : String(status.wake.position);
|
|
375
|
+
lines.push(` wake queued: position ${position}, trigger=${status.wake.triggerKind ?? "-"}, reason=${status.wake.reason ?? "-"}, attempt=${status.wake.attempt ?? 0}`);
|
|
376
|
+
}
|
|
377
|
+
if (status.capacity) {
|
|
378
|
+
const { global, repo } = status.capacity;
|
|
379
|
+
const globalMark = global.atCapacity ? " (at capacity)" : "";
|
|
380
|
+
const repoMark = repo.atCapacity ? " (at capacity)" : "";
|
|
381
|
+
lines.push(` wake capacity: global ${global.inflight}/${global.max}${globalMark}, repo ${repo.inflight}/${repo.max}${repoMark}`);
|
|
382
|
+
}
|
|
383
|
+
if (status.refusal) {
|
|
384
|
+
lines.push(` last refusal: ${status.refusal.reason} (${status.refusal.outcome}) at ${status.refusal.at}`);
|
|
385
|
+
}
|
|
386
|
+
if (status.lastWakeReason) {
|
|
387
|
+
lines.push(` last wake reason: ${status.lastWakeReason}`);
|
|
388
|
+
}
|
|
389
|
+
return lines.join("\n");
|
|
390
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AgentLifecycleEvent, AgentLifecycleRetentionInfo } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Read-only, sanitized rollup of hibernation lifecycle telemetry.
|
|
4
|
+
*
|
|
5
|
+
* This is the operable status surface over the append-only
|
|
6
|
+
* `agent_lifecycle_events` table (see `getRecentAgentLifecycleEvents`). It is a
|
|
7
|
+
* pure derivation of already-sanitized events — it never reads prompts, message
|
|
8
|
+
* bodies, tokens, or environment values — so it is safe to render in a CLI,
|
|
9
|
+
* dashboard, or Slack status reply without touching the live broker.
|
|
10
|
+
*
|
|
11
|
+
* The counts mirror the documented dogfood report query so a fleet operator can
|
|
12
|
+
* reconcile this in-process summary against the raw SQL.
|
|
13
|
+
*/
|
|
14
|
+
export interface HibernationTelemetrySummary {
|
|
15
|
+
/** Total events considered. */
|
|
16
|
+
totalEvents: number;
|
|
17
|
+
/** Accepted transitions into `hibernated`. */
|
|
18
|
+
hibernations: number;
|
|
19
|
+
/** Accepted `waking -> live` transitions. */
|
|
20
|
+
wakeSuccesses: number;
|
|
21
|
+
/** Events whose outcome was not `accepted` (refusals, stale fences, aborts). */
|
|
22
|
+
failures: number;
|
|
23
|
+
/** Mean accepted wake latency in ms, or null when no accepted wake is present. */
|
|
24
|
+
meanWakeMs: number | null;
|
|
25
|
+
/** Nearest-rank 95th percentile accepted wake latency in ms, or null. */
|
|
26
|
+
p95WakeMs: number | null;
|
|
27
|
+
/** Largest observed queue depth across events. */
|
|
28
|
+
maxQueueDepth: number;
|
|
29
|
+
/** Largest observed oldest-queued-message age in ms across events. */
|
|
30
|
+
maxOldestQueueAgeMs: number;
|
|
31
|
+
/** Sum of max(rssBefore - rssAfter, 0) over accepted hibernations, in bytes. */
|
|
32
|
+
recoveredRssBytes: number;
|
|
33
|
+
/** Non-accepted outcome reasons, most frequent first. */
|
|
34
|
+
refusalReasons: Array<{
|
|
35
|
+
reason: string;
|
|
36
|
+
count: number;
|
|
37
|
+
}>;
|
|
38
|
+
/** Distinct agents represented in the event window. */
|
|
39
|
+
agentCount: number;
|
|
40
|
+
/** Retained/pruned counters when retention info is provided. */
|
|
41
|
+
retainedCount: number | null;
|
|
42
|
+
prunedCount: number | null;
|
|
43
|
+
lastPrunedAt: string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Summarize a window of lifecycle events into an operable status rollup. Pass
|
|
47
|
+
* the newest events (any order); retention info is optional passthrough.
|
|
48
|
+
*/
|
|
49
|
+
export declare function summarizeHibernationTelemetry(events: AgentLifecycleEvent[], retention?: AgentLifecycleRetentionInfo): HibernationTelemetrySummary;
|
|
50
|
+
/**
|
|
51
|
+
* Render a compact, human-readable status block from a telemetry summary. Safe
|
|
52
|
+
* for CLI/Slack output: it contains only aggregate counters, never bodies.
|
|
53
|
+
*/
|
|
54
|
+
export declare function formatHibernationTelemetry(summary: HibernationTelemetrySummary): string;
|