@ours.network/fleet 0.17.0 → 0.17.2
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 +38 -2
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +14 -10
- package/dist/application/session-control.js +14 -3
- package/dist/atomic-file.d.ts +7 -1
- package/dist/atomic-file.js +33 -5
- package/dist/build-info.json +10 -0
- package/dist/capabilities.d.ts +20 -0
- package/dist/capabilities.js +21 -0
- package/dist/cli.js +98 -10
- package/dist/config.d.ts +9 -2
- package/dist/config.js +16 -2
- package/dist/creation.d.ts +16 -0
- package/dist/creation.js +28 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +70 -4
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +87 -2
- package/dist/fleet-proxy.js +2 -2
- package/dist/harness/acp-agent.d.ts +3 -0
- package/dist/harness/acp-agent.js +4 -1
- package/dist/harness/codex-app-server-proxy.d.ts +4 -0
- package/dist/harness/codex-app-server-proxy.js +133 -0
- package/dist/harness/codex.js +116 -11
- package/dist/harness/types.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +42 -1
- package/dist/loops/manager.js +115 -16
- package/dist/loops/state.d.ts +46 -2
- package/dist/loops/state.js +81 -3
- package/dist/monitor.d.ts +21 -0
- package/dist/monitor.js +42 -0
- package/dist/ops.d.ts +6 -0
- package/dist/ops.js +46 -1
- package/dist/owner-channel/channel.d.ts +18 -2
- package/dist/owner-channel/channel.js +146 -2
- package/dist/owner-channel/commands.d.ts +2 -2
- package/dist/owner-channel/commands.js +7 -2
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +5 -0
- package/dist/provenance.d.ts +77 -0
- package/dist/provenance.js +283 -0
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +100 -14
- package/dist/session/acp.d.ts +40 -4
- package/dist/session/acp.js +272 -37
- package/dist/session/arbiter.d.ts +28 -2
- package/dist/session/arbiter.js +75 -4
- package/dist/session/control.js +12 -6
- package/dist/session/event-log.d.ts +109 -0
- package/dist/session/event-log.js +247 -0
- package/dist/session/events.d.ts +21 -0
- package/dist/session/events.js +105 -26
- package/dist/session/tmux.d.ts +3 -2
- package/dist/session/tmux.js +2 -0
- package/dist/session/types.d.ts +39 -2
- package/dist/session/types.js +11 -1
- package/dist/spawn.d.ts +3 -3
- package/dist/spawn.js +40 -14
- package/dist/temp-lifecycle.d.ts +62 -0
- package/dist/temp-lifecycle.js +437 -0
- package/package.json +5 -3
package/dist/session/control.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createConnection, createServer } from 'node:net';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { SessionControlError } from './types.js';
|
|
5
|
+
import { SessionControlError, interruptOutcome } from './types.js';
|
|
6
6
|
const MAX_LINE_BYTES = 64 * 1024;
|
|
7
7
|
/** Commands that require protocol version 3. */
|
|
8
8
|
const V3_COMMANDS = new Set([
|
|
@@ -253,10 +253,13 @@ export class RoleControlServer {
|
|
|
253
253
|
});
|
|
254
254
|
return;
|
|
255
255
|
}
|
|
256
|
-
case 'interrupt':
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
case 'interrupt': {
|
|
257
|
+
// Forced recovery cancelled the turn just as surely as a cooperative
|
|
258
|
+
// stop did. Report HOW, never as a failed operation.
|
|
259
|
+
const outcome = interruptOutcome(await this.session.interrupt('local-console'));
|
|
260
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result: outcome });
|
|
259
261
|
return;
|
|
262
|
+
}
|
|
260
263
|
case 'loop_status': {
|
|
261
264
|
if (!this.loopManager)
|
|
262
265
|
throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
|
|
@@ -396,8 +399,11 @@ export class RoleControlServer {
|
|
|
396
399
|
this.write(socket, { version: 1, id: request.id, ok: true, result: existing });
|
|
397
400
|
return;
|
|
398
401
|
}
|
|
399
|
-
await this.session.interrupt('local-console');
|
|
400
|
-
const receipt = {
|
|
402
|
+
const outcome = interruptOutcome(await this.session.interrupt('local-console'));
|
|
403
|
+
const receipt = {
|
|
404
|
+
accepted: true, commandId: request.commandId, at: new Date().toISOString(),
|
|
405
|
+
...outcome,
|
|
406
|
+
};
|
|
401
407
|
this.interruptCommands.set(request.commandId, receipt);
|
|
402
408
|
if (this.interruptCommands.size > 200) {
|
|
403
409
|
const oldest = this.interruptCommands.keys().next().value;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe JSONL primitives for the session event stream.
|
|
3
|
+
*
|
|
4
|
+
* The 06:32 disk-full incident showed what an unguarded `appendFileSync` does
|
|
5
|
+
* under ENOSPC: the kernel short-writes, the already-written prefix stays on
|
|
6
|
+
* disk, and the next successful append lands directly on it — fusing a partial
|
|
7
|
+
* record with a valid later one into a single unparseable line. These helpers
|
|
8
|
+
* make an append all-or-nothing, keep a damaged byte range from ever swallowing
|
|
9
|
+
* a later record, and describe damage rather than quietly dropping it.
|
|
10
|
+
*
|
|
11
|
+
* Single-writer assumption: rollback truncates back to the size observed at the
|
|
12
|
+
* start of the append, so exactly one process may append to a given path.
|
|
13
|
+
*/
|
|
14
|
+
/** The syscall surface an append needs, injectable so faults can be forced deterministically. */
|
|
15
|
+
export interface LogIo {
|
|
16
|
+
openSync(path: string, flags: string, mode?: number): number;
|
|
17
|
+
fstatSync(fd: number): {
|
|
18
|
+
size: number;
|
|
19
|
+
};
|
|
20
|
+
readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number): number;
|
|
21
|
+
writeSync(fd: number, buffer: Buffer, offset: number, length: number): number;
|
|
22
|
+
ftruncateSync(fd: number, length: number): void;
|
|
23
|
+
closeSync(fd: number): void;
|
|
24
|
+
}
|
|
25
|
+
export declare const nodeLogIo: LogIo;
|
|
26
|
+
export declare class AtomicAppendError extends Error {
|
|
27
|
+
readonly rollbackFailed: boolean;
|
|
28
|
+
readonly cause?: unknown | undefined;
|
|
29
|
+
constructor(message: string, rollbackFailed: boolean, cause?: unknown | undefined);
|
|
30
|
+
}
|
|
31
|
+
export interface AppendResult {
|
|
32
|
+
/** True when a dangling partial record was closed off before this line was written. */
|
|
33
|
+
repairedBoundary: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface AppendOptions {
|
|
36
|
+
io?: LogIo;
|
|
37
|
+
/** Total attempts, including the first. Bounded so a full disk cannot spin. */
|
|
38
|
+
maxAttempts?: number;
|
|
39
|
+
mode?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Append one line, all or nothing. A failed write is rolled back to the byte
|
|
43
|
+
* length observed before it started, so a partial record never persists; if the
|
|
44
|
+
* file already ends mid-record (damage from before this fix), the line is put on
|
|
45
|
+
* a fresh line so it cannot fuse with the damaged bytes.
|
|
46
|
+
*/
|
|
47
|
+
export declare function appendLineAtomic(path: string, line: string, options?: AppendOptions): AppendResult;
|
|
48
|
+
export type DamageReason = 'truncated_tail' | 'interior_corruption';
|
|
49
|
+
export interface DamagedLine {
|
|
50
|
+
/** 1-based line number in the file as read. */
|
|
51
|
+
lineNumber: number;
|
|
52
|
+
/** True byte length of the damaged line, even when `raw` is capped. */
|
|
53
|
+
bytes: number;
|
|
54
|
+
reason: DamageReason;
|
|
55
|
+
/** The damaged bytes, capped for storage; `truncatedEvidence` says when. */
|
|
56
|
+
raw: string;
|
|
57
|
+
truncatedEvidence: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** A run of sequence numbers that is simply gone — never recoverable, only reportable. */
|
|
60
|
+
export interface SequenceGap {
|
|
61
|
+
afterSeq: number;
|
|
62
|
+
beforeSeq: number;
|
|
63
|
+
missing: number;
|
|
64
|
+
}
|
|
65
|
+
export interface LogRecord {
|
|
66
|
+
version: 1;
|
|
67
|
+
seq: number;
|
|
68
|
+
[key: string]: unknown;
|
|
69
|
+
}
|
|
70
|
+
export interface ReadLogResult {
|
|
71
|
+
/** Records this version understands and can replay. */
|
|
72
|
+
records: LogRecord[];
|
|
73
|
+
/**
|
|
74
|
+
* Sequence numbers claimed by every readable entry in file order, whatever
|
|
75
|
+
* its version. A record written by a newer version is not replayable here,
|
|
76
|
+
* but it did occupy its number: counting it is what keeps a forward-compatible
|
|
77
|
+
* file from looking like it has a hole, and keeps continuation past it.
|
|
78
|
+
*/
|
|
79
|
+
sequence: number[];
|
|
80
|
+
/** Highest sequence still readable anywhere in the file, for monotonic continuation. */
|
|
81
|
+
maxSeq: number;
|
|
82
|
+
damaged: DamagedLine[];
|
|
83
|
+
gaps: SequenceGap[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read the log line by line. One bad line costs exactly that line: everything
|
|
87
|
+
* before and after it is kept, and the damage is described rather than dropped.
|
|
88
|
+
*/
|
|
89
|
+
export declare function readLog(path: string): ReadLogResult;
|
|
90
|
+
/**
|
|
91
|
+
* Missing sequence numbers between consecutive records. Records that parse
|
|
92
|
+
* perfectly still leave a hole when the writes between them never landed, so
|
|
93
|
+
* this is computed over whatever ordered run the caller cares about — including
|
|
94
|
+
* a rotated stream followed by its live successor, where the hole falls on the
|
|
95
|
+
* boundary and neither file can see it alone.
|
|
96
|
+
*/
|
|
97
|
+
export declare function findSequenceGaps(sequence: number[]): SequenceGap[];
|
|
98
|
+
export interface QuarantineResult {
|
|
99
|
+
quarantined: boolean;
|
|
100
|
+
/** Present when the sidecar could not be written; the source file is untouched either way. */
|
|
101
|
+
error?: string;
|
|
102
|
+
sidecar: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Copy damaged bytes to a sidecar for later forensics. The damaged file itself
|
|
106
|
+
* is never read-modify-written, and a sidecar that cannot be created is reported
|
|
107
|
+
* rather than retried into the event writer.
|
|
108
|
+
*/
|
|
109
|
+
export declare function quarantineDamage(path: string, damaged: DamagedLine[], source?: string): QuarantineResult;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, ftruncateSync, openSync, readFileSync, readSync, writeSync, } from 'node:fs';
|
|
2
|
+
export const nodeLogIo = {
|
|
3
|
+
openSync: (path, flags, mode) => openSync(path, flags, mode),
|
|
4
|
+
fstatSync: fd => fstatSync(fd),
|
|
5
|
+
readSync: (fd, buffer, offset, length, position) => readSync(fd, buffer, offset, length, position),
|
|
6
|
+
writeSync: (fd, buffer, offset, length) => writeSync(fd, buffer, offset, length),
|
|
7
|
+
ftruncateSync: (fd, length) => ftruncateSync(fd, length),
|
|
8
|
+
closeSync: fd => closeSync(fd),
|
|
9
|
+
};
|
|
10
|
+
const NEWLINE = 0x0a;
|
|
11
|
+
/** Enough to hold a whole event line as evidence without letting one record dominate the sidecar. */
|
|
12
|
+
const MAX_RAW_EVIDENCE_BYTES = 8 * 1024;
|
|
13
|
+
const MAX_QUARANTINE_BYTES = 1024 * 1024;
|
|
14
|
+
export class AtomicAppendError extends Error {
|
|
15
|
+
rollbackFailed;
|
|
16
|
+
cause;
|
|
17
|
+
constructor(message, rollbackFailed, cause) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.rollbackFailed = rollbackFailed;
|
|
20
|
+
this.cause = cause;
|
|
21
|
+
this.name = 'AtomicAppendError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Append one line, all or nothing. A failed write is rolled back to the byte
|
|
26
|
+
* length observed before it started, so a partial record never persists; if the
|
|
27
|
+
* file already ends mid-record (damage from before this fix), the line is put on
|
|
28
|
+
* a fresh line so it cannot fuse with the damaged bytes.
|
|
29
|
+
*/
|
|
30
|
+
export function appendLineAtomic(path, line, options = {}) {
|
|
31
|
+
const io = options.io ?? nodeLogIo;
|
|
32
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? 2);
|
|
33
|
+
const payload = line.endsWith('\n') ? line : line + '\n';
|
|
34
|
+
let lastError;
|
|
35
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
36
|
+
let fd;
|
|
37
|
+
let sizeBefore = 0;
|
|
38
|
+
try {
|
|
39
|
+
fd = io.openSync(path, 'a+', options.mode ?? 0o600);
|
|
40
|
+
sizeBefore = io.fstatSync(fd).size;
|
|
41
|
+
const repairedBoundary = sizeBefore > 0 && !endsWithNewline(io, fd, sizeBefore);
|
|
42
|
+
const buffer = Buffer.from(repairedBoundary ? '\n' + payload : payload, 'utf8');
|
|
43
|
+
let written = 0;
|
|
44
|
+
try {
|
|
45
|
+
while (written < buffer.length) {
|
|
46
|
+
const advanced = io.writeSync(fd, buffer, written, buffer.length - written);
|
|
47
|
+
// A write that accepts nothing would spin forever and never reach the
|
|
48
|
+
// bounded-retry logic, so it fails here instead.
|
|
49
|
+
if (advanced <= 0)
|
|
50
|
+
throw new Error(`write made no progress at byte ${written} of ${buffer.length}`);
|
|
51
|
+
written += advanced;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (writeError) {
|
|
55
|
+
throw rollback(io, fd, sizeBefore, writeError);
|
|
56
|
+
}
|
|
57
|
+
return { repairedBoundary };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
lastError = error;
|
|
61
|
+
// A failed rollback leaves bytes we cannot account for; retrying would
|
|
62
|
+
// append on top of them, so stop and let the caller report it.
|
|
63
|
+
if (error instanceof AtomicAppendError && error.rollbackFailed)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
if (fd !== undefined) {
|
|
68
|
+
try {
|
|
69
|
+
io.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
catch { /* the write outcome is what matters */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw lastError;
|
|
76
|
+
}
|
|
77
|
+
function endsWithNewline(io, fd, size) {
|
|
78
|
+
const tail = Buffer.alloc(1);
|
|
79
|
+
const read = io.readSync(fd, tail, 0, 1, size - 1);
|
|
80
|
+
// An unreadable tail is treated as damaged: adding a newline is always safe.
|
|
81
|
+
return read === 1 && tail[0] === NEWLINE;
|
|
82
|
+
}
|
|
83
|
+
function rollback(io, fd, sizeBefore, writeError) {
|
|
84
|
+
const detail = writeError instanceof Error ? writeError.message : String(writeError);
|
|
85
|
+
try {
|
|
86
|
+
io.ftruncateSync(fd, sizeBefore);
|
|
87
|
+
return new AtomicAppendError(detail, false, writeError);
|
|
88
|
+
}
|
|
89
|
+
catch (truncateError) {
|
|
90
|
+
const reason = truncateError instanceof Error ? truncateError.message : String(truncateError);
|
|
91
|
+
return new AtomicAppendError(`${detail} (rollback failed: ${reason})`, true, writeError);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Read the log line by line. One bad line costs exactly that line: everything
|
|
96
|
+
* before and after it is kept, and the damage is described rather than dropped.
|
|
97
|
+
*/
|
|
98
|
+
export function readLog(path) {
|
|
99
|
+
const empty = { records: [], sequence: [], maxSeq: 0, damaged: [], gaps: [] };
|
|
100
|
+
if (!existsSync(path))
|
|
101
|
+
return empty;
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = readFileSync(path, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return empty;
|
|
108
|
+
}
|
|
109
|
+
if (raw === '')
|
|
110
|
+
return empty;
|
|
111
|
+
const terminated = raw.endsWith('\n');
|
|
112
|
+
const segments = raw.split('\n');
|
|
113
|
+
if (terminated)
|
|
114
|
+
segments.pop();
|
|
115
|
+
const records = [];
|
|
116
|
+
const sequence = [];
|
|
117
|
+
const damaged = [];
|
|
118
|
+
let maxSeq = 0;
|
|
119
|
+
segments.forEach((segment, index) => {
|
|
120
|
+
if (segment.trim() === '')
|
|
121
|
+
return;
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(segment);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
const isFinalSegment = index === segments.length - 1;
|
|
128
|
+
damaged.push({
|
|
129
|
+
lineNumber: index + 1,
|
|
130
|
+
bytes: Buffer.byteLength(segment, 'utf8'),
|
|
131
|
+
// An unterminated final line is a write that never committed; anything
|
|
132
|
+
// else sits between records that did, so it is interior corruption.
|
|
133
|
+
reason: isFinalSegment && !terminated ? 'truncated_tail' : 'interior_corruption',
|
|
134
|
+
raw: segment.slice(0, MAX_RAW_EVIDENCE_BYTES),
|
|
135
|
+
truncatedEvidence: segment.length > MAX_RAW_EVIDENCE_BYTES,
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Unknown versions are forward compatibility, not damage: they are not
|
|
140
|
+
// replayed here, but they did claim their sequence number, so they still
|
|
141
|
+
// count towards ordering and continuation.
|
|
142
|
+
const seq = sequenceOf(parsed);
|
|
143
|
+
if (seq === undefined)
|
|
144
|
+
return;
|
|
145
|
+
sequence.push(seq);
|
|
146
|
+
if (seq > maxSeq)
|
|
147
|
+
maxSeq = seq;
|
|
148
|
+
if (isLogRecord(parsed))
|
|
149
|
+
records.push(parsed);
|
|
150
|
+
});
|
|
151
|
+
return { records, sequence, maxSeq, damaged, gaps: findSequenceGaps(sequence) };
|
|
152
|
+
}
|
|
153
|
+
/** The sequence a readable entry claims, whatever version wrote it. */
|
|
154
|
+
function sequenceOf(value) {
|
|
155
|
+
if (typeof value !== 'object' || value === null)
|
|
156
|
+
return undefined;
|
|
157
|
+
const seq = value.seq;
|
|
158
|
+
return typeof seq === 'number' && Number.isFinite(seq) ? seq : undefined;
|
|
159
|
+
}
|
|
160
|
+
function isLogRecord(value) {
|
|
161
|
+
if (typeof value !== 'object' || value === null)
|
|
162
|
+
return false;
|
|
163
|
+
const candidate = value;
|
|
164
|
+
return candidate.version === 1 && typeof candidate.seq === 'number';
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Missing sequence numbers between consecutive records. Records that parse
|
|
168
|
+
* perfectly still leave a hole when the writes between them never landed, so
|
|
169
|
+
* this is computed over whatever ordered run the caller cares about — including
|
|
170
|
+
* a rotated stream followed by its live successor, where the hole falls on the
|
|
171
|
+
* boundary and neither file can see it alone.
|
|
172
|
+
*/
|
|
173
|
+
export function findSequenceGaps(sequence) {
|
|
174
|
+
const gaps = [];
|
|
175
|
+
for (let i = 1; i < sequence.length; i++) {
|
|
176
|
+
const afterSeq = sequence[i - 1];
|
|
177
|
+
const beforeSeq = sequence[i];
|
|
178
|
+
if (beforeSeq > afterSeq + 1)
|
|
179
|
+
gaps.push({ afterSeq, beforeSeq, missing: beforeSeq - afterSeq - 1 });
|
|
180
|
+
}
|
|
181
|
+
return gaps;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Copy damaged bytes to a sidecar for later forensics. The damaged file itself
|
|
185
|
+
* is never read-modify-written, and a sidecar that cannot be created is reported
|
|
186
|
+
* rather than retried into the event writer.
|
|
187
|
+
*/
|
|
188
|
+
export function quarantineDamage(path, damaged, source = path) {
|
|
189
|
+
const sidecar = path + '.corrupt';
|
|
190
|
+
if (damaged.length === 0)
|
|
191
|
+
return { quarantined: false, sidecar };
|
|
192
|
+
let alreadyHeld;
|
|
193
|
+
try {
|
|
194
|
+
alreadyHeld = existingEvidence(sidecar);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return { quarantined: false, error: describe(error), sidecar };
|
|
198
|
+
}
|
|
199
|
+
const pending = damaged.filter(line => !alreadyHeld.has(evidenceKey(source, line.raw)));
|
|
200
|
+
if (pending.length === 0)
|
|
201
|
+
return { quarantined: true, sidecar };
|
|
202
|
+
for (const line of pending) {
|
|
203
|
+
try {
|
|
204
|
+
appendLineAtomic(sidecar, JSON.stringify({
|
|
205
|
+
quarantinedAt: new Date().toISOString(),
|
|
206
|
+
source,
|
|
207
|
+
lineNumber: line.lineNumber,
|
|
208
|
+
bytes: line.bytes,
|
|
209
|
+
reason: line.reason,
|
|
210
|
+
truncatedEvidence: line.truncatedEvidence,
|
|
211
|
+
raw: line.raw,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
return { quarantined: false, error: describe(error), sidecar };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { quarantined: true, sidecar };
|
|
219
|
+
}
|
|
220
|
+
function existingEvidence(sidecar) {
|
|
221
|
+
const held = new Set();
|
|
222
|
+
if (!existsSync(sidecar))
|
|
223
|
+
return held;
|
|
224
|
+
const raw = readFileSync(sidecar, 'utf8');
|
|
225
|
+
if (raw.length > MAX_QUARANTINE_BYTES)
|
|
226
|
+
throw new Error(`quarantine sidecar is full (${raw.length} bytes)`);
|
|
227
|
+
for (const line of raw.split('\n')) {
|
|
228
|
+
if (line.trim() === '')
|
|
229
|
+
continue;
|
|
230
|
+
try {
|
|
231
|
+
const entry = JSON.parse(line);
|
|
232
|
+
if (typeof entry.raw === 'string' && typeof entry.source === 'string') {
|
|
233
|
+
held.add(evidenceKey(entry.source, entry.raw));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch { /* an unreadable sidecar entry only costs us dedup */ }
|
|
237
|
+
}
|
|
238
|
+
return held;
|
|
239
|
+
}
|
|
240
|
+
/** Damage is identified by its bytes and which file they came from, so the live
|
|
241
|
+
* stream and its rotated predecessor are never confused for each other. */
|
|
242
|
+
function evidenceKey(source, raw) {
|
|
243
|
+
return `${source}${raw}`;
|
|
244
|
+
}
|
|
245
|
+
function describe(error) {
|
|
246
|
+
return error instanceof Error ? error.message : String(error);
|
|
247
|
+
}
|
package/dist/session/events.d.ts
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
import { type DamagedLine, type SequenceGap } from './event-log.js';
|
|
1
2
|
import type { SessionEvent, SessionEventKind } from './types.js';
|
|
3
|
+
/** What the on-disk stream is known to have lost or mangled. Never optimistic. */
|
|
4
|
+
export interface StreamIntegrity {
|
|
5
|
+
healthy: boolean;
|
|
6
|
+
damaged: DamagedLine[];
|
|
7
|
+
gaps: SequenceGap[];
|
|
8
|
+
quarantined: boolean;
|
|
9
|
+
quarantineError?: string;
|
|
10
|
+
/** Appends that failed outright; their events survive in memory only. */
|
|
11
|
+
writeFailures: number;
|
|
12
|
+
lastWriteError?: string;
|
|
13
|
+
/** Appends that had to close off a dangling partial record first. */
|
|
14
|
+
boundaryRepairs: number;
|
|
15
|
+
rotationFailed: boolean;
|
|
16
|
+
}
|
|
2
17
|
/** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
|
|
3
18
|
export declare class SessionEvents {
|
|
4
19
|
private readonly path;
|
|
5
20
|
private seq;
|
|
6
21
|
private readonly events;
|
|
7
22
|
private readonly listeners;
|
|
23
|
+
private readonly integrityState;
|
|
24
|
+
private readonly rotatedPath;
|
|
8
25
|
constructor(path: string);
|
|
9
26
|
emit(kind: SessionEventKind, fields?: Omit<SessionEvent, 'version' | 'seq' | 'at' | 'kind'>): SessionEvent;
|
|
10
27
|
since(seq: number): SessionEvent[];
|
|
11
28
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
29
|
+
/** Snapshot of what is known to be wrong with the persisted stream. */
|
|
30
|
+
integrity(): StreamIntegrity;
|
|
31
|
+
private persist;
|
|
12
32
|
private restore;
|
|
33
|
+
private describeLoss;
|
|
13
34
|
private rotateIfNeeded;
|
|
14
35
|
}
|
package/dist/session/events.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, renameSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { appendLineAtomic, findSequenceGaps, quarantineDamage, readLog, } from './event-log.js';
|
|
2
4
|
const MAX_EVENTS = 1_000;
|
|
3
5
|
const MAX_EVENT_FILE_BYTES = 2 * 1024 * 1024;
|
|
4
6
|
/** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
|
|
@@ -7,8 +9,14 @@ export class SessionEvents {
|
|
|
7
9
|
seq = 0;
|
|
8
10
|
events = [];
|
|
9
11
|
listeners = new Set();
|
|
12
|
+
integrityState = {
|
|
13
|
+
healthy: true, damaged: [], gaps: [], quarantined: false,
|
|
14
|
+
writeFailures: 0, boundaryRepairs: 0, rotationFailed: false,
|
|
15
|
+
};
|
|
16
|
+
rotatedPath;
|
|
10
17
|
constructor(path) {
|
|
11
18
|
this.path = path;
|
|
19
|
+
this.rotatedPath = path + '.1';
|
|
12
20
|
this.restore();
|
|
13
21
|
}
|
|
14
22
|
emit(kind, fields = {}) {
|
|
@@ -22,16 +30,7 @@ export class SessionEvents {
|
|
|
22
30
|
this.events.push(event);
|
|
23
31
|
if (this.events.length > MAX_EVENTS)
|
|
24
32
|
this.events.shift();
|
|
25
|
-
|
|
26
|
-
this.rotateIfNeeded();
|
|
27
|
-
// Owner-visible commentary remains live/in-memory for the authenticated
|
|
28
|
-
// bridge, but its plaintext must not become diagnostic state on disk.
|
|
29
|
-
const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
|
|
30
|
-
? { ...event, text: '[assistant commentary redacted]' }
|
|
31
|
-
: event;
|
|
32
|
-
appendFileSync(this.path, JSON.stringify(persisted) + '\n', { mode: 0o600 });
|
|
33
|
-
}
|
|
34
|
-
catch { /* diagnostics must never terminate a role */ }
|
|
33
|
+
this.persist(event);
|
|
35
34
|
for (const listener of this.listeners)
|
|
36
35
|
listener(event);
|
|
37
36
|
return event;
|
|
@@ -43,30 +42,110 @@ export class SessionEvents {
|
|
|
43
42
|
this.listeners.add(listener);
|
|
44
43
|
return () => this.listeners.delete(listener);
|
|
45
44
|
}
|
|
46
|
-
|
|
45
|
+
/** Snapshot of what is known to be wrong with the persisted stream. */
|
|
46
|
+
integrity() {
|
|
47
|
+
return { ...this.integrityState, damaged: [...this.integrityState.damaged], gaps: [...this.integrityState.gaps] };
|
|
48
|
+
}
|
|
49
|
+
persist(event) {
|
|
50
|
+
this.rotateIfNeeded();
|
|
51
|
+
// Owner-visible commentary remains live/in-memory for the authenticated
|
|
52
|
+
// bridge, but its plaintext must not become diagnostic state on disk.
|
|
53
|
+
const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
|
|
54
|
+
? { ...event, text: '[assistant commentary redacted]' }
|
|
55
|
+
: event;
|
|
47
56
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const event = JSON.parse(line);
|
|
53
|
-
if (event.version === 1 && typeof event.seq === 'number') {
|
|
54
|
-
this.events.push(event);
|
|
55
|
-
this.seq = Math.max(this.seq, event.seq);
|
|
56
|
-
}
|
|
57
|
+
const { repairedBoundary } = appendLineAtomic(this.path, JSON.stringify(persisted));
|
|
58
|
+
if (repairedBoundary) {
|
|
59
|
+
this.integrityState.boundaryRepairs++;
|
|
60
|
+
this.integrityState.healthy = false;
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
|
-
catch {
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Diagnostics must never terminate a role, but a silent loss is how the
|
|
65
|
+
// 06:32 incident stayed invisible for an hour — record it instead.
|
|
66
|
+
this.integrityState.writeFailures++;
|
|
67
|
+
this.integrityState.lastWriteError = error instanceof Error ? error.message : String(error);
|
|
68
|
+
this.integrityState.healthy = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
restore() {
|
|
72
|
+
// Rotation renames the live stream to `.1` and only then starts a new one.
|
|
73
|
+
// A crash in that window leaves no live file at all, so reading only the
|
|
74
|
+
// live path would restart at seq 1 and re-issue numbers `.1` already holds.
|
|
75
|
+
const rotated = readLog(this.rotatedPath);
|
|
76
|
+
const live = readLog(this.path);
|
|
77
|
+
const records = [...rotated.records, ...live.records];
|
|
78
|
+
for (const record of records.slice(-MAX_EVENTS))
|
|
79
|
+
this.events.push(record);
|
|
80
|
+
// Monotonic even across damage and rotation: a sequence number already on
|
|
81
|
+
// disk, readable or not, is never handed out again.
|
|
82
|
+
this.seq = Math.max(this.seq, rotated.maxSeq, live.maxSeq);
|
|
83
|
+
const damaged = [...rotated.damaged, ...live.damaged];
|
|
84
|
+
// Records that parse perfectly still leave a hole when the writes between
|
|
85
|
+
// them never landed, and a hole on the rotated/live boundary is invisible to
|
|
86
|
+
// either file alone — so gaps are computed over the combined ordered run,
|
|
87
|
+
// and reported whether or not any bytes were mangled.
|
|
88
|
+
const gaps = findSequenceGaps([...rotated.sequence, ...live.sequence]);
|
|
89
|
+
if (damaged.length === 0 && gaps.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
this.integrityState.healthy = false;
|
|
92
|
+
this.integrityState.damaged = damaged;
|
|
93
|
+
this.integrityState.gaps = gaps;
|
|
94
|
+
let error;
|
|
95
|
+
if (damaged.length > 0) {
|
|
96
|
+
const results = [
|
|
97
|
+
quarantineDamage(this.path, rotated.damaged, this.rotatedPath),
|
|
98
|
+
quarantineDamage(this.path, live.damaged, this.path),
|
|
99
|
+
].filter(result => result.quarantined || result.error);
|
|
100
|
+
this.integrityState.quarantined = results.every(result => result.quarantined);
|
|
101
|
+
error = results.find(result => result.error)?.error;
|
|
102
|
+
if (error)
|
|
103
|
+
this.integrityState.quarantineError = error;
|
|
104
|
+
}
|
|
105
|
+
// Reported through the ordinary stream so it reaches replay and audit;
|
|
106
|
+
// never through a second attempt at the writer that just failed.
|
|
107
|
+
this.emit('error', { text: this.describeLoss(damaged, gaps, error) });
|
|
108
|
+
}
|
|
109
|
+
describeLoss(damaged, gaps, error) {
|
|
110
|
+
const interior = damaged.filter(line => line.reason === 'interior_corruption');
|
|
111
|
+
const tails = damaged.filter(line => line.reason === 'truncated_tail');
|
|
112
|
+
const parts = ['event stream integrity'];
|
|
113
|
+
if (interior.length) {
|
|
114
|
+
parts.push(`${interior.length} interior corruption at line ${interior.map(l => l.lineNumber).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (tails.length)
|
|
117
|
+
parts.push(`${tails.length} truncated tail discarded`);
|
|
118
|
+
const missing = gaps.reduce((total, gap) => total + gap.missing, 0);
|
|
119
|
+
if (missing) {
|
|
120
|
+
const span = gaps.map(gap => `${gap.afterSeq}..${gap.beforeSeq}`).join(', ');
|
|
121
|
+
parts.push(`${missing} record${missing === 1 ? '' : 's'} lost between seq ${span} (irrecoverable)`);
|
|
122
|
+
}
|
|
123
|
+
if (damaged.length) {
|
|
124
|
+
parts.push(this.integrityState.quarantined
|
|
125
|
+
? `damaged bytes quarantined to ${basename(this.path)}.corrupt`
|
|
126
|
+
: `damaged bytes NOT quarantined${error ? ` (${error})` : ''}`);
|
|
127
|
+
}
|
|
128
|
+
return parts.join('; ');
|
|
60
129
|
}
|
|
61
130
|
rotateIfNeeded() {
|
|
62
|
-
if (!existsSync(this.path)
|
|
131
|
+
if (!existsSync(this.path))
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
if (statSync(this.path).size < MAX_EVENT_FILE_BYTES)
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
63
138
|
return;
|
|
64
|
-
|
|
139
|
+
}
|
|
65
140
|
try {
|
|
66
|
-
renameSync(this.path,
|
|
141
|
+
renameSync(this.path, this.rotatedPath);
|
|
142
|
+
this.integrityState.rotationFailed = false;
|
|
67
143
|
}
|
|
68
144
|
catch {
|
|
69
|
-
|
|
145
|
+
// Fail closed: the previous fallback truncated the live stream, which
|
|
146
|
+
// destroys exactly the evidence an incident needs.
|
|
147
|
+
this.integrityState.rotationFailed = true;
|
|
148
|
+
this.integrityState.healthy = false;
|
|
70
149
|
}
|
|
71
150
|
}
|
|
72
151
|
}
|
package/dist/session/tmux.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Tmux } from '../tmux.js';
|
|
2
|
-
import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnResult } from './types.js';
|
|
2
|
+
import type { ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnResult } from './types.js';
|
|
3
3
|
/** SessionHandle adapter for the existing tmux transport. */
|
|
4
4
|
export declare class TmuxSession implements SessionHandle {
|
|
5
5
|
private readonly name;
|
|
@@ -12,7 +12,8 @@ export declare class TmuxSession implements SessionHandle {
|
|
|
12
12
|
snapshot(): SessionSnapshot;
|
|
13
13
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
14
14
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
15
|
-
|
|
15
|
+
/** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
|
|
16
|
+
interrupt(): Promise<InterruptOutcome>;
|
|
16
17
|
respondPermission(): boolean;
|
|
17
18
|
eventsSince(): SessionEvent[];
|
|
18
19
|
subscribe(): () => void;
|
package/dist/session/tmux.js
CHANGED
|
@@ -51,8 +51,10 @@ export class TmuxSession {
|
|
|
51
51
|
throw error;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
/** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
|
|
54
55
|
async interrupt() {
|
|
55
56
|
await this.tmux.sendKey(this.name, 'C-c');
|
|
57
|
+
return { state: 'settled' };
|
|
56
58
|
}
|
|
57
59
|
respondPermission() {
|
|
58
60
|
return false;
|