@agfpd/iapeer 0.2.9 → 0.2.10
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/package.json +1 -1
- package/src/daemon/daemon.test.ts +38 -1
- package/src/daemon/deliverylog.ts +64 -0
- package/src/daemon/index.ts +33 -3
- package/src/daemon/main.ts +4 -0
- package/src/identity/identity.test.ts +17 -0
- package/src/identity/index.ts +13 -0
- package/src/launch/bootdeliver.test.ts +106 -0
- package/src/launch/index.ts +25 -5
- package/src/launch/types.ts +2 -1
- package/src/lifecycle/eventlog.ts +18 -69
- package/src/lifecycle/index.ts +24 -0
- package/src/lifecycle/lifecycle.test.ts +26 -4
- package/src/storage/rotatelog.test.ts +44 -0
- package/src/storage/rotatelog.ts +109 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import {
|
|
@@ -101,3 +101,40 @@ describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', ()
|
|
|
101
101
|
expect((await callTool(caller, 'bogus', {})).isError).toBe(true)
|
|
102
102
|
})
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
describe('per-delivery outcome log (Ф-#8a — delivery.log)', () => {
|
|
106
|
+
test('with deliveryLogDir wired: ONE logfmt outcome line per attempt, metadata only', async () => {
|
|
107
|
+
const logDir = join(root, 'logs', 'iapeer')
|
|
108
|
+
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
109
|
+
await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' }, undefined, logDir)
|
|
110
|
+
await callTool(
|
|
111
|
+
caller,
|
|
112
|
+
'send_to_peer',
|
|
113
|
+
{ personality: 'nobody', message: 'hello there', topic: 'probe topic' },
|
|
114
|
+
undefined,
|
|
115
|
+
logDir,
|
|
116
|
+
)
|
|
117
|
+
const lines = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim().split('\n')
|
|
118
|
+
expect(lines.length).toBe(2)
|
|
119
|
+
// attempt 1: known-but-offline peer (no wake wired → Ф1 explicit offline)
|
|
120
|
+
expect(lines[0]).toContain('ev=delivery')
|
|
121
|
+
expect(lines[0]).toContain('caller=claude-boris')
|
|
122
|
+
expect(lines[0]).toContain('to=offlinepeer')
|
|
123
|
+
expect(lines[0]).toContain('ok=false')
|
|
124
|
+
expect(lines[0]).toMatch(/err=".*offline.*"/)
|
|
125
|
+
expect(lines[0]).toContain('len=2') // body length, NEVER the body itself
|
|
126
|
+
expect(lines[0]).not.toContain('hi"') // the message text must not leak
|
|
127
|
+
// attempt 2: unknown peer — validation failures are recorded too
|
|
128
|
+
expect(lines[1]).toContain('to=nobody')
|
|
129
|
+
expect(lines[1]).toContain('ok=false')
|
|
130
|
+
expect(lines[1]).toContain('topic="probe topic"')
|
|
131
|
+
expect(lines[1]).toContain('len=11')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('without deliveryLogDir (default): nothing is written — library/test daemons stay hermetic', async () => {
|
|
135
|
+
const probeDir = join(root, 'logs', 'unwired')
|
|
136
|
+
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
137
|
+
await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' })
|
|
138
|
+
expect(existsSync(join(probeDir, 'delivery.log'))).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Per-delivery outcome log — the daemon's DURABLE trace of EVERY send_to_peer it
|
|
2
|
+
// routes (Ф-#8a transport hardening). This closes the observability gap the
|
|
3
|
+
// 09.06 long-message investigation hit: a sender held an `ok:true` while the
|
|
4
|
+
// recipient never saw the message, and there was NO daemon-side record of what
|
|
5
|
+
// routeSend actually decided (hit/miss, woke, error) to reconstruct the path.
|
|
6
|
+
// With this log, the NEXT suspected loss is answerable from disk: one logfmt
|
|
7
|
+
// line per delivery attempt — who sent, to whom, how it resolved, how long it
|
|
8
|
+
// took — delivery.log, sibling to lifecycle.log / exits.log (where the
|
|
9
|
+
// investigator already looks).
|
|
10
|
+
//
|
|
11
|
+
// What is logged: routing METADATA only (caller, target, outcome, sizes, topic)
|
|
12
|
+
// — never the message body (peer traffic stays out of the foundation's logs;
|
|
13
|
+
// length is enough to correlate with the sender's account).
|
|
14
|
+
//
|
|
15
|
+
// Same class as lifecycle.log ("встроенная ротация", Фаза — Ротация логов
|
|
16
|
+
// iapeer): written by OUR code → rotates itself in the writer, via the shared
|
|
17
|
+
// storage/rotatelog primitive. The dir is passed IN by the composition point
|
|
18
|
+
// (daemon/main.ts routes cfg.eventLogDir), NEVER re-resolved from env here — a
|
|
19
|
+
// falsy dir → no-op, so library/test callers of startDaemon stay hermetic by
|
|
20
|
+
// default (same opt-in pattern as the discovery file).
|
|
21
|
+
|
|
22
|
+
import { join } from 'path'
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_LOG_KEEP,
|
|
25
|
+
DEFAULT_LOG_MAX_BYTES,
|
|
26
|
+
appendRotatedEvent,
|
|
27
|
+
} from '../storage/rotatelog.ts'
|
|
28
|
+
|
|
29
|
+
/** The per-delivery outcome log inside `logDir` (sibling to lifecycle.log). */
|
|
30
|
+
export function deliveryLogPath(logDir: string): string {
|
|
31
|
+
return join(logDir, 'delivery.log')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
35
|
+
const n = parseInt(raw ?? '', 10)
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AppendDeliveryOptions {
|
|
40
|
+
/** Reads the rotation knobs IAPEER_DELIVERY_LOG_MAX_BYTES / _KEEP. */
|
|
41
|
+
env?: NodeJS.ProcessEnv
|
|
42
|
+
/** Stamp the line with this epoch-ms. Default Date.now(). */
|
|
43
|
+
nowMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Append one delivery outcome line into `logDir`/delivery.log. A falsy `logDir`
|
|
48
|
+
* is a no-op (the default for library/test daemons — production main passes the
|
|
49
|
+
* cfg-resolved dir). Fully best-effort — never throws; logging must never fail
|
|
50
|
+
* a delivery.
|
|
51
|
+
*/
|
|
52
|
+
export function appendDeliveryEvent(
|
|
53
|
+
logDir: string | undefined,
|
|
54
|
+
fields: Record<string, string | number | undefined>,
|
|
55
|
+
opts: AppendDeliveryOptions = {},
|
|
56
|
+
): void {
|
|
57
|
+
if (!logDir) return
|
|
58
|
+
const env = opts.env ?? process.env
|
|
59
|
+
appendRotatedEvent(deliveryLogPath(logDir), fields, {
|
|
60
|
+
nowMs: opts.nowMs,
|
|
61
|
+
maxBytes: envPosInt(env.IAPEER_DELIVERY_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
|
|
62
|
+
keep: envPosInt(env.IAPEER_DELIVERY_LOG_KEEP, DEFAULT_LOG_KEEP),
|
|
63
|
+
})
|
|
64
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage
|
|
|
44
44
|
import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
|
|
45
45
|
import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
|
|
46
46
|
import { routeSend, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
|
|
47
|
+
import { appendDeliveryEvent } from './deliverylog.ts'
|
|
47
48
|
|
|
48
49
|
export const CALLER_HEADER = 'x-iapeer-identity'
|
|
49
50
|
const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
|
|
@@ -176,6 +177,7 @@ export async function callTool(
|
|
|
176
177
|
name: string,
|
|
177
178
|
args: Record<string, unknown>,
|
|
178
179
|
wake?: WakeFn,
|
|
180
|
+
deliveryLogDir?: string,
|
|
179
181
|
): Promise<ToolResult> {
|
|
180
182
|
if (name === 'send_to_peer') {
|
|
181
183
|
const input: SendToPeerInput = {
|
|
@@ -185,7 +187,27 @@ export async function callTool(
|
|
|
185
187
|
topic: typeof args.topic === 'string' ? args.topic : undefined,
|
|
186
188
|
attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
|
|
187
189
|
}
|
|
190
|
+
const t0 = Date.now()
|
|
188
191
|
const sent = await routeSend(caller, input, { wake })
|
|
192
|
+
// Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
|
|
193
|
+
// to lifecycle.log) — metadata only, never the body. Both branches of the
|
|
194
|
+
// routeSend result are recorded, so a suspected loss is reconstructable from
|
|
195
|
+
// disk (the gap the 09.06 investigation hit). No-op when no dir is wired
|
|
196
|
+
// (library/test daemons); appendDeliveryEvent itself never throws.
|
|
197
|
+
appendDeliveryEvent(deliveryLogDir, {
|
|
198
|
+
ev: 'delivery',
|
|
199
|
+
caller: caller.address,
|
|
200
|
+
to: input.personality,
|
|
201
|
+
rt: input.runtime, // requested runtime override (skipped when absent)
|
|
202
|
+
ok: String(sent.ok),
|
|
203
|
+
via: sent.ok ? `${sent.value.delivered_to.runtime}-${sent.value.delivered_to.personality}` : undefined,
|
|
204
|
+
woke: sent.ok ? String(sent.value.woke) : undefined,
|
|
205
|
+
ms: Date.now() - t0,
|
|
206
|
+
len: input.message.length,
|
|
207
|
+
att: input.attachments?.length || undefined,
|
|
208
|
+
topic: input.topic,
|
|
209
|
+
err: sent.ok ? undefined : sent.error.message,
|
|
210
|
+
})
|
|
189
211
|
return sent.ok ? jsonResult(sent.value) : errResult(sent.error.message)
|
|
190
212
|
}
|
|
191
213
|
return errResult(`unknown tool: ${name}`)
|
|
@@ -201,7 +223,7 @@ function headerFromRequestInfo(extra: { requestInfo?: { headers?: Record<string,
|
|
|
201
223
|
return Array.isArray(value) ? (value[0] as string) : (value as string | undefined)
|
|
202
224
|
}
|
|
203
225
|
|
|
204
|
-
export function createMcpServer(wake?: WakeFn): Server {
|
|
226
|
+
export function createMcpServer(wake?: WakeFn, deliveryLogDir?: string): Server {
|
|
205
227
|
const server = new Server(SERVER_INFO, { capabilities: { tools: {} } })
|
|
206
228
|
|
|
207
229
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(readPeersIndex()) }))
|
|
@@ -224,7 +246,7 @@ export function createMcpServer(wake?: WakeFn): Server {
|
|
|
224
246
|
process.stderr.write(`[iapeer-daemon] tools/call tool=${req.params.name} caller=${caller.address}\n`)
|
|
225
247
|
}
|
|
226
248
|
const args = (req.params.arguments ?? {}) as Record<string, unknown>
|
|
227
|
-
return (await callTool(caller, req.params.name, args, wake)) as CallToolResult
|
|
249
|
+
return (await callTool(caller, req.params.name, args, wake, deliveryLogDir)) as CallToolResult
|
|
228
250
|
})
|
|
229
251
|
|
|
230
252
|
return server
|
|
@@ -292,6 +314,14 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
292
314
|
* broader than same-uid, so a token gates it without changing the on-wire MCP.
|
|
293
315
|
*/
|
|
294
316
|
bearerToken?: string
|
|
317
|
+
/**
|
|
318
|
+
* Per-delivery outcome log dir (Ф-#8a) — when set, EVERY send_to_peer the daemon
|
|
319
|
+
* routes appends one logfmt outcome line to `<dir>/delivery.log` (rotated,
|
|
320
|
+
* metadata-only — see daemon/deliverylog.ts). OFF by default (library/test
|
|
321
|
+
* callers stay hermetic); the production main wires cfg.eventLogDir here, so
|
|
322
|
+
* delivery.log sits next to lifecycle.log under the SAME cfg-resolved root.
|
|
323
|
+
*/
|
|
324
|
+
deliveryLogDir?: string
|
|
295
325
|
/**
|
|
296
326
|
* Write the discovery file (router.json) at <root>/state/iapeer/router.json with
|
|
297
327
|
* the active addresses `{sock, tcp}` — atomically on listen, removed on close.
|
|
@@ -319,7 +349,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
319
349
|
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'unauthorized' } }))
|
|
320
350
|
return
|
|
321
351
|
}
|
|
322
|
-
const server = createMcpServer(opts.wake)
|
|
352
|
+
const server = createMcpServer(opts.wake, opts.deliveryLogDir)
|
|
323
353
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
324
354
|
res.on('close', () => {
|
|
325
355
|
transport.close()
|
package/src/daemon/main.ts
CHANGED
|
@@ -115,6 +115,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
115
115
|
port: opts.port ?? DEFAULT_DAEMON_PORT,
|
|
116
116
|
host: opts.host ?? '127.0.0.1',
|
|
117
117
|
socketPath: opts.socketPath ?? defaultDaemonSocketPath({ env, rootDir: opts.rootDir }),
|
|
118
|
+
// Ф-#8a per-delivery outcome log → delivery.log NEXT TO lifecycle.log, routed
|
|
119
|
+
// through the SAME lifecycle cfg (NOT re-resolved from env) so a sandboxed cfg
|
|
120
|
+
// sandboxes this log too.
|
|
121
|
+
deliveryLogDir: cfg.eventLogDir,
|
|
118
122
|
discovery: opts.discovery ?? true,
|
|
119
123
|
env,
|
|
120
124
|
rootDir: opts.rootDir,
|
|
@@ -121,6 +121,23 @@ describe('writePeerProfileAtomic H1 preserve', () => {
|
|
|
121
121
|
const parsed = readPeerProfile(cwd)!
|
|
122
122
|
expect(parsed.intelligence).toBe('artificial')
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
test('wake_policy "ephemeral" parsed; unknown/absent → omitted (never throws)', () => {
|
|
126
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
127
|
+
// honored enum value
|
|
128
|
+
writeFileSync(peerProfilePath(cwd), JSON.stringify({
|
|
129
|
+
personality: 'p', runtime: 'claude', runtimes: ['claude'], wake_policy: 'ephemeral',
|
|
130
|
+
}))
|
|
131
|
+
expect(readPeerProfile(cwd)!.wake_policy).toBe('ephemeral')
|
|
132
|
+
// absent → undefined
|
|
133
|
+
writeFileSync(peerProfilePath(cwd), JSON.stringify({ personality: 'p', runtime: 'claude', runtimes: ['claude'] }))
|
|
134
|
+
expect(readPeerProfile(cwd)!.wake_policy).toBeUndefined()
|
|
135
|
+
// unknown value → omitted (forward-compatible, no throw)
|
|
136
|
+
writeFileSync(peerProfilePath(cwd), JSON.stringify({
|
|
137
|
+
personality: 'p', runtime: 'claude', runtimes: ['claude'], wake_policy: 'persistent-future',
|
|
138
|
+
}))
|
|
139
|
+
expect(readPeerProfile(cwd)!.wake_policy).toBeUndefined()
|
|
140
|
+
})
|
|
124
141
|
})
|
|
125
142
|
|
|
126
143
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/identity/index.ts
CHANGED
|
@@ -48,6 +48,14 @@ import {
|
|
|
48
48
|
// Types
|
|
49
49
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
50
|
|
|
51
|
+
/** Per-peer wake policy. `ephemeral` = stateless worker: every delivery is handled in
|
|
52
|
+
* a FRESH session, the peer dies after its turn, and a delivery to a still-live session
|
|
53
|
+
* is QUEUED (serial) rather than injected — so each task gets a clean context window.
|
|
54
|
+
* Lifecycle-owned (resolveWakeMode forces fresh; superviseTick reaps post-turn; the
|
|
55
|
+
* daemon drains the queue on death). Absent = normal warm-on-demand (resume-eligible).
|
|
56
|
+
* Enum (not bool) to leave room for future policies. */
|
|
57
|
+
export type WakePolicy = 'ephemeral'
|
|
58
|
+
|
|
51
59
|
export interface PeerProfile {
|
|
52
60
|
personality: string
|
|
53
61
|
runtime: Runtime
|
|
@@ -59,6 +67,8 @@ export interface PeerProfile {
|
|
|
59
67
|
* warm). Carries an opening directive and/or a "I'm up" report. */
|
|
60
68
|
initial_prompt?: string
|
|
61
69
|
interfaces?: PeerInterfaces
|
|
70
|
+
/** Per-peer wake policy (lifecycle-owned). Absent = normal warm-on-demand. */
|
|
71
|
+
wake_policy?: WakePolicy
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
// Write shape: intelligence/description optional so a caller can write a profile
|
|
@@ -211,6 +221,9 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
|
|
|
211
221
|
? { initial_prompt: obj.initial_prompt }
|
|
212
222
|
: {}),
|
|
213
223
|
...(interfaces ? { interfaces } : {}),
|
|
224
|
+
// Wake policy — only the known enum value is honored; anything else → omitted
|
|
225
|
+
// (treated as normal warm-on-demand, never throws on an unknown future value).
|
|
226
|
+
...(obj.wake_policy === 'ephemeral' ? { wake_policy: 'ephemeral' as const } : {}),
|
|
214
227
|
}
|
|
215
228
|
}
|
|
216
229
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Ф-#8b: cold-wake boot first-message delivery via load-buffer + bracketed
|
|
2
|
+
// paste-buffer — the SAME byte-path as warm delivery (transport.deliverViaTmux),
|
|
3
|
+
// replacing the old `send-keys -l` retype. Proven hermetically against a REAL
|
|
4
|
+
// tmux (gated like sockdir.test.ts) with a fake tui adapter whose "runtime" is
|
|
5
|
+
// `cat >> <file>`: whatever the boot path injects into the pane lands verbatim in
|
|
6
|
+
// the file, and the ready-gate keys on that file's mtime (the activity proxy).
|
|
7
|
+
//
|
|
8
|
+
// pty note: `cat` reads the pane pty in CANONICAL mode (line-buffered, ~1 KiB
|
|
9
|
+
// line cap on macOS) — real TUIs run raw and have no such cap. The fixture
|
|
10
|
+
// message therefore uses many sub-1-KiB LINES to total multi-KiB; what this test
|
|
11
|
+
// pins is the INJECTION path (one bracketed paste, no option-parsing traps, no
|
|
12
|
+
// key-by-key retype), not the tty discipline.
|
|
13
|
+
|
|
14
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
15
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
|
|
16
|
+
import { tmpdir } from 'os'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { spawnSync } from 'child_process'
|
|
19
|
+
import { launch } from './index.ts'
|
|
20
|
+
import type { LaunchConfig, LaunchSpec, RuntimeAdapter } from './types.ts'
|
|
21
|
+
|
|
22
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
23
|
+
|
|
24
|
+
const dirs: string[] = []
|
|
25
|
+
function mkTmp(): string {
|
|
26
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-bootdeliver-'))
|
|
27
|
+
dirs.push(d)
|
|
28
|
+
return d
|
|
29
|
+
}
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Fake tui adapter: the "runtime" appends its pty input to `recvPath`; the
|
|
35
|
+
* activity proxy is that file's mtime (absent → null, exactly like a missing
|
|
36
|
+
* transcript), so the boot baseline is 0 and the ready-gate flips on receipt. */
|
|
37
|
+
function catAdapter(recvPath: string): RuntimeAdapter {
|
|
38
|
+
return {
|
|
39
|
+
runtime: 'claude', // any tui Runtime — the adapter object itself is what launch consumes
|
|
40
|
+
kind: 'tui',
|
|
41
|
+
usesDoctrine: false,
|
|
42
|
+
deliveryMarkers: { promptGlyphs: [] },
|
|
43
|
+
buildArgv: () => ['/bin/sh', '-c', `cat >> ${recvPath}`],
|
|
44
|
+
bootDialogKeys: () => null,
|
|
45
|
+
isInputReady: () => true,
|
|
46
|
+
newestActivityMtime: () => {
|
|
47
|
+
try {
|
|
48
|
+
return statSync(recvPath).mtimeMs
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
permissionDialogActive: () => false,
|
|
54
|
+
permissionDialogKeys: () => [],
|
|
55
|
+
resolveResume: () => ({ ok: true }),
|
|
56
|
+
executeControl: () => null,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('boot first-message delivery (load-buffer + bracketed paste)', () => {
|
|
61
|
+
test.if(tmuxAvailable)(
|
|
62
|
+
'a multi-line, dash-leading, multi-KiB first message lands INTACT and launch goes READY',
|
|
63
|
+
async () => {
|
|
64
|
+
const root = mkTmp()
|
|
65
|
+
const recv = join(root, 'received.txt')
|
|
66
|
+
const sock = join(root, 'tmux-iap-claude-bootd.sock')
|
|
67
|
+
// The fixture stresses every historical boot-inject trap at once:
|
|
68
|
+
// • leading '-' — the send-keys option-parsing trap (audit #6);
|
|
69
|
+
// • quotes/$()/; — shell-metachar corruption if anything re-quoted the body;
|
|
70
|
+
// • multi-KiB — a size send-keys -l replayed key-by-key (8 × ~700 B lines).
|
|
71
|
+
const firstMessage = [
|
|
72
|
+
'- dash-leading first line (the send-keys option-parsing trap)',
|
|
73
|
+
`quotes "double" 'single' and $dollar \`backtick\` ; semicolon`,
|
|
74
|
+
...Array.from({ length: 8 }, (_, i) => `${i}:${'x'.repeat(700)}`),
|
|
75
|
+
].join('\n')
|
|
76
|
+
const spec: LaunchSpec = {
|
|
77
|
+
personality: 'bootd',
|
|
78
|
+
runtime: 'claude',
|
|
79
|
+
cwd: root,
|
|
80
|
+
identity: 'claude-bootd',
|
|
81
|
+
socketPath: sock,
|
|
82
|
+
intelligence: 'artificial',
|
|
83
|
+
}
|
|
84
|
+
const cfg: LaunchConfig = {
|
|
85
|
+
claudeBin: 'unused',
|
|
86
|
+
codexBin: 'unused',
|
|
87
|
+
sockDir: root,
|
|
88
|
+
bootDeadlineSecs: 10,
|
|
89
|
+
readyGateSecs: 8,
|
|
90
|
+
maxAgeSecs: 120,
|
|
91
|
+
logDir: join(root, 'logs'),
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const r = await launch(spec, catAdapter(recv), firstMessage, cfg)
|
|
95
|
+
expect(r.status).toBe('READY')
|
|
96
|
+
const got = readFileSync(recv, 'utf8')
|
|
97
|
+
expect(got).toContain('- dash-leading first line (the send-keys option-parsing trap)')
|
|
98
|
+
expect(got).toContain(`quotes "double" 'single' and $dollar \`backtick\` ; semicolon`)
|
|
99
|
+
for (let i = 0; i < 8; i++) expect(got).toContain(`${i}:${'x'.repeat(700)}`)
|
|
100
|
+
} finally {
|
|
101
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
60000,
|
|
105
|
+
)
|
|
106
|
+
})
|
package/src/launch/index.ts
CHANGED
|
@@ -273,7 +273,8 @@ export const launch: LaunchFn = async (
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
// (6) BOOT phase (tui) — baseline the activity proxy, answer startup dialogs,
|
|
276
|
-
// wait for the input surface, then deliver firstMessage (
|
|
276
|
+
// wait for the input surface, then deliver firstMessage (load-buffer +
|
|
277
|
+
// bracketed paste-buffer + Enter — the SAME byte-path as warm delivery).
|
|
277
278
|
// An EMPTY firstMessage is a BARE bring-up (folder-launch with no seed, or an
|
|
278
279
|
// attach-resume that carries no message): reach the input surface and return
|
|
279
280
|
// READY — there is no message to deliver and nothing to ready-gate on (the
|
|
@@ -297,12 +298,31 @@ export const launch: LaunchFn = async (
|
|
|
297
298
|
}
|
|
298
299
|
if (adapter.isInputReady(pane)) {
|
|
299
300
|
if (!hasMessage) return ready(identity) // bare bring-up — session up, no message
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// send-keys
|
|
303
|
-
|
|
301
|
+
// Boot delivery = load-buffer → paste-buffer -p → Enter (Ф-#8b hardening):
|
|
302
|
+
// the SAME mechanism warm delivery uses (transport.deliverViaTmux), replacing
|
|
303
|
+
// the old `send-keys -l`. send-keys retypes the message as literal keystrokes —
|
|
304
|
+
// a multi-KB envelope replays key-by-key through the pty input buffer, and the
|
|
305
|
+
// two paths could diverge (a message that survives warm delivery could be
|
|
306
|
+
// mangled at cold-wake). load-buffer hands the TUI the whole envelope as ONE
|
|
307
|
+
// bracketed paste, byte-identical to the warm path. A load/paste hiccup →
|
|
308
|
+
// retry on the next boot iteration (previously the send-keys result was
|
|
309
|
+
// ignored and a failed inject was declared delivered, failing the wake later
|
|
310
|
+
// with the wrong reason at the ready-gate).
|
|
311
|
+
const bufferName = `iapeer-boot-${process.pid}-${Date.now()}`
|
|
312
|
+
const load = spawnSync(
|
|
313
|
+
'tmux',
|
|
314
|
+
['-S', sock, 'load-buffer', '-b', bufferName, '-'],
|
|
315
|
+
{ input: firstMessage, encoding: 'utf8' },
|
|
316
|
+
)
|
|
317
|
+
if (load.status !== 0) continue
|
|
318
|
+
const paste = tmux(sock, 'paste-buffer', '-p', '-b', bufferName, '-t', identity)
|
|
319
|
+
if (!paste.ok) {
|
|
320
|
+
tmux(sock, 'delete-buffer', '-b', bufferName)
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
304
323
|
await sleep(300)
|
|
305
324
|
tmux(sock, 'send-keys', '-t', identity, 'Enter')
|
|
325
|
+
tmux(sock, 'delete-buffer', '-b', bufferName)
|
|
306
326
|
delivered = true
|
|
307
327
|
}
|
|
308
328
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -312,7 +312,8 @@ export interface LaunchResult {
|
|
|
312
312
|
* Bring up ONE session: pre-clean stale tmux server → tmux new-session -d with
|
|
313
313
|
* adapter.buildArgv → pipe-pane → session self-TTL → boot (answer dialogs via
|
|
314
314
|
* adapter, wait for adapter.isInputReady, deliver the first message via
|
|
315
|
-
*
|
|
315
|
+
* load-buffer + bracketed paste — the same byte-path as warm delivery) →
|
|
316
|
+
* ready-gate (adapter.newestActivityMtime strictly advances).
|
|
316
317
|
* Runtime-agnostic; all specifics come from the adapter. Returns READY/FAILED.
|
|
317
318
|
* `firstMessage` (the task / routed envelope) is delivered as the boot message;
|
|
318
319
|
* a router runtime skips the TUI boot/ready phases.
|
|
@@ -19,17 +19,22 @@
|
|
|
19
19
|
// • Best-effort throughout: a write/rotate failure is swallowed. Observability
|
|
20
20
|
// must never take down the daemon or fail a wake/reap.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// The rotate-append primitive was PROMOTED to storage/rotatelog.ts (as this header
|
|
23
|
+
// anticipated) when the daemon's per-delivery log became the second producer
|
|
24
|
+
// (Ф-#8a). This module keeps its public API (appendLifecycleEvent + the logfmt
|
|
25
|
+
// helpers, re-exported) so its call sites and tests are untouched; only the
|
|
26
|
+
// implementation now lives in storage.
|
|
25
27
|
|
|
26
|
-
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
|
|
27
28
|
import { join } from 'path'
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_LOG_KEEP,
|
|
31
|
+
DEFAULT_LOG_MAX_BYTES,
|
|
32
|
+
appendRotatedEvent,
|
|
33
|
+
} from '../storage/rotatelog.ts'
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const DEFAULT_KEEP = 5
|
|
35
|
+
// Re-export the logfmt helpers — historical home of these (consumers import them
|
|
36
|
+
// from eventlog; the implementation moved to storage/rotatelog.ts).
|
|
37
|
+
export { fmtValue, formatEventLine } from '../storage/rotatelog.ts'
|
|
33
38
|
|
|
34
39
|
/** The durable lifecycle decision log inside `logDir` (cfg.eventLogDir). */
|
|
35
40
|
export function lifecycleLogPath(logDir: string): string {
|
|
@@ -49,56 +54,6 @@ export function superviseLogVerbose(env: NodeJS.ProcessEnv = process.env): boole
|
|
|
49
54
|
return v === '1' || v === 'true' || v === 'yes'
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
/** logfmt value: bare token, or double-quoted with `"`/`\` escaped, when it
|
|
53
|
-
* contains whitespace, `=` or `"`. Empty string → `""`. */
|
|
54
|
-
export function fmtValue(v: string | number): string {
|
|
55
|
-
const s = String(v)
|
|
56
|
-
if (s === '') return '""'
|
|
57
|
-
if (/[\s"=]/.test(s)) return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
58
|
-
return s
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Render one logfmt line (ts first, then fields in insertion order; undefined
|
|
62
|
-
* fields are skipped). No trailing newline. Pure — unit-testable. */
|
|
63
|
-
export function formatEventLine(nowMs: number, fields: Record<string, string | number | undefined>): string {
|
|
64
|
-
const parts = [`ts=${new Date(nowMs).toISOString()}`]
|
|
65
|
-
for (const [k, v] of Object.entries(fields)) {
|
|
66
|
-
if (v === undefined) continue
|
|
67
|
-
parts.push(`${k}=${fmtValue(v)}`)
|
|
68
|
-
}
|
|
69
|
-
return parts.join(' ')
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Size-rotate `path` (and its .1 … .keep backups) when the next line would push
|
|
73
|
-
* it over `maxBytes`. Drops the oldest, shifts each backup up by one, base→.1.
|
|
74
|
-
* Best-effort: any fs hiccup leaves the chain as-is (we then just append). */
|
|
75
|
-
function rotateIfNeeded(path: string, lineLen: number, maxBytes: number, keep: number): void {
|
|
76
|
-
let size: number
|
|
77
|
-
try {
|
|
78
|
-
size = statSync(path).size
|
|
79
|
-
} catch {
|
|
80
|
-
return // no file yet → nothing to rotate
|
|
81
|
-
}
|
|
82
|
-
if (size + lineLen <= maxBytes) return
|
|
83
|
-
try {
|
|
84
|
-
rmSync(`${path}.${keep}`, { force: true })
|
|
85
|
-
} catch {
|
|
86
|
-
/* best-effort */
|
|
87
|
-
}
|
|
88
|
-
for (let i = keep - 1; i >= 1; i--) {
|
|
89
|
-
try {
|
|
90
|
-
renameSync(`${path}.${i}`, `${path}.${i + 1}`)
|
|
91
|
-
} catch {
|
|
92
|
-
/* that backup may not exist yet */
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
renameSync(path, `${path}.1`)
|
|
97
|
-
} catch {
|
|
98
|
-
/* best-effort */
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
57
|
export interface AppendEventOptions {
|
|
103
58
|
/** Reads the rotation knobs IAPEER_LIFECYCLE_LOG_MAX_BYTES / _KEEP. */
|
|
104
59
|
env?: NodeJS.ProcessEnv
|
|
@@ -119,15 +74,9 @@ export function appendLifecycleEvent(
|
|
|
119
74
|
): void {
|
|
120
75
|
if (!logDir) return
|
|
121
76
|
const env = opts.env ?? process.env
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
mkdirSync(logDir, { recursive: true, mode: 0o700 })
|
|
128
|
-
rotateIfNeeded(path, line.length, maxBytes, keep)
|
|
129
|
-
appendFileSync(path, line, { mode: 0o600 })
|
|
130
|
-
} catch {
|
|
131
|
-
/* observability is best-effort — a log failure must never break a wake/reap */
|
|
132
|
-
}
|
|
77
|
+
appendRotatedEvent(lifecycleLogPath(logDir), fields, {
|
|
78
|
+
nowMs: opts.nowMs,
|
|
79
|
+
maxBytes: envPosInt(env.IAPEER_LIFECYCLE_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
|
|
80
|
+
keep: envPosInt(env.IAPEER_LIFECYCLE_LOG_KEEP, DEFAULT_LOG_KEEP),
|
|
81
|
+
})
|
|
133
82
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -359,6 +359,22 @@ function isHumanConversational(cwd: string): boolean {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* True iff the peer of `cwd` declares `wake_policy: "ephemeral"` — a stateless worker
|
|
364
|
+
* that ALWAYS wakes fresh on delivery (never resume), dies after its turn, and whose
|
|
365
|
+
* warm-session deliveries are queued (M3). A profile read hiccup → not-ephemeral (safe
|
|
366
|
+
* default: normal warm-on-demand). When BOTH ephemeral and a telegram interface are
|
|
367
|
+
* set, ephemeral WINS in resolveWakeMode (explicit policy beats the inferred human
|
|
368
|
+
* type) — provision warns on that combination; it should not occur for real workers.
|
|
369
|
+
*/
|
|
370
|
+
function isEphemeralPeer(cwd: string): boolean {
|
|
371
|
+
try {
|
|
372
|
+
return readPeerProfile(cwd)?.wake_policy === 'ephemeral'
|
|
373
|
+
} catch {
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
362
378
|
/**
|
|
363
379
|
* Decide resume vs fresh on a wake (TARGET redesign). Branch order:
|
|
364
380
|
* 1. argsResume === false (folder-launch `iapeer <runtime>`) → FRESH.
|
|
@@ -390,6 +406,14 @@ export function resolveWakeMode(
|
|
|
390
406
|
return { resume: true, resumeRef: r.ref, cause: 'attach' }
|
|
391
407
|
}
|
|
392
408
|
// 3. default (a message woke a dead/asleep peer): decide by the death cause.
|
|
409
|
+
// 3-ephemeral (M1): a stateless worker ALWAYS wakes fresh on delivery — never resume,
|
|
410
|
+
// regardless of death cause or topic. Its clean-window-per-task is the whole point.
|
|
411
|
+
// Consume a stray .idle-reaped marker so it does not accumulate (it has no effect for
|
|
412
|
+
// an ephemeral peer, which never resumes, but keep state tidy).
|
|
413
|
+
if (isEphemeralPeer(cwd)) {
|
|
414
|
+
clearIdleReaped(cfg, identity)
|
|
415
|
+
return { resume: false, cause: 'ephemeral-policy' }
|
|
416
|
+
}
|
|
393
417
|
// 3a. NOT idle-reaped → it died on its own (crash / self-close) → clean FRESH.
|
|
394
418
|
if (!hasIdleReaped(cfg, identity)) return { resume: false, cause: 'crash-or-self-close' }
|
|
395
419
|
// 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
|
|
@@ -366,8 +366,9 @@ describe('C2 initial_prompt (composeFirstMessage)', () => {
|
|
|
366
366
|
// = .idle-reaped marker, plus peer-type/topic; NO agent-dropped fresh mark).
|
|
367
367
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
368
368
|
|
|
369
|
-
/** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational
|
|
370
|
-
|
|
369
|
+
/** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational;
|
|
370
|
+
* ephemeral → wake_policy "ephemeral". */
|
|
371
|
+
function profileCwd(human: boolean, ephemeral = false): string {
|
|
371
372
|
const cwd = mkdtempSync(join(tmpdir(), 'iapeer-wm-cwd-'))
|
|
372
373
|
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
373
374
|
writeFileSync(
|
|
@@ -378,6 +379,7 @@ function profileCwd(human: boolean): string {
|
|
|
378
379
|
runtimes: ['claude'],
|
|
379
380
|
intelligence: human ? 'natural' : 'artificial',
|
|
380
381
|
...(human ? { interfaces: { telegram: { user_id: 1 } } } : {}),
|
|
382
|
+
...(ephemeral ? { wake_policy: 'ephemeral' } : {}),
|
|
381
383
|
}),
|
|
382
384
|
)
|
|
383
385
|
return cwd
|
|
@@ -395,8 +397,8 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
|
395
397
|
for (const c of cwds) rmSync(c, { recursive: true, force: true })
|
|
396
398
|
})
|
|
397
399
|
const cfg = () => ({ stateDir } as LifecycleConfig)
|
|
398
|
-
const cwd = (human = false) => {
|
|
399
|
-
const c = profileCwd(human)
|
|
400
|
+
const cwd = (human = false, ephemeral = false) => {
|
|
401
|
+
const c = profileCwd(human, ephemeral)
|
|
400
402
|
cwds.push(c)
|
|
401
403
|
return c
|
|
402
404
|
}
|
|
@@ -449,6 +451,26 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
|
449
451
|
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'unrelated-bug')).toEqual({ resume: false, cause: 'idle-reaped-new-topic' })
|
|
450
452
|
expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed even on the fresh executor branch
|
|
451
453
|
})
|
|
454
|
+
|
|
455
|
+
// ── M1: wake_policy "ephemeral" → ALWAYS fresh on delivery, overrides resume ──
|
|
456
|
+
test('DEFAULT + ephemeral → FRESH (ephemeral-policy), even with a resumable transcript', () => {
|
|
457
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
|
|
458
|
+
})
|
|
459
|
+
test('DEFAULT + ephemeral + idle-reaped → FRESH (overrides idle-reaped-resume), marker consumed', () => {
|
|
460
|
+
const c = cfg()
|
|
461
|
+
setIdleReaped(c, 'claude-p')
|
|
462
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
|
|
463
|
+
expect(hasIdleReaped(c, 'claude-p')).toBe(false) // stray marker consumed
|
|
464
|
+
})
|
|
465
|
+
test('DEFAULT + ephemeral + telegram (human) → FRESH (ephemeral WINS over human type)', () => {
|
|
466
|
+
const c = cfg()
|
|
467
|
+
setIdleReaped(c, 'claude-p')
|
|
468
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(true, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
|
|
469
|
+
})
|
|
470
|
+
test('ephemeral does NOT hijack explicit attach (argsResume=true still resumes)', () => {
|
|
471
|
+
// attach is an operator action; ephemeral only governs the delivery path.
|
|
472
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), true, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'attach' })
|
|
473
|
+
})
|
|
452
474
|
})
|
|
453
475
|
|
|
454
476
|
describe('idle-reaped marker round-trip', () => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// rotatelog — the generic rotated-logfmt-append primitive (promoted from
|
|
2
|
+
// lifecycle/eventlog.ts when the daemon's delivery.log became the second
|
|
3
|
+
// producer). The logfmt formatting (fmtValue/formatEventLine) is pinned in
|
|
4
|
+
// lifecycle/eventlog.test.ts (its historical home, re-exported); these tests
|
|
5
|
+
// cover what is NEW at this layer: the path-parameterized append + rotation.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { appendRotatedEvent } from './rotatelog.ts'
|
|
12
|
+
|
|
13
|
+
const dirs: string[] = []
|
|
14
|
+
function mkTmp(): string {
|
|
15
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-rotatelog-'))
|
|
16
|
+
dirs.push(d)
|
|
17
|
+
return d
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('appendRotatedEvent', () => {
|
|
24
|
+
test('creates the parent dir and appends one ts-stamped logfmt line', () => {
|
|
25
|
+
const path = join(mkTmp(), 'deep', 'nested', 'some.log')
|
|
26
|
+
appendRotatedEvent(path, { ev: 'delivery', ok: 'true', note: 'two words' }, { nowMs: 1750000000000 })
|
|
27
|
+
const text = readFileSync(path, 'utf8')
|
|
28
|
+
expect(text).toBe('ts=2025-06-15T15:06:40.000Z ev=delivery ok=true note="two words"\n')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('rotates base → .1 at maxBytes and drops beyond keep', () => {
|
|
32
|
+
const path = join(mkTmp(), 'r.log')
|
|
33
|
+
const line = { ev: 'x', pad: 'a'.repeat(50) } // ~65 bytes/line
|
|
34
|
+
// maxBytes 100 → every second append rotates; keep 1 → no .2 ever exists.
|
|
35
|
+
for (let i = 0; i < 6; i++) appendRotatedEvent(path, line, { maxBytes: 100, keep: 1 })
|
|
36
|
+
expect(existsSync(path)).toBe(true)
|
|
37
|
+
expect(existsSync(`${path}.1`)).toBe(true)
|
|
38
|
+
expect(existsSync(`${path}.2`)).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('never throws on an unwritable path (best-effort observability)', () => {
|
|
42
|
+
expect(() => appendRotatedEvent('/dev/null/impossible/x.log', { ev: 'x' })).not.toThrow()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Rotated logfmt append — the GENERIC durable-log primitive, promoted out of
|
|
2
|
+
// lifecycle/eventlog.ts (its header anticipated exactly this: "the rotate-append
|
|
3
|
+
// primitive is path-parameterized, so the adjacent 'log rotation' phase can
|
|
4
|
+
// promote it to storage/ and point other log producers at it"). Producers today:
|
|
5
|
+
// • lifecycle.log (lifecycle/eventlog.ts — daemon lifecycle decisions)
|
|
6
|
+
// • delivery.log (daemon/deliverylog.ts — per-delivery outcomes, Ф-#8a)
|
|
7
|
+
//
|
|
8
|
+
// Design (carried verbatim from eventlog):
|
|
9
|
+
// • One line per event, logfmt (`key=value`, values quoted iff they contain
|
|
10
|
+
// whitespace/quotes/`=`). Human-greppable AND machine-parseable.
|
|
11
|
+
// • Append-only, app-managed SIZE rotation (base → .1 … .keep). This is the
|
|
12
|
+
// "встроенная ротация" class (Фаза — Ротация логов iapeer): a log OUR code
|
|
13
|
+
// writes rotates itself in the writer; external rotation is only for logs
|
|
14
|
+
// written by processes we don't control.
|
|
15
|
+
// • The target PATH is passed IN by the caller (who routes it through cfg),
|
|
16
|
+
// never re-resolved from env here — so a sandboxed caller cfg sandboxes the
|
|
17
|
+
// log too (no leak to the real ~/.iapeer).
|
|
18
|
+
// • Best-effort throughout: a write/rotate failure is swallowed. Observability
|
|
19
|
+
// must never take down the daemon or fail a wake/reap/delivery.
|
|
20
|
+
|
|
21
|
+
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
|
|
22
|
+
import { dirname } from 'path'
|
|
23
|
+
|
|
24
|
+
/** Default cap per log file before it rotates to <path>.1. */
|
|
25
|
+
export const DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
|
|
26
|
+
/** Default number of rotated backups kept (<path>.1 … .KEEP). */
|
|
27
|
+
export const DEFAULT_LOG_KEEP = 5
|
|
28
|
+
|
|
29
|
+
/** logfmt value: bare token, or double-quoted with `"`/`\` escaped, when it
|
|
30
|
+
* contains whitespace, `=` or `"`. Empty string → `""`. */
|
|
31
|
+
export function fmtValue(v: string | number): string {
|
|
32
|
+
const s = String(v)
|
|
33
|
+
if (s === '') return '""'
|
|
34
|
+
if (/[\s"=]/.test(s)) return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
35
|
+
return s
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render one logfmt line (ts first, then fields in insertion order; undefined
|
|
39
|
+
* fields are skipped). No trailing newline. Pure — unit-testable. */
|
|
40
|
+
export function formatEventLine(nowMs: number, fields: Record<string, string | number | undefined>): string {
|
|
41
|
+
const parts = [`ts=${new Date(nowMs).toISOString()}`]
|
|
42
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
43
|
+
if (v === undefined) continue
|
|
44
|
+
parts.push(`${k}=${fmtValue(v)}`)
|
|
45
|
+
}
|
|
46
|
+
return parts.join(' ')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Size-rotate `path` (and its .1 … .keep backups) when the next line would push
|
|
50
|
+
* it over `maxBytes`. Drops the oldest, shifts each backup up by one, base→.1.
|
|
51
|
+
* Best-effort: any fs hiccup leaves the chain as-is (we then just append). */
|
|
52
|
+
function rotateIfNeeded(path: string, lineLen: number, maxBytes: number, keep: number): void {
|
|
53
|
+
let size: number
|
|
54
|
+
try {
|
|
55
|
+
size = statSync(path).size
|
|
56
|
+
} catch {
|
|
57
|
+
return // no file yet → nothing to rotate
|
|
58
|
+
}
|
|
59
|
+
if (size + lineLen <= maxBytes) return
|
|
60
|
+
try {
|
|
61
|
+
rmSync(`${path}.${keep}`, { force: true })
|
|
62
|
+
} catch {
|
|
63
|
+
/* best-effort */
|
|
64
|
+
}
|
|
65
|
+
for (let i = keep - 1; i >= 1; i--) {
|
|
66
|
+
try {
|
|
67
|
+
renameSync(`${path}.${i}`, `${path}.${i + 1}`)
|
|
68
|
+
} catch {
|
|
69
|
+
/* that backup may not exist yet */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
renameSync(path, `${path}.1`)
|
|
74
|
+
} catch {
|
|
75
|
+
/* best-effort */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AppendRotatedOptions {
|
|
80
|
+
/** Stamp the line with this epoch-ms (a caller may pass its own tick clock so the
|
|
81
|
+
* log timestamp agrees with its accounting). Default Date.now(). */
|
|
82
|
+
nowMs?: number
|
|
83
|
+
/** Rotation cap per file (default DEFAULT_LOG_MAX_BYTES). */
|
|
84
|
+
maxBytes?: number
|
|
85
|
+
/** Rotated backups kept (default DEFAULT_LOG_KEEP). */
|
|
86
|
+
keep?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Append one logfmt event line to the rotated log at `path` (full file path,
|
|
91
|
+
* routed through the caller's cfg). Creates the parent dir (0700) and the file
|
|
92
|
+
* (0600) as needed. Fully best-effort — never throws.
|
|
93
|
+
*/
|
|
94
|
+
export function appendRotatedEvent(
|
|
95
|
+
path: string,
|
|
96
|
+
fields: Record<string, string | number | undefined>,
|
|
97
|
+
opts: AppendRotatedOptions = {},
|
|
98
|
+
): void {
|
|
99
|
+
const line = `${formatEventLine(opts.nowMs ?? Date.now(), fields)}\n`
|
|
100
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_LOG_MAX_BYTES
|
|
101
|
+
const keep = opts.keep ?? DEFAULT_LOG_KEEP
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
104
|
+
rotateIfNeeded(path, line.length, maxBytes, keep)
|
|
105
|
+
appendFileSync(path, line, { mode: 0o600 })
|
|
106
|
+
} catch {
|
|
107
|
+
/* observability is best-effort — a log failure must never break the caller */
|
|
108
|
+
}
|
|
109
|
+
}
|