@agfpd/iapeer 0.2.9 → 0.2.11
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 +151 -1
- package/src/daemon/deliverylog.ts +64 -0
- package/src/daemon/index.ts +86 -6
- package/src/daemon/main.test.ts +112 -0
- package/src/daemon/main.ts +91 -1
- 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 +197 -3
- package/src/lifecycle/lifecycle.test.ts +160 -4
- package/src/lifecycle/queue.test.ts +185 -0
- package/src/lifecycle/queue.ts +159 -0
- package/src/provision/index.ts +21 -0
- package/src/provision/provision.test.ts +57 -1
- package/src/storage/rotatelog.test.ts +44 -0
- package/src/storage/rotatelog.ts +109 -0
- package/src/transport/index.ts +39 -0
|
@@ -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
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -378,6 +378,14 @@ export interface RouteResult {
|
|
|
378
378
|
delivered_to: { personality: string; runtime: string }
|
|
379
379
|
woke: boolean
|
|
380
380
|
ts: string
|
|
381
|
+
/** wake_policy:"ephemeral" M3 — true when the message was ENQUEUED for a
|
|
382
|
+
* stateless worker rather than injected/woken synchronously: the daemon
|
|
383
|
+
* drains the per-peer FIFO one task per fresh session (async). The
|
|
384
|
+
* substantive result arrives as the worker's own reply (FaaS semantics);
|
|
385
|
+
* `delivered_to` names the ACCEPTING queue identity, not a live session. */
|
|
386
|
+
queued?: boolean
|
|
387
|
+
/** Queue depth right after the enqueue (observability; only with queued). */
|
|
388
|
+
queueDepth?: number
|
|
381
389
|
}
|
|
382
390
|
|
|
383
391
|
// WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
|
|
@@ -403,9 +411,29 @@ export interface WakeOutcome {
|
|
|
403
411
|
}
|
|
404
412
|
export type WakeFn = (req: WakeRequest) => Promise<WakeOutcome>
|
|
405
413
|
|
|
414
|
+
/** wake_policy:"ephemeral" M3 — the injected serial-queue delivery seam. Like
|
|
415
|
+
* WakeFn, transport never imports lifecycle: the daemon composition (main.ts)
|
|
416
|
+
* wires the queue + drain behind this contract; absent → every target takes
|
|
417
|
+
* the normal live/miss path (library/CLI/test callers unchanged). */
|
|
418
|
+
export interface EphemeralRouteDeps {
|
|
419
|
+
/** Is the target peer (by its registry cwd) an ephemeral stateless worker? */
|
|
420
|
+
isEphemeral: (cwd: string) => boolean
|
|
421
|
+
/** Always-enqueue delivery: enqueue + async drain kick → fast `{queued:true}`
|
|
422
|
+
* ack. MUST NOT block on the wake (the sender's content-level answer is the
|
|
423
|
+
* worker's own reply, not this transport ack). */
|
|
424
|
+
deliver: (args: {
|
|
425
|
+
peer: PeerRecord
|
|
426
|
+
envelope: string
|
|
427
|
+
topic?: string
|
|
428
|
+
runtime?: string
|
|
429
|
+
}) => Promise<Result<RouteResult>>
|
|
430
|
+
}
|
|
431
|
+
|
|
406
432
|
export interface RouteDeps {
|
|
407
433
|
/** On a miss, wake the dead peer instead of returning offline (Ф2). */
|
|
408
434
|
wake?: WakeFn
|
|
435
|
+
/** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
|
|
436
|
+
ephemeral?: EphemeralRouteDeps
|
|
409
437
|
}
|
|
410
438
|
|
|
411
439
|
function truncateTopic(raw: string | undefined): string | undefined {
|
|
@@ -483,6 +511,17 @@ export async function routeSend(
|
|
|
483
511
|
message,
|
|
484
512
|
})
|
|
485
513
|
|
|
514
|
+
// M3 wake_policy:"ephemeral" — an ephemeral target is NEVER injected into a live
|
|
515
|
+
// session and never woken synchronously here: the delivery is ALWAYS enqueued
|
|
516
|
+
// (per-peer disk FIFO) and drained one task per fresh session, asynchronously.
|
|
517
|
+
// ONE delivery path by design: no live/miss race, strict FIFO, a clean context
|
|
518
|
+
// window per task. Self-send is refused up front (a worker enqueueing to itself
|
|
519
|
+
// would deadlock its own die-after-reply reap on a forever-non-empty queue).
|
|
520
|
+
if (deps.ephemeral?.isEphemeral(peer.cwd)) {
|
|
521
|
+
if (caller.personality === peer.personality) return err('cannot send to self')
|
|
522
|
+
return deps.ephemeral.deliver({ peer, envelope, topic, runtime })
|
|
523
|
+
}
|
|
524
|
+
|
|
486
525
|
const target = resolvePeerDeliveryTarget(personality, runtime, peer)
|
|
487
526
|
if (target.ok) {
|
|
488
527
|
if (target.value.address === caller.address) return err('cannot send to self')
|