@animalabs/agent-framework 0.14.0 → 0.16.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/README.md +22 -1
- package/dist/src/agent.d.ts +42 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +94 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts +9 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +156 -0
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/api/types.d.ts +15 -0
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/framework.d.ts +423 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1906 -102
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +16 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +41 -3
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +172 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +444 -2
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +9 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +71 -13
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +145 -0
- package/dist/src/modules/history/index.d.ts.map +1 -0
- package/dist/src/modules/history/index.js +1150 -0
- package/dist/src/modules/history/index.js.map +1 -0
- package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
- package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
- package/dist/src/modules/history/search-regex-worker.js +57 -0
- package/dist/src/modules/history/search-regex-worker.js.map +1 -0
- package/dist/src/modules/index.d.ts +1 -0
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -0
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +42 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +5 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/operator-log.d.ts +78 -0
- package/dist/src/operator-log.d.ts.map +1 -0
- package/dist/src/operator-log.js +102 -0
- package/dist/src/operator-log.js.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +20 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/types/framework.d.ts +24 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +40 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +199 -1
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/api-admin-gate.test.d.ts +2 -0
- package/dist/test/api-admin-gate.test.d.ts.map +1 -0
- package/dist/test/api-admin-gate.test.js +116 -0
- package/dist/test/api-admin-gate.test.js.map +1 -0
- package/dist/test/channel-label-history.test.d.ts +2 -0
- package/dist/test/channel-label-history.test.d.ts.map +1 -0
- package/dist/test/channel-label-history.test.js +424 -0
- package/dist/test/channel-label-history.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +16 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/event-gate.test.js +44 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
- package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
- package/dist/test/helpers/quiesce-crash-child.js +156 -0
- package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
- package/dist/test/history-module-overview.test.d.ts +2 -0
- package/dist/test/history-module-overview.test.d.ts.map +1 -0
- package/dist/test/history-module-overview.test.js +711 -0
- package/dist/test/history-module-overview.test.js.map +1 -0
- package/dist/test/history-module.test.d.ts +2 -0
- package/dist/test/history-module.test.d.ts.map +1 -0
- package/dist/test/history-module.test.js +595 -0
- package/dist/test/history-module.test.js.map +1 -0
- package/dist/test/host-quiesce.test.d.ts +2 -0
- package/dist/test/host-quiesce.test.d.ts.map +1 -0
- package/dist/test/host-quiesce.test.js +863 -0
- package/dist/test/host-quiesce.test.js.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +39 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -1
- package/dist/test/operator-surgery.test.d.ts +2 -0
- package/dist/test/operator-surgery.test.d.ts.map +1 -0
- package/dist/test/operator-surgery.test.js +398 -0
- package/dist/test/operator-surgery.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.js +118 -1
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
- package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
- package/dist/test/puppet-turn-reservation.test.js +139 -0
- package/dist/test/puppet-turn-reservation.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +29 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.js +114 -0
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.d.ts +2 -0
- package/dist/test/workspace-read-text.test.d.ts.map +1 -0
- package/dist/test/workspace-read-text.test.js +101 -0
- package/dist/test/workspace-read-text.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +110 -0
- package/src/api/server.ts +159 -2
- package/src/api/types.ts +15 -0
- package/src/framework.ts +2196 -115
- package/src/gate/event-gate.ts +47 -4
- package/src/index.ts +5 -1
- package/src/mcpl/channel-registry.ts +495 -2
- package/src/mcpl/server-connection.ts +73 -13
- package/src/modules/history/index.ts +1277 -0
- package/src/modules/history/search-regex-worker.ts +76 -0
- package/src/modules/index.ts +2 -0
- package/src/modules/workspace/index.ts +42 -1
- package/src/modules/workspace/types.ts +5 -0
- package/src/operator-log.ts +132 -0
- package/src/recovery/discord-awareness-outbox.ts +19 -0
- package/src/types/framework.ts +27 -0
- package/src/types/trace.ts +46 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-regex-worker — runs a caller-supplied regex against a batch of
|
|
3
|
+
* message text on a separate thread, so HistoryModule's `search` tool can
|
|
4
|
+
* forcibly terminate a catastrophically-backtracking pattern (ReDoS)
|
|
5
|
+
* instead of blocking the framework's single-threaded event loop.
|
|
6
|
+
*
|
|
7
|
+
* Why a worker and not a Promise.race/setTimeout "timeout": JS is
|
|
8
|
+
* single-threaded, so nothing on the SAME thread can interrupt a
|
|
9
|
+
* synchronous RegExp.exec() call already in flight — a timeout callback
|
|
10
|
+
* racing it can't even fire until the blocking call returns, by which point
|
|
11
|
+
* it's too late. A worker thread is real OS-level concurrency: the parent's
|
|
12
|
+
* event loop keeps running, and `worker.terminate()` actually kills the
|
|
13
|
+
* stuck thread rather than just giving up on waiting for it.
|
|
14
|
+
*
|
|
15
|
+
* Only regex-mode search routes through this worker. Plain substring search
|
|
16
|
+
* (String.prototype.indexOf) is inherently linear in input length and has
|
|
17
|
+
* no equivalent risk, so it stays in-process — the common case pays no
|
|
18
|
+
* worker-spawn overhead.
|
|
19
|
+
*
|
|
20
|
+
* One-shot: the whole job (already-flattened text, already syntax-validated
|
|
21
|
+
* pattern/flags, match limit) arrives via `workerData` at construction; this
|
|
22
|
+
* script runs it to completion and posts exactly one message, then the
|
|
23
|
+
* parent always terminates the worker (whether it finished normally or had
|
|
24
|
+
* to be killed for running long) — no persistent pool, so a wedged worker
|
|
25
|
+
* from one bad pattern can never contaminate a later call.
|
|
26
|
+
*/
|
|
27
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
28
|
+
|
|
29
|
+
export interface SearchWorkerJob {
|
|
30
|
+
/** Flattened candidate text, aligned by index with the parent's candidate array. */
|
|
31
|
+
texts: string[];
|
|
32
|
+
pattern: string;
|
|
33
|
+
flags: string;
|
|
34
|
+
/** Stop scanning once this many matches are found. */
|
|
35
|
+
limit: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SearchWorkerMatch {
|
|
39
|
+
/** Index into `texts` / the parent's candidate array. */
|
|
40
|
+
candidateIndex: number;
|
|
41
|
+
matchIndex: number;
|
|
42
|
+
matchLength: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SearchWorkerMessage =
|
|
46
|
+
| { type: 'done'; matches: SearchWorkerMatch[]; scanned: number }
|
|
47
|
+
| { type: 'error'; error: string };
|
|
48
|
+
|
|
49
|
+
const { texts, pattern, flags, limit } = workerData as SearchWorkerJob;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Re-construct (not re-validate for safety — RegExp syntax is what it is;
|
|
53
|
+
// the parent already rejected an uncompilable pattern before ever getting
|
|
54
|
+
// here) the matcher on this thread. Compilation succeeding doesn't bound
|
|
55
|
+
// MATCHING time — that's exactly the risk this worker exists to contain.
|
|
56
|
+
const matcher = new RegExp(pattern, flags);
|
|
57
|
+
const matches: SearchWorkerMatch[] = [];
|
|
58
|
+
let scanned = 0;
|
|
59
|
+
for (let i = 0; i < texts.length; i++) {
|
|
60
|
+
// Check the limit BEFORE doing any work for this candidate — not after
|
|
61
|
+
// pushing a match — so limit:0 (a valid clampCount value: it's >= 0)
|
|
62
|
+
// correctly yields zero matches instead of one. Checking post-push would
|
|
63
|
+
// always let through the match that first reaches the limit.
|
|
64
|
+
if (matches.length >= limit) break;
|
|
65
|
+
scanned++;
|
|
66
|
+
const m = matcher.exec(texts[i] ?? '');
|
|
67
|
+
if (m) {
|
|
68
|
+
matches.push({ candidateIndex: i, matchIndex: m.index, matchLength: m[0].length });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const done: SearchWorkerMessage = { type: 'done', matches, scanned };
|
|
72
|
+
parentPort?.postMessage(done);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
const errMsg: SearchWorkerMessage = { type: 'error', error: e instanceof Error ? e.message : String(e) };
|
|
75
|
+
parentPort?.postMessage(errMsg);
|
|
76
|
+
}
|
package/src/modules/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type { ApiEvent } from './api/index.js';
|
|
|
8
8
|
export { HealthModule } from './health/index.js';
|
|
9
9
|
export type { HealthModuleConfig } from './health/index.js';
|
|
10
10
|
|
|
11
|
+
export { HistoryModule } from './history/index.js';
|
|
12
|
+
|
|
11
13
|
export { WorkspaceModule, WorkspaceReadError } from './workspace/index.js';
|
|
12
14
|
export type { WorkspaceReadErrorCode, WorkspaceReadStage, WorkspaceDiskReadResult, ReadFileFromDiskOptions } from './workspace/index.js';
|
|
13
15
|
export type {
|
|
@@ -932,13 +932,19 @@ export class WorkspaceModule implements Module {
|
|
|
932
932
|
return [
|
|
933
933
|
{
|
|
934
934
|
name: 'read',
|
|
935
|
-
description: 'Read a file from the workspace. Returns content with line numbers.
|
|
935
|
+
description: 'Read a file from the workspace. Returns content with line numbers. '
|
|
936
|
+
+ 'Reads at most 2000 lines per call by default — use offset/limit to page through larger files. '
|
|
937
|
+
+ 'For long lines or spill files, use offsetChars/limitChars instead: returns raw text without '
|
|
938
|
+
+ 'line numbers, with nextOffsetChars (null at EOF). Keep limitChars when continuing. '
|
|
939
|
+
+ 'Character offsets count UTF-16 code units; do not mix character and line parameters.',
|
|
936
940
|
inputSchema: {
|
|
937
941
|
type: 'object' as const,
|
|
938
942
|
properties: {
|
|
939
943
|
path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
|
|
940
944
|
offset: { type: 'number', description: 'Starting line number (1-indexed)' },
|
|
941
945
|
limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
|
|
946
|
+
offsetChars: { type: 'integer', description: 'Character paging: zero-based UTF-16 offset (default 0); use nextOffsetChars to continue' },
|
|
947
|
+
limitChars: { type: 'integer', description: 'Character paging: code units to return (default 2000), plus at most one to keep a surrogate pair intact. Use the limit suggested by a spill notice.' },
|
|
942
948
|
},
|
|
943
949
|
required: ['path'],
|
|
944
950
|
},
|
|
@@ -1741,6 +1747,19 @@ export class WorkspaceModule implements Module {
|
|
|
1741
1747
|
// ==========================================================================
|
|
1742
1748
|
|
|
1743
1749
|
private async handleRead(input: ReadInput): Promise<ToolResult> {
|
|
1750
|
+
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
1751
|
+
const offsetChars = input.offsetChars ?? 0;
|
|
1752
|
+
const limitChars = input.limitChars ?? 2000;
|
|
1753
|
+
if (characterPaging) {
|
|
1754
|
+
if (input.offset !== undefined || input.limit !== undefined) {
|
|
1755
|
+
return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
|
|
1756
|
+
}
|
|
1757
|
+
if (!Number.isSafeInteger(offsetChars) || offsetChars < 0
|
|
1758
|
+
|| !Number.isSafeInteger(limitChars) || limitChars < 1
|
|
1759
|
+
|| input.offsetChars === null || input.limitChars === null) {
|
|
1760
|
+
return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1744
1763
|
const { mount, relativePath } = this.parsePath(input.path);
|
|
1745
1764
|
const store = this.getStore();
|
|
1746
1765
|
|
|
@@ -1757,6 +1776,28 @@ export class WorkspaceModule implements Module {
|
|
|
1757
1776
|
}
|
|
1758
1777
|
|
|
1759
1778
|
const content = blob.toString('utf-8');
|
|
1779
|
+
if (characterPaging) {
|
|
1780
|
+
const start = Math.min(offsetChars, content.length);
|
|
1781
|
+
const splitsPair = (at: number): boolean =>
|
|
1782
|
+
at > 0 && at < content.length
|
|
1783
|
+
&& content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
|
|
1784
|
+
&& content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
|
|
1785
|
+
if (splitsPair(start)) {
|
|
1786
|
+
return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
|
|
1787
|
+
}
|
|
1788
|
+
let end = start + Math.min(limitChars, content.length - start);
|
|
1789
|
+
if (splitsPair(end)) end++;
|
|
1790
|
+
return {
|
|
1791
|
+
success: true,
|
|
1792
|
+
data: {
|
|
1793
|
+
path: input.path,
|
|
1794
|
+
totalChars: content.length,
|
|
1795
|
+
offsetChars: start,
|
|
1796
|
+
nextOffsetChars: end < content.length ? end : null,
|
|
1797
|
+
content: content.slice(start, end),
|
|
1798
|
+
},
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1760
1801
|
const lines = content.split('\n');
|
|
1761
1802
|
|
|
1762
1803
|
// Apply offset/limit. An unlimited read of a large file would inject the
|
|
@@ -133,6 +133,11 @@ export interface ReadInput {
|
|
|
133
133
|
offset?: number;
|
|
134
134
|
/** Number of lines to read (optional) */
|
|
135
135
|
limit?: number;
|
|
136
|
+
/** Zero-based UTF-16 offset; selects character paging. Cannot mix with line offset/limit. */
|
|
137
|
+
offsetChars?: number;
|
|
138
|
+
/** Character page size in UTF-16 code units (default 2000); selects character paging.
|
|
139
|
+
* May include one extra code unit to keep a surrogate pair intact. */
|
|
140
|
+
limitChars?: number;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
export interface ReadImageInput {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator action log — a durable, append-only JSONL record of every
|
|
3
|
+
* operator-initiated mutation of a live resident: branch rollbacks, message
|
|
4
|
+
* suppressions, undo/redo/hide, unstick/nudge, runtime-settings changes,
|
|
5
|
+
* host quiesce/resume. One file per store, next to the chronicle
|
|
6
|
+
* (`<storePath>/operator-actions.jsonl`), so it travels with backups and
|
|
7
|
+
* survives host restarts the way `repair-receipts.jsonl` and
|
|
8
|
+
* `watchdog-wedge.jsonl` do.
|
|
9
|
+
*
|
|
10
|
+
* The chronicle record log is the authoritative history of *what* changed;
|
|
11
|
+
* this file records *who asked for it, from where, and why* — the part the
|
|
12
|
+
* store cannot know. Writes are best-effort (a logging failure never fails
|
|
13
|
+
* the action) and synchronous (`write(2)`, not `fsync`): an entry survives a
|
|
14
|
+
* process crash once the call returns, not a power loss. Mutations record
|
|
15
|
+
* after they complete, so a crash between the chronicle switch and the
|
|
16
|
+
* append loses the who/why for that one action — the branch itself remains
|
|
17
|
+
* the evidence. The file is created 0600: it carries requester identity and
|
|
18
|
+
* free-text operator notes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** Who asked. `via` names the surface ('webui', 'host-command', 'api',
|
|
25
|
+
* 'cli', …); `name`/`id` are whatever identity that surface has. */
|
|
26
|
+
export interface OperatorRequester {
|
|
27
|
+
via: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
id?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OperatorLogEntry {
|
|
33
|
+
/** ISO-8601 wall-clock time of the record. */
|
|
34
|
+
at: string;
|
|
35
|
+
/** Action kind, e.g. 'rollback', 'suppress', 'undo', 'hide', 'quiesce'. */
|
|
36
|
+
kind: string;
|
|
37
|
+
/** Agent the action targeted (absent for host-wide actions). */
|
|
38
|
+
agent?: string;
|
|
39
|
+
requester?: OperatorRequester;
|
|
40
|
+
/** Free-text reason supplied by the operator. */
|
|
41
|
+
note?: string;
|
|
42
|
+
/** Inputs as the caller supplied them (ids, counts, patches). */
|
|
43
|
+
params?: Record<string, unknown>;
|
|
44
|
+
/** Outcome details (branches, removed ids, refs) when the action succeeded. */
|
|
45
|
+
result?: Record<string, unknown>;
|
|
46
|
+
/** Error message when the action was refused or failed. */
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type OperatorLogInput = Omit<OperatorLogEntry, 'at'> & { at?: string };
|
|
51
|
+
|
|
52
|
+
export class OperatorLog {
|
|
53
|
+
private warned = false;
|
|
54
|
+
|
|
55
|
+
constructor(readonly path: string | undefined) {}
|
|
56
|
+
|
|
57
|
+
get enabled(): boolean {
|
|
58
|
+
return this.path !== undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Append one record. Never throws. Returns the stamped entry. */
|
|
62
|
+
append(input: OperatorLogInput): OperatorLogEntry {
|
|
63
|
+
const entry: OperatorLogEntry = { ...input, at: input.at ?? new Date().toISOString() };
|
|
64
|
+
if (!this.path) return entry;
|
|
65
|
+
try {
|
|
66
|
+
const dir = dirname(this.path);
|
|
67
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
68
|
+
appendFileSync(this.path, JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!this.warned) {
|
|
71
|
+
this.warned = true;
|
|
72
|
+
console.error(
|
|
73
|
+
`[operator-log] failed to write ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the newest `limit` entries (oldest first within the slice). Reads
|
|
82
|
+
* the whole file — the log is small (one line per operator action).
|
|
83
|
+
* Malformed lines are skipped rather than failing the read.
|
|
84
|
+
*/
|
|
85
|
+
readTail(limit = 100): OperatorLogEntry[] {
|
|
86
|
+
if (!this.path || !existsSync(this.path)) return [];
|
|
87
|
+
let raw: string;
|
|
88
|
+
try {
|
|
89
|
+
raw = readFileSync(this.path, 'utf8');
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
94
|
+
const slice = lines.slice(Math.max(0, lines.length - Math.max(0, limit)));
|
|
95
|
+
const out: OperatorLogEntry[] = [];
|
|
96
|
+
for (const line of slice) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(line) as OperatorLogEntry;
|
|
99
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.kind === 'string') out.push(parsed);
|
|
100
|
+
} catch {
|
|
101
|
+
// skip torn/corrupt line
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function defaultOperatorLogPath(storePath: string): string {
|
|
109
|
+
return join(storePath, 'operator-actions.jsonl');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Ids lists in log records are capped so one wide `hide`/`suppress` cannot
|
|
113
|
+
* write a multi-KB line; the full count is always recorded alongside. */
|
|
114
|
+
export const OPERATOR_LOG_ID_CAP = 50;
|
|
115
|
+
export function capIds(ids: string[]): { ids: string[]; count: number; truncated?: true } {
|
|
116
|
+
return ids.length > OPERATOR_LOG_ID_CAP
|
|
117
|
+
? { ids: ids.slice(0, OPERATOR_LOG_ID_CAP), count: ids.length, truncated: true }
|
|
118
|
+
: { ids, count: ids.length };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Thrown by live surgery methods when a request cannot be honored. `code`
|
|
122
|
+
* lets surfaces distinguish "agent busy — quiesce first" from bad input. */
|
|
123
|
+
export class OperatorActionError extends Error {
|
|
124
|
+
constructor(
|
|
125
|
+
readonly code: 'unknown-agent' | 'agent-busy' | 'unknown-message' | 'invalid' | 'failed',
|
|
126
|
+
message: string,
|
|
127
|
+
options?: { cause?: unknown },
|
|
128
|
+
) {
|
|
129
|
+
super(message, options);
|
|
130
|
+
this.name = 'OperatorActionError';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -158,6 +158,25 @@ export class DiscordAwarenessOutbox {
|
|
|
158
158
|
this.write(document);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Retire a batch that was prepared but whose operation did not complete
|
|
163
|
+
* (fork failed, a removal threw and the source branch was restored). A
|
|
164
|
+
* prepared batch is armed: it re-fires at boot or on branch switch through
|
|
165
|
+
* `activatePreparedForBranch` / `preparedSuppressionsForBranch`, and an
|
|
166
|
+
* un-completable suppression there aborts framework start. Only prepared
|
|
167
|
+
* batches can be discarded — an active batch owns delivered markers and
|
|
168
|
+
* must be reconciled, not dropped. Returns false when nothing was removed.
|
|
169
|
+
*/
|
|
170
|
+
discard(batchId: string): boolean {
|
|
171
|
+
const document = this.read();
|
|
172
|
+
const index = document.batches.findIndex((candidate) => candidate.id === batchId);
|
|
173
|
+
if (index < 0) return false;
|
|
174
|
+
if (document.batches[index].status !== 'prepared') return false;
|
|
175
|
+
document.batches.splice(index, 1);
|
|
176
|
+
this.write(document);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
161
180
|
/**
|
|
162
181
|
* Reconcile every retained ledger entry against the active branch. A marker
|
|
163
182
|
* is desired when any active batch for the same ref+emoji has a target branch
|
package/src/types/framework.ts
CHANGED
|
@@ -95,9 +95,36 @@ export interface FrameworkConfig {
|
|
|
95
95
|
*/
|
|
96
96
|
discordAwarenessOutboxPath?: string;
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Branch-INDEPENDENT record of the host's quiesce state (issue #122).
|
|
100
|
+
* Defaults to `<storePath>/recovery/host-mode.json`. Lives outside the
|
|
101
|
+
* chronicle because a historical rollback (branchAt + switchBranch) would
|
|
102
|
+
* otherwise move the active branch to a version of `framework/state`
|
|
103
|
+
* predating the marker, and a restart would boot SERVING mid-surgery. Set
|
|
104
|
+
* explicitly when providing an app-owned `store` without `storePath`;
|
|
105
|
+
* without either, the branch-local slot is used with a one-time warning.
|
|
106
|
+
*/
|
|
107
|
+
hostModePath?: string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Branch-independent durable queue of context writes deferred while
|
|
111
|
+
* quiesced. Defaults to `<storePath>/recovery/deferred-writes.json`; same
|
|
112
|
+
* rationale and fallback as `hostModePath`.
|
|
113
|
+
*/
|
|
114
|
+
deferredWritesPath?: string;
|
|
115
|
+
|
|
98
116
|
/** Emoji used for branch-awareness markers (default: 💤). */
|
|
99
117
|
discordAwarenessEmoji?: string;
|
|
100
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Durable JSONL record of operator-initiated mutations (rollback, suppress,
|
|
121
|
+
* undo/hide, settings, quiesce). Defaults to
|
|
122
|
+
* `<storePath>/operator-actions.jsonl`; set explicitly when providing an
|
|
123
|
+
* app-owned `store` without `storePath`. `false` disables the file (actions
|
|
124
|
+
* still emit `operator:action` traces).
|
|
125
|
+
*/
|
|
126
|
+
operatorLogPath?: string | false;
|
|
127
|
+
|
|
101
128
|
/**
|
|
102
129
|
* Mandatory deadline for each Discord awareness marker tools/call. This is
|
|
103
130
|
* independent of MCPL requestTimeoutMs and cannot be disabled with 0.
|
package/src/types/trace.ts
CHANGED
|
@@ -266,6 +266,20 @@ export type TraceEvent =
|
|
|
266
266
|
toBranch: string;
|
|
267
267
|
})
|
|
268
268
|
|
|
269
|
+
// Operator-initiated mutation (rollback/suppress/undo/hide/settings/quiesce…).
|
|
270
|
+
// Mirrors the durable operator-actions.jsonl record so live UIs see the
|
|
271
|
+
// same entry the file keeps.
|
|
272
|
+
| (TraceEventBase & {
|
|
273
|
+
type: 'operator:action';
|
|
274
|
+
agentName?: string;
|
|
275
|
+
kind: string;
|
|
276
|
+
requester?: { via: string; name?: string; id?: string };
|
|
277
|
+
note?: string;
|
|
278
|
+
params?: Record<string, unknown>;
|
|
279
|
+
result?: Record<string, unknown>;
|
|
280
|
+
error?: string;
|
|
281
|
+
})
|
|
282
|
+
|
|
269
283
|
// MCPL subprocess stderr (one trace per line, for receipts when things break)
|
|
270
284
|
| (TraceEventBase & {
|
|
271
285
|
type: 'mcpl:server-stderr';
|
|
@@ -351,6 +365,10 @@ export type TraceEvent =
|
|
|
351
365
|
toolUseId: string;
|
|
352
366
|
isError: boolean;
|
|
353
367
|
durationMs: number;
|
|
368
|
+
/** The pair was queued behind a turn that replaced the puppet's
|
|
369
|
+
* reservation (provider-admission re-entry) instead of stored
|
|
370
|
+
* directly; it lands at that turn's end flush. */
|
|
371
|
+
deferred?: boolean;
|
|
354
372
|
})
|
|
355
373
|
|
|
356
374
|
// MCPL server connection lifecycle (spawn / handshake / reconnect health).
|
|
@@ -422,6 +440,34 @@ export type TraceEvent =
|
|
|
422
440
|
message: string;
|
|
423
441
|
/** Kind-specific structured payload (mirrors the failures.log record). */
|
|
424
442
|
data?: Record<string, unknown>;
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// Host quiesce/maintenance mode (issue #122)
|
|
446
|
+
| (TraceEventBase & {
|
|
447
|
+
type: 'host:quiesce';
|
|
448
|
+
reason?: string;
|
|
449
|
+
/** Whether all turns settled within the drain window. */
|
|
450
|
+
drained: boolean;
|
|
451
|
+
/** Turns still alive at return (0 unless the drain timed out). */
|
|
452
|
+
activeTurns: number;
|
|
453
|
+
/** Wakes still parked on provider admission at return (see HostModeStatus). */
|
|
454
|
+
parkedAdmissions?: number;
|
|
455
|
+
/** True when undrained turns were force-cancelled (abandon). */
|
|
456
|
+
abandoned?: boolean;
|
|
457
|
+
/** Agents whose turn abandon could not cancel (token held, no stream). */
|
|
458
|
+
unabandonable?: string[];
|
|
459
|
+
})
|
|
460
|
+
| (TraceEventBase & {
|
|
461
|
+
type: 'host:resume';
|
|
462
|
+
/** True when a failing feasibility verdict was overridden. */
|
|
463
|
+
forced?: boolean;
|
|
464
|
+
/** Gated inference requests released back to the scheduler. */
|
|
465
|
+
releasedRequests: number;
|
|
466
|
+
})
|
|
467
|
+
| (TraceEventBase & {
|
|
468
|
+
type: 'host:quiesced_boot';
|
|
469
|
+
reason?: string;
|
|
470
|
+
since?: number;
|
|
425
471
|
});
|
|
426
472
|
|
|
427
473
|
/**
|