@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,159 @@
|
|
|
1
|
+
// Ephemeral serial queue (wake_policy:"ephemeral" M3) — per-peer disk FIFO of
|
|
2
|
+
// pending tasks for a stateless worker. Deliveries to an ephemeral target are
|
|
3
|
+
// NEVER injected into a live session (one task = one clean context window =
|
|
4
|
+
// the whole point of the policy); they are ALWAYS enqueued here, and the drain
|
|
5
|
+
// (drainEphemeralQueue in index.ts) feeds the worker one task per fresh session.
|
|
6
|
+
//
|
|
7
|
+
// Layout: `<stateDir>/<identity>.queue/<seq>` — one JSON file per task
|
|
8
|
+
// ({ task, topic? }), zero-padded numeric names so lexicographic order IS the
|
|
9
|
+
// FIFO order. Durable by construction: the queue survives a daemon restart and
|
|
10
|
+
// is drained by the supervise tick (the same scan that retries a failed wake).
|
|
11
|
+
//
|
|
12
|
+
// Concurrency: the daemon is the main writer, but a direct CLI `iap send`
|
|
13
|
+
// (daemon down) can race it from another process. Enqueue is therefore
|
|
14
|
+
// EXCLUSIVE-CREATE: write a temp file, then linkSync it to the next seq —
|
|
15
|
+
// linkSync fails with EEXIST on a taken name (atomic on POSIX), so two
|
|
16
|
+
// concurrent enqueues can never share a seq; the loser just advances. Content
|
|
17
|
+
// is complete before the link lands (no partial reads).
|
|
18
|
+
//
|
|
19
|
+
// Retry semantics (boris acceptance (b)): the consumer PEEKS (reads without
|
|
20
|
+
// removing), wakes the worker, and removes the item ONLY on READY — a failed
|
|
21
|
+
// wake leaves the task at the head for the next supervise-tick drain. See
|
|
22
|
+
// drainEphemeralQueue.
|
|
23
|
+
|
|
24
|
+
import { linkSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
import type { LifecycleConfig } from './index.ts'
|
|
27
|
+
|
|
28
|
+
/** One queued task for an ephemeral worker. */
|
|
29
|
+
export interface EphemeralQueueItem {
|
|
30
|
+
/** The routed envelope — becomes the boot first-message of the fresh session. */
|
|
31
|
+
task: string
|
|
32
|
+
/** Optional topic (threading; recorded by the wake as the session topic). */
|
|
33
|
+
topic?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A peeked item: the queue position (for the later remove) plus the payload. */
|
|
37
|
+
export interface PeekedQueueItem extends EphemeralQueueItem {
|
|
38
|
+
seq: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ephemeralQueueDir(cfg: LifecycleConfig, identity: string): string {
|
|
42
|
+
return join(cfg.stateDir, `${identity}.queue`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SEQ_PAD = 6
|
|
46
|
+
|
|
47
|
+
function listSeqs(dir: string): string[] {
|
|
48
|
+
let entries: string[]
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir)
|
|
51
|
+
} catch {
|
|
52
|
+
return [] // no dir yet → empty queue
|
|
53
|
+
}
|
|
54
|
+
// Only the numbered items — temp files (.tmp-*) and strays are not queue entries.
|
|
55
|
+
return entries.filter(name => /^\d+$/.test(name)).sort()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Queue depth (pending tasks). 0 for a missing dir. */
|
|
59
|
+
export function ephemeralQueueDepth(cfg: LifecycleConfig, identity: string): number {
|
|
60
|
+
return listSeqs(ephemeralQueueDir(cfg, identity)).length
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append a task to the identity's FIFO. Returns the depth AFTER the append
|
|
65
|
+
* (≥1 — usable as the "qd" observability field). Exclusive-create: safe against
|
|
66
|
+
* a concurrent enqueue from another process. Throws only on a real FS failure
|
|
67
|
+
* (the caller surfaces it as a delivery error — an un-enqueued task must NOT be
|
|
68
|
+
* reported queued).
|
|
69
|
+
*/
|
|
70
|
+
export function enqueueEphemeralTask(
|
|
71
|
+
cfg: LifecycleConfig,
|
|
72
|
+
identity: string,
|
|
73
|
+
item: EphemeralQueueItem,
|
|
74
|
+
): number {
|
|
75
|
+
const dir = ephemeralQueueDir(cfg, identity)
|
|
76
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
77
|
+
const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
78
|
+
writeFileSync(tmp, JSON.stringify({ task: item.task, ...(item.topic ? { topic: item.topic } : {}) }), {
|
|
79
|
+
mode: 0o600,
|
|
80
|
+
})
|
|
81
|
+
try {
|
|
82
|
+
// Next seq = max existing + 1; on an EEXIST race, advance and retry.
|
|
83
|
+
let seq = (() => {
|
|
84
|
+
const seqs = listSeqs(dir)
|
|
85
|
+
return seqs.length ? parseInt(seqs[seqs.length - 1]!, 10) + 1 : 1
|
|
86
|
+
})()
|
|
87
|
+
// Bounded retry: a competitor can win a name at most once per its own enqueue.
|
|
88
|
+
for (let attempt = 0; attempt < 1000; attempt++, seq++) {
|
|
89
|
+
const target = join(dir, String(seq).padStart(SEQ_PAD, '0'))
|
|
90
|
+
try {
|
|
91
|
+
linkSync(tmp, target) // atomic exclusive-create (EEXIST when taken)
|
|
92
|
+
return listSeqs(dir).length
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`ephemeral queue enqueue: could not claim a seq for ${identity} after 1000 attempts`)
|
|
98
|
+
} finally {
|
|
99
|
+
try {
|
|
100
|
+
unlinkSync(tmp)
|
|
101
|
+
} catch {
|
|
102
|
+
/* already gone */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the HEAD of the FIFO without removing it (retry semantics: the item is
|
|
109
|
+
* removed only after the wake went READY — removeEphemeralTask). null on empty.
|
|
110
|
+
* A corrupt head (unparseable JSON) is dropped with its slot — a poison task
|
|
111
|
+
* must not wedge the whole queue — and the next item (if any) is returned.
|
|
112
|
+
*/
|
|
113
|
+
export function peekEphemeralTask(cfg: LifecycleConfig, identity: string): PeekedQueueItem | null {
|
|
114
|
+
const dir = ephemeralQueueDir(cfg, identity)
|
|
115
|
+
for (const seq of listSeqs(dir)) {
|
|
116
|
+
const path = join(dir, seq)
|
|
117
|
+
try {
|
|
118
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
119
|
+
if (typeof raw.task === 'string' && raw.task.length > 0) {
|
|
120
|
+
return { seq, task: raw.task, topic: typeof raw.topic === 'string' ? raw.topic : undefined }
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
/* unreadable/corrupt → drop the slot below */
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
rmSync(path, { force: true }) // poison/corrupt item: drop, do not wedge the queue
|
|
127
|
+
} catch {
|
|
128
|
+
return null // cannot even drop it — give up this round, retry next tick
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Remove a consumed item (after its wake went READY). Idempotent. */
|
|
135
|
+
export function removeEphemeralTask(cfg: LifecycleConfig, identity: string, seq: string): void {
|
|
136
|
+
try {
|
|
137
|
+
rmSync(join(ephemeralQueueDir(cfg, identity), seq), { force: true })
|
|
138
|
+
} catch {
|
|
139
|
+
/* already gone */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Identities with a non-empty queue (the supervise-tick drain scan; also the
|
|
144
|
+
* drain-on-start surface — the queue is durable across daemon restarts). */
|
|
145
|
+
export function listQueuedIdentities(cfg: LifecycleConfig): string[] {
|
|
146
|
+
let entries: string[]
|
|
147
|
+
try {
|
|
148
|
+
entries = readdirSync(cfg.stateDir)
|
|
149
|
+
} catch {
|
|
150
|
+
return []
|
|
151
|
+
}
|
|
152
|
+
const out: string[] = []
|
|
153
|
+
for (const name of entries) {
|
|
154
|
+
if (!name.endsWith('.queue')) continue
|
|
155
|
+
const identity = name.slice(0, -'.queue'.length)
|
|
156
|
+
if (listSeqs(join(cfg.stateDir, name)).length > 0) out.push(identity)
|
|
157
|
+
}
|
|
158
|
+
return out.sort()
|
|
159
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// onboardMemoryProvider — the host-phase memory-slot step (контракт «Слот
|
|
2
|
+
// памяти»). Default-YES, report-only for the exit code; the PROVIDER writes the
|
|
3
|
+
// slot declaration (the step verifies it did). All writes under a temp
|
|
4
|
+
// IAPEER_ROOT — never the live host.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { coreKnownInitArgs, onboardMemoryProvider, DEFAULT_MEMORY_PACKAGE } from './memory.ts'
|
|
11
|
+
import { memoryProviderPath } from '../status/index.ts'
|
|
12
|
+
|
|
13
|
+
const dirs: string[] = []
|
|
14
|
+
function mkTmp(): string {
|
|
15
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-memstep-'))
|
|
16
|
+
dirs.push(d)
|
|
17
|
+
return d
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function envFor(root: string): NodeJS.ProcessEnv {
|
|
24
|
+
return { IAPEER_ROOT: root } as NodeJS.ProcessEnv
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SLOT = {
|
|
28
|
+
provider: 'iapeer-memory',
|
|
29
|
+
package: DEFAULT_MEMORY_PACKAGE,
|
|
30
|
+
version: '0.1.0',
|
|
31
|
+
registeredAt: '2026-06-10T00:00:00Z',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function registry(root: string, naturals: string[]): void {
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(root, 'peers-profiles.json'),
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
version: 2,
|
|
39
|
+
peers: [
|
|
40
|
+
...naturals.map(p => ({ personality: p, runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'natural', cwd: `/tmp/${p}` })),
|
|
41
|
+
{ personality: 'bot', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/bot' },
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('coreKnownInitArgs (the EXHAUSTIVE v1 passthrough list)', () => {
|
|
48
|
+
test('exactly one natural peer → --human <personality>', () => {
|
|
49
|
+
const root = mkTmp()
|
|
50
|
+
registry(root, ['arthur'])
|
|
51
|
+
expect(coreKnownInitArgs(envFor(root))).toEqual(['--human', 'arthur'])
|
|
52
|
+
})
|
|
53
|
+
test('zero or many naturals → pass NOTHING (the provider asks itself)', () => {
|
|
54
|
+
const none = mkTmp()
|
|
55
|
+
registry(none, [])
|
|
56
|
+
expect(coreKnownInitArgs(envFor(none))).toEqual([])
|
|
57
|
+
const many = mkTmp()
|
|
58
|
+
registry(many, ['arthur', 'maria'])
|
|
59
|
+
expect(coreKnownInitArgs(envFor(many))).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
test('legacy intelligence "human" normalizes to natural (read-compat)', () => {
|
|
62
|
+
const root = mkTmp()
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(root, 'peers-profiles.json'),
|
|
65
|
+
JSON.stringify({ version: 2, peers: [{ personality: 'arthur', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'human', cwd: '/tmp/a' }] }),
|
|
66
|
+
)
|
|
67
|
+
expect(coreKnownInitArgs(envFor(root))).toEqual(['--human', 'arthur'])
|
|
68
|
+
})
|
|
69
|
+
test('no registry at all → [] (never throws)', () => {
|
|
70
|
+
expect(coreKnownInitArgs(envFor(mkTmp()))).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('onboardMemoryProvider', () => {
|
|
75
|
+
test('--no-memory → skipped-flag, init never invoked', async () => {
|
|
76
|
+
const root = mkTmp()
|
|
77
|
+
let invoked = 0
|
|
78
|
+
const r = await onboardMemoryProvider({
|
|
79
|
+
skip: true,
|
|
80
|
+
env: envFor(root),
|
|
81
|
+
runInit: () => ((invoked++), { status: 0, unavailable: false }),
|
|
82
|
+
})
|
|
83
|
+
expect(r.state).toBe('skipped-flag')
|
|
84
|
+
expect(invoked).toBe(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('same package already in the slot → already (idempotent no-op, no init call)', async () => {
|
|
88
|
+
const root = mkTmp()
|
|
89
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify(SLOT))
|
|
90
|
+
let invoked = 0
|
|
91
|
+
const r = await onboardMemoryProvider({
|
|
92
|
+
env: envFor(root),
|
|
93
|
+
runInit: () => ((invoked++), { status: 0, unavailable: false }),
|
|
94
|
+
})
|
|
95
|
+
expect(r.state).toBe('already')
|
|
96
|
+
expect(r.provider?.provider).toBe('iapeer-memory')
|
|
97
|
+
expect(invoked).toBe(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('slot occupied by ANOTHER provider → refused-foreign (never silent overwrite)', async () => {
|
|
101
|
+
const root = mkTmp()
|
|
102
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify({ ...SLOT, provider: 'other-mem', package: '@x/other' }))
|
|
103
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 0, unavailable: false }) })
|
|
104
|
+
expect(r.state).toBe('refused-foreign')
|
|
105
|
+
expect(r.detail).toMatch(/occupied .*other-mem.*uninstall/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('dry-run → reports the exact would-be command incl. --human passthrough', async () => {
|
|
109
|
+
const root = mkTmp()
|
|
110
|
+
registry(root, ['arthur'])
|
|
111
|
+
const r = await onboardMemoryProvider({ dryRun: true, env: envFor(root) })
|
|
112
|
+
expect(r.state).toBe('dry-run')
|
|
113
|
+
expect(r.detail).toBe(`would run: npx -y ${DEFAULT_MEMORY_PACKAGE} init --human arthur`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('package unavailable → skipped-unavailable (SOFT skip — release order never blocks onboard)', async () => {
|
|
117
|
+
const root = mkTmp()
|
|
118
|
+
registry(root, [])
|
|
119
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 1, unavailable: true }) })
|
|
120
|
+
expect(r.state).toBe('skipped-unavailable')
|
|
121
|
+
expect(r.detail).toMatch(/not available/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('init ok + provider declared the slot → installed (slot read back)', async () => {
|
|
125
|
+
const root = mkTmp()
|
|
126
|
+
registry(root, ['arthur'])
|
|
127
|
+
const env = envFor(root)
|
|
128
|
+
const calls: string[][] = []
|
|
129
|
+
const r = await onboardMemoryProvider({
|
|
130
|
+
env,
|
|
131
|
+
runInit: (pkg, args) => {
|
|
132
|
+
calls.push([pkg, ...args])
|
|
133
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify(SLOT)) // the PROVIDER writes the slot
|
|
134
|
+
return { status: 0, unavailable: false }
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
expect(r.state).toBe('installed')
|
|
138
|
+
expect(r.provider?.version).toBe('0.1.0')
|
|
139
|
+
expect(calls).toEqual([[DEFAULT_MEMORY_PACKAGE, 'init', '--human', 'arthur']])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('init ok but slot NOT declared → provider-init-failed (its contract duty, surfaced)', async () => {
|
|
143
|
+
const root = mkTmp()
|
|
144
|
+
registry(root, [])
|
|
145
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 0, unavailable: false }) })
|
|
146
|
+
expect(r.state).toBe('provider-init-failed')
|
|
147
|
+
expect(r.detail).toMatch(/did not declare/)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('init non-zero (e.g. non-tty refusal) → provider-init-failed with the exit code', async () => {
|
|
151
|
+
const root = mkTmp()
|
|
152
|
+
registry(root, [])
|
|
153
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 2, unavailable: false }) })
|
|
154
|
+
expect(r.state).toBe('provider-init-failed')
|
|
155
|
+
expect(r.detail).toMatch(/exited 2/)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Memory-slot onboard step (docs/Слот памяти — контракт memory provider.md,
|
|
2
|
+
// agreed with iapeer-memory + boris 10.06). Host-phase, optional, DEFAULT-YES:
|
|
3
|
+
// install the default memory provider package and run ITS OWN init-verb — the
|
|
4
|
+
// provider writes the slot declaration and deploys its surfaces; the core never
|
|
5
|
+
// writes the slot itself. The provider OWNS the install questions (two-mode
|
|
6
|
+
// init): we run it with INHERITED stdio so its tty interactive happens inside
|
|
7
|
+
// the onboard host phase, exactly once. The core passes as flags ONLY facts it
|
|
8
|
+
// owns by its own contracts — v1 list is exactly one: `--human <personality>`
|
|
9
|
+
// when EXACTLY ONE natural peer is registered.
|
|
10
|
+
//
|
|
11
|
+
// Outcome semantics: this step NEVER fails the onboard exit code — an empty
|
|
12
|
+
// slot is a fully valid state regardless of why (skipped / package not yet
|
|
13
|
+
// published / provider refused non-tty). Outcomes are reported, not enforced.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'child_process'
|
|
16
|
+
import { normalizeIntelligenceValue } from '../core/constants.ts'
|
|
17
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
18
|
+
import { readMemoryProvider, type MemoryProvider } from '../status/index.ts'
|
|
19
|
+
|
|
20
|
+
/** The distribution default provider (Артур: memory is a first-class core option). */
|
|
21
|
+
export const DEFAULT_MEMORY_PACKAGE = '@agfpd/iapeer-memory'
|
|
22
|
+
|
|
23
|
+
export interface MemoryOnboardOptions {
|
|
24
|
+
/** --no-memory: skip the step entirely. */
|
|
25
|
+
skip?: boolean
|
|
26
|
+
/** --memory <pkg>: override the provider package (default @agfpd/iapeer-memory). */
|
|
27
|
+
package?: string
|
|
28
|
+
dryRun?: boolean
|
|
29
|
+
env?: NodeJS.ProcessEnv
|
|
30
|
+
/** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init …`
|
|
31
|
+
* with inherited stdio. */
|
|
32
|
+
runInit?: (pkg: string, args: string[], env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MemoryOnboardResult {
|
|
36
|
+
state:
|
|
37
|
+
| 'installed' // provider init succeeded and declared the slot
|
|
38
|
+
| 'already' // slot already occupied by the SAME package — idempotent no-op
|
|
39
|
+
| 'skipped-flag' // --no-memory
|
|
40
|
+
| 'skipped-unavailable' // package not published / no network — soft skip (contract)
|
|
41
|
+
| 'refused-foreign' // slot occupied by ANOTHER provider — explicit refusal
|
|
42
|
+
| 'provider-init-failed' // provider init ran and exited non-zero / declared nothing
|
|
43
|
+
| 'dry-run'
|
|
44
|
+
provider: MemoryProvider | null
|
|
45
|
+
detail?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultRunInit(
|
|
49
|
+
pkg: string,
|
|
50
|
+
args: string[],
|
|
51
|
+
env: NodeJS.ProcessEnv,
|
|
52
|
+
): { status: number | null; unavailable: boolean } {
|
|
53
|
+
// Availability probe FIRST (cheap, side-effect-free): an unpublished package /
|
|
54
|
+
// no network → soft-skip per the contract (the provider's release order must
|
|
55
|
+
// never block the core's onboard).
|
|
56
|
+
const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env })
|
|
57
|
+
if (probe.status !== 0) return { status: probe.status, unavailable: true }
|
|
58
|
+
// INHERITED stdio — the provider owns its install questions (tty interactive
|
|
59
|
+
// happens here, once). npx bin-name nuance (cf. the 0.2.9 update грабли): with
|
|
60
|
+
// the provider bin already on PATH npx runs the INSTALLED one — acceptable for
|
|
61
|
+
// an idempotent init (unlike the self-update case where it was a structural bug).
|
|
62
|
+
const r = spawnSync('npx', ['-y', pkg, ...args], { stdio: 'inherit', env })
|
|
63
|
+
return { status: r.status, unavailable: false }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The exhaustive v1 list of core-known facts passed to the provider init:
|
|
67
|
+
* `--human <personality>` iff EXACTLY ONE natural peer is registered (the core
|
|
68
|
+
* has no separate owner-name config — the owner IS the natural peer). */
|
|
69
|
+
export function coreKnownInitArgs(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
70
|
+
try {
|
|
71
|
+
const naturals = readPeersIndex({ env }).peers.filter(
|
|
72
|
+
p => normalizeIntelligenceValue(p.intelligence) === 'natural',
|
|
73
|
+
)
|
|
74
|
+
return naturals.length === 1 ? ['--human', naturals[0]!.personality] : []
|
|
75
|
+
} catch {
|
|
76
|
+
return [] // no registry / unreadable → pass nothing, the provider asks itself
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Promise<MemoryOnboardResult> {
|
|
81
|
+
const env = opts.env ?? process.env
|
|
82
|
+
const pkg = opts.package?.trim() || DEFAULT_MEMORY_PACKAGE
|
|
83
|
+
const existing = readMemoryProvider(env)
|
|
84
|
+
if (opts.skip) return { state: 'skipped-flag', provider: existing }
|
|
85
|
+
if (existing) {
|
|
86
|
+
if (existing.package === pkg) return { state: 'already', provider: existing }
|
|
87
|
+
// Never silently install over an occupied slot (contract: ЗАПРЕЩЕНО).
|
|
88
|
+
return {
|
|
89
|
+
state: 'refused-foreign',
|
|
90
|
+
provider: existing,
|
|
91
|
+
detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const args = ['init', ...coreKnownInitArgs(env)]
|
|
95
|
+
if (opts.dryRun) {
|
|
96
|
+
return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
|
|
97
|
+
}
|
|
98
|
+
const run = opts.runInit ?? defaultRunInit
|
|
99
|
+
const r = run(pkg, args, env)
|
|
100
|
+
if (r.unavailable) {
|
|
101
|
+
return {
|
|
102
|
+
state: 'skipped-unavailable',
|
|
103
|
+
provider: null,
|
|
104
|
+
detail: `${pkg} is not available (not published yet / no network) — install later: npx ${pkg} init`,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (r.status !== 0) {
|
|
108
|
+
return {
|
|
109
|
+
state: 'provider-init-failed',
|
|
110
|
+
provider: readMemoryProvider(env),
|
|
111
|
+
detail: `provider init exited ${r.status ?? 'null'}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// The PROVIDER declares the slot; verify it actually did (its contract duty).
|
|
115
|
+
const after = readMemoryProvider(env)
|
|
116
|
+
if (!after) {
|
|
117
|
+
return {
|
|
118
|
+
state: 'provider-init-failed',
|
|
119
|
+
provider: null,
|
|
120
|
+
detail: 'provider init succeeded but did not declare the slot (memory-provider.json missing)',
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { state: 'installed', provider: after }
|
|
124
|
+
}
|
package/src/provision/index.ts
CHANGED
|
@@ -91,6 +91,27 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
91
91
|
runtimeBin,
|
|
92
92
|
warn: opts.warn,
|
|
93
93
|
})
|
|
94
|
+
// wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
|
|
95
|
+
// lives in the hand-editable local profile, provision just surfaces the mismatch):
|
|
96
|
+
// • + interfaces.telegram: ephemeral WINS in resolveWakeMode (explicit policy
|
|
97
|
+
// beats the inferred human type), so a human dialogue channel would die-after-
|
|
98
|
+
// reply and never resume — almost certainly a config mistake.
|
|
99
|
+
// • + infra (always-on) runtime: launchd KeepAlive owns the session (H4 read-only)
|
|
100
|
+
// — the daemon never wakes/reaps it, so the ephemeral policy is INERT.
|
|
101
|
+
if (profile.wake_policy === 'ephemeral') {
|
|
102
|
+
if (profile.interfaces?.telegram != null) {
|
|
103
|
+
opts.warn?.(
|
|
104
|
+
`peer "${profile.personality}" declares BOTH wake_policy:"ephemeral" AND interfaces.telegram — ` +
|
|
105
|
+
`ephemeral wins (always-fresh, die-after-reply); a human telegram dialogue should not be ephemeral`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
if (isInfraRuntime(opts.runtime)) {
|
|
109
|
+
opts.warn?.(
|
|
110
|
+
`peer "${profile.personality}" declares wake_policy:"ephemeral" on always-on infra runtime ` +
|
|
111
|
+
`"${opts.runtime}" — launchd owns the session (H4), the daemon never wakes/reaps it, the policy is inert`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
94
115
|
// 2) registry entry — without it the daemon does not see the peer (tool-list,
|
|
95
116
|
// caller resolution, wake-on-miss findPeer all read peers-profiles.json).
|
|
96
117
|
await upsertPeer(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// IAPEER_LAUNCHAGENTS_DIR temp dirs so the suite never touches the live fleet.
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
6
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
7
7
|
import { tmpdir } from 'os'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { provisionPeer } from './index.ts'
|
|
@@ -102,3 +102,59 @@ describe('provisionPeer', () => {
|
|
|
102
102
|
expect(findPeer(readPeersIndex({ env }), 'timer')?.cwd).toBe(cwd)
|
|
103
103
|
})
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// wake_policy:"ephemeral" provision warnings (M2 edge cases — warn, not refuse)
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe('provisionPeer ephemeral warnings', () => {
|
|
111
|
+
test('ephemeral + interfaces.telegram → WARN (ephemeral wins; human dialogue should not die-after-reply)', async () => {
|
|
112
|
+
const root = mkTmp()
|
|
113
|
+
const env = envFor(root)
|
|
114
|
+
const cwd = join(root, 'wtg')
|
|
115
|
+
// pre-existing profile (provision returns it unchanged) carrying the bad combo
|
|
116
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
117
|
+
writeFileSync(
|
|
118
|
+
peerProfilePath(cwd),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
personality: 'wtg',
|
|
121
|
+
runtime: 'claude',
|
|
122
|
+
intelligence: 'natural',
|
|
123
|
+
interfaces: { telegram: { user_id: 1 } },
|
|
124
|
+
wake_policy: 'ephemeral',
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
const warns: string[] = []
|
|
128
|
+
await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
129
|
+
expect(warns.some(w => /ephemeral/.test(w) && /telegram/.test(w))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('plain ephemeral worker (claude, no telegram) → NO ephemeral warn', async () => {
|
|
133
|
+
const root = mkTmp()
|
|
134
|
+
const env = envFor(root)
|
|
135
|
+
const cwd = join(root, 'weph')
|
|
136
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
137
|
+
writeFileSync(
|
|
138
|
+
peerProfilePath(cwd),
|
|
139
|
+
JSON.stringify({ personality: 'weph', runtime: 'claude', wake_policy: 'ephemeral' }),
|
|
140
|
+
)
|
|
141
|
+
const warns: string[] = []
|
|
142
|
+
await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
143
|
+
expect(warns.filter(w => /ephemeral/.test(w))).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('ephemeral + always-on infra runtime → WARN (H4: launchd owns it, the policy is inert)', async () => {
|
|
147
|
+
const root = mkTmp()
|
|
148
|
+
const { dir: bindir } = fakeBinDir()
|
|
149
|
+
const env = envFor(root, bindir)
|
|
150
|
+
const cwd = join(root, 'winf')
|
|
151
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
152
|
+
writeFileSync(
|
|
153
|
+
peerProfilePath(cwd),
|
|
154
|
+
JSON.stringify({ personality: 'winf', runtime: 'notifier', wake_policy: 'ephemeral' }),
|
|
155
|
+
)
|
|
156
|
+
const warns: string[] = []
|
|
157
|
+
await provisionPeer({ cwd, runtime: 'notifier', env, warn: m => warns.push(m) })
|
|
158
|
+
expect(warns.some(w => /ephemeral/.test(w) && /inert|launchd/i.test(w))).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Status — the host-snapshot verb: installed-binary version, daemon health, and
|
|
2
|
+
// the MEMORY SLOT line (docs/Слот памяти — контракт memory provider.md). The slot
|
|
3
|
+
// is DECLARATIVE: a root file `~/.iapeer/memory-provider.json` written (atomically)
|
|
4
|
+
// by the PROVIDER's own init/uninstall — the core only ever READS it. An absent or
|
|
5
|
+
// unreadable file is the EMPTY slot — a fully valid state (bare core), never an
|
|
6
|
+
// error (fail-open). The core never acts on heartbeat staleness — it only REPORTS
|
|
7
|
+
// it (healing the provider's daemon is the provider's job, their ADR-010).
|
|
8
|
+
|
|
9
|
+
import { readFileSync, statSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { IAPEER_VERSION } from '../core/version.ts'
|
|
12
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
13
|
+
import { daemonDiscoveryPath } from '../daemon/index.ts'
|
|
14
|
+
import { waitForDaemonHealthy } from '../update/index.ts'
|
|
15
|
+
|
|
16
|
+
/** The slot-declaration filename in the storage root (next to peers-profiles.json). */
|
|
17
|
+
export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
|
|
18
|
+
|
|
19
|
+
export interface MemoryProvider {
|
|
20
|
+
/** Provider name occupying the slot (e.g. "iapeer-memory"). */
|
|
21
|
+
provider: string
|
|
22
|
+
/** npm package of the provider (e.g. "@agfpd/iapeer-memory"). */
|
|
23
|
+
package: string
|
|
24
|
+
version: string
|
|
25
|
+
registeredAt: string
|
|
26
|
+
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
27
|
+
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
28
|
+
heartbeat?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
return join(resolveGlobalRoot(env), MEMORY_PROVIDER_FILE)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the memory-slot declaration. null = EMPTY slot (absent / unreadable /
|
|
37
|
+
* schema-invalid file) — a valid state, so this NEVER throws (fail-open to bare).
|
|
38
|
+
*/
|
|
39
|
+
export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): MemoryProvider | null {
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(readFileSync(memoryProviderPath(env), 'utf8')) as unknown
|
|
42
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
43
|
+
const o = raw as Record<string, unknown>
|
|
44
|
+
if (typeof o.provider !== 'string' || !o.provider.trim()) return null
|
|
45
|
+
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
46
|
+
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
47
|
+
return {
|
|
48
|
+
provider: o.provider.trim(),
|
|
49
|
+
package: o.package.trim(),
|
|
50
|
+
version: o.version.trim(),
|
|
51
|
+
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
52
|
+
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return null // empty slot — bare core is valid
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Age (s) of the provider's heartbeat file, or null (none declared / unreadable). */
|
|
60
|
+
export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.now()): number | null {
|
|
61
|
+
if (!provider.heartbeat) return null
|
|
62
|
+
try {
|
|
63
|
+
return Math.max(0, Math.floor((nowMs - statSync(provider.heartbeat).mtimeMs) / 1000))
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface HostStatus {
|
|
70
|
+
version: string
|
|
71
|
+
daemon: { healthy: boolean; url: string | null; sock: string | null }
|
|
72
|
+
memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface HostStatusOptions {
|
|
76
|
+
env?: NodeJS.ProcessEnv
|
|
77
|
+
/** Injectable daemon probe (tests). Default: the real socket probe. */
|
|
78
|
+
probe?: () => Promise<boolean>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Assemble the host snapshot: baked version + daemon probe + memory slot. */
|
|
82
|
+
export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStatus> {
|
|
83
|
+
const env = opts.env ?? process.env
|
|
84
|
+
let url: string | null = null
|
|
85
|
+
let sock: string | null = null
|
|
86
|
+
try {
|
|
87
|
+
const d = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8')) as { tcp?: string | null; sock?: string | null }
|
|
88
|
+
url = d.tcp ?? null
|
|
89
|
+
sock = d.sock ?? null
|
|
90
|
+
} catch {
|
|
91
|
+
/* no discovery file → daemon down or never started; addresses stay null */
|
|
92
|
+
}
|
|
93
|
+
const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
|
|
94
|
+
const provider = readMemoryProvider(env)
|
|
95
|
+
return {
|
|
96
|
+
version: IAPEER_VERSION,
|
|
97
|
+
daemon: { healthy: health.healthy, url, sock },
|
|
98
|
+
memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Render the human status block (one fact per line; `memory:` per the slot contract). */
|
|
103
|
+
export function formatHostStatus(s: HostStatus): string {
|
|
104
|
+
const daemon = s.daemon.healthy
|
|
105
|
+
? `healthy${s.daemon.url ? ` @ ${s.daemon.url}` : ''}${s.daemon.sock ? ` + ${s.daemon.sock}` : ''}`
|
|
106
|
+
: 'NOT healthy (socket not accepting connections)'
|
|
107
|
+
// Heartbeat interpretation (slot contract, provider semantics agreed 10.06):
|
|
108
|
+
// file REMOVED on graceful shutdown → declared-but-absent = daemon not running;
|
|
109
|
+
// present = age shown. The core only REPORTS — staleness healing is the
|
|
110
|
+
// provider's own job (its verify/repair), never the core's.
|
|
111
|
+
let hb = ''
|
|
112
|
+
if (s.memory.provider?.heartbeat) {
|
|
113
|
+
hb = s.memory.heartbeatAgeSecs !== null
|
|
114
|
+
? ` (heartbeat ${s.memory.heartbeatAgeSecs}s ago)`
|
|
115
|
+
: ' (daemon not running — no heartbeat file)'
|
|
116
|
+
}
|
|
117
|
+
const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
|
|
118
|
+
return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n`
|
|
119
|
+
}
|