@agfpd/iapeer 0.2.10 → 0.2.12
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/cli/index.ts +24 -1
- package/src/daemon/daemon.test.ts +116 -3
- package/src/daemon/index.ts +58 -8
- package/src/daemon/main.test.ts +112 -0
- package/src/daemon/main.ts +87 -1
- package/src/index.ts +3 -0
- package/src/lifecycle/index.ts +174 -4
- package/src/lifecycle/lifecycle.test.ts +134 -0
- package/src/lifecycle/queue.test.ts +185 -0
- package/src/lifecycle/queue.ts +159 -0
- package/src/onboard/memory.test.ts +157 -0
- package/src/onboard/memory.ts +124 -0
- package/src/provision/index.ts +21 -0
- package/src/provision/provision.test.ts +57 -1
- package/src/status/index.ts +119 -0
- package/src/status/status.test.ts +125 -0
- package/src/transport/index.ts +39 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Memory slot — the DECLARATIVE provider slot (docs/Слот памяти — контракт
|
|
2
|
+
// memory provider.md): the core only READS the root declaration; absent /
|
|
3
|
+
// unreadable / invalid → EMPTY slot (bare core, valid state, never an error).
|
|
4
|
+
// Plus the host-status assembly + rendering (memory: <provider> | none).
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import {
|
|
11
|
+
formatHostStatus,
|
|
12
|
+
heartbeatAgeSecs,
|
|
13
|
+
hostStatus,
|
|
14
|
+
memoryProviderPath,
|
|
15
|
+
readMemoryProvider,
|
|
16
|
+
type HostStatus,
|
|
17
|
+
type MemoryProvider,
|
|
18
|
+
} from './index.ts'
|
|
19
|
+
|
|
20
|
+
const dirs: string[] = []
|
|
21
|
+
function mkTmp(): string {
|
|
22
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-slot-'))
|
|
23
|
+
dirs.push(d)
|
|
24
|
+
return d
|
|
25
|
+
}
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function envFor(root: string): NodeJS.ProcessEnv {
|
|
31
|
+
return { IAPEER_ROOT: root } as NodeJS.ProcessEnv
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VALID = {
|
|
35
|
+
provider: 'iapeer-memory',
|
|
36
|
+
package: '@agfpd/iapeer-memory',
|
|
37
|
+
version: '0.1.0',
|
|
38
|
+
registeredAt: '2026-06-10T00:00:00Z',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
42
|
+
test('absent file → null (EMPTY slot — valid bare state)', () => {
|
|
43
|
+
expect(readMemoryProvider(envFor(mkTmp()))).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('valid declaration → parsed provider; heartbeat optional', () => {
|
|
47
|
+
const root = mkTmp()
|
|
48
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify({ ...VALID, heartbeat: '/tmp/hb' }))
|
|
49
|
+
const p = readMemoryProvider(envFor(root))
|
|
50
|
+
expect(p).toMatchObject({ ...VALID, heartbeat: '/tmp/hb' })
|
|
51
|
+
// without heartbeat — field absent, not empty-string
|
|
52
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify(VALID))
|
|
53
|
+
expect(readMemoryProvider(envFor(root))?.heartbeat).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('garbage / wrong shape / missing required fields → null, NEVER throws', () => {
|
|
57
|
+
const root = mkTmp()
|
|
58
|
+
const env = envFor(root)
|
|
59
|
+
for (const bad of ['NOT JSON {{{', '[]', '{}', JSON.stringify({ provider: 'x' }), JSON.stringify({ ...VALID, version: 42 })]) {
|
|
60
|
+
writeFileSync(memoryProviderPath(env), bad)
|
|
61
|
+
expect(readMemoryProvider(env)).toBeNull()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('heartbeatAgeSecs', () => {
|
|
67
|
+
test('no heartbeat declared → null; declared but absent file → null; present → age', () => {
|
|
68
|
+
const root = mkTmp()
|
|
69
|
+
expect(heartbeatAgeSecs({ ...VALID } as MemoryProvider)).toBeNull()
|
|
70
|
+
const hb = join(root, 'memoryd.heartbeat')
|
|
71
|
+
expect(heartbeatAgeSecs({ ...VALID, heartbeat: hb } as MemoryProvider)).toBeNull() // not running
|
|
72
|
+
writeFileSync(hb, '')
|
|
73
|
+
const old = (Date.now() - 45_000) / 1000
|
|
74
|
+
utimesSync(hb, old, old)
|
|
75
|
+
const age = heartbeatAgeSecs({ ...VALID, heartbeat: hb } as MemoryProvider)
|
|
76
|
+
expect(age).toBeGreaterThanOrEqual(44)
|
|
77
|
+
expect(age).toBeLessThanOrEqual(60)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('hostStatus + formatHostStatus', () => {
|
|
82
|
+
test('empty slot → memory: none; daemon health from the injected probe', async () => {
|
|
83
|
+
const root = mkTmp()
|
|
84
|
+
const s = await hostStatus({ env: envFor(root), probe: async () => true })
|
|
85
|
+
expect(s.daemon.healthy).toBe(true)
|
|
86
|
+
expect(s.memory.provider).toBeNull()
|
|
87
|
+
expect(formatHostStatus(s)).toContain('memory: none')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('occupied slot + fresh heartbeat → provider line with age', async () => {
|
|
91
|
+
const root = mkTmp()
|
|
92
|
+
const env = envFor(root)
|
|
93
|
+
const hb = join(root, 'memoryd.heartbeat')
|
|
94
|
+
writeFileSync(hb, '')
|
|
95
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, heartbeat: hb }))
|
|
96
|
+
const s = await hostStatus({ env, probe: async () => true })
|
|
97
|
+
expect(formatHostStatus(s)).toMatch(/memory: iapeer-memory 0\.1\.0 \(heartbeat \d+s ago\)/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('occupied slot, heartbeat declared but file ABSENT → "daemon not running" (graceful-shutdown semantics)', () => {
|
|
101
|
+
const s: HostStatus = {
|
|
102
|
+
version: '0.0.0',
|
|
103
|
+
daemon: { healthy: false, url: null, sock: null },
|
|
104
|
+
memory: {
|
|
105
|
+
provider: { ...VALID, heartbeat: '/nope/hb' } as MemoryProvider,
|
|
106
|
+
heartbeatAgeSecs: null,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
const text = formatHostStatus(s)
|
|
110
|
+
expect(text).toContain('memory: iapeer-memory 0.1.0 (daemon not running — no heartbeat file)')
|
|
111
|
+
expect(text).toContain('daemon: NOT healthy')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('discovery file addresses surface in the daemon line', async () => {
|
|
115
|
+
const root = mkTmp()
|
|
116
|
+
const env = envFor(root)
|
|
117
|
+
const stateDir = join(root, 'state', 'iapeer')
|
|
118
|
+
mkdirSync(stateDir, { recursive: true })
|
|
119
|
+
writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x/router.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
|
|
120
|
+
const s = await hostStatus({ env, probe: async () => true })
|
|
121
|
+
const text = formatHostStatus(s)
|
|
122
|
+
expect(text).toContain('@ http://127.0.0.1:8765/mcp')
|
|
123
|
+
expect(text).toContain('+ /x/router.sock')
|
|
124
|
+
})
|
|
125
|
+
})
|
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')
|