@agfpd/iapeer 0.2.11 → 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/index.ts +3 -0
- package/src/onboard/memory.test.ts +157 -0
- package/src/onboard/memory.ts +124 -0
- package/src/status/index.ts +119 -0
- package/src/status/status.test.ts +125 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -352,7 +352,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
352
352
|
rollback revert to the previous binary (.prev) + restart the daemon
|
|
353
353
|
version | --version | -v print the installed binary's version
|
|
354
354
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
355
|
-
onboard [--dry-run] [--infra <csv>]
|
|
355
|
+
onboard [--dry-run] [--infra <csv>] [--no-memory] [--memory <pkg>] register the agfpd marketplace (+ infra runtimes; + default memory provider, default YES)
|
|
356
|
+
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
356
357
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
357
358
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
358
359
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
@@ -406,8 +407,30 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
406
407
|
} else if (infra.length) {
|
|
407
408
|
out(`onboard --dry-run: would onboard infra runtimes: ${infra.join(', ')}\n`)
|
|
408
409
|
}
|
|
410
|
+
// Memory slot (контракт «Слот памяти», 10.06): optional DEFAULT-YES install of
|
|
411
|
+
// the default provider; its own init runs with INHERITED stdio (the provider
|
|
412
|
+
// owns the install questions). The step is REPORT-ONLY for the exit code — an
|
|
413
|
+
// empty slot is a valid state regardless of why.
|
|
414
|
+
const { onboardMemoryProvider } = await import('../onboard/memory.ts')
|
|
415
|
+
const mem = await onboardMemoryProvider({
|
|
416
|
+
skip: flags['no-memory'] === true,
|
|
417
|
+
package: typeof flags.memory === 'string' ? flags.memory : undefined,
|
|
418
|
+
dryRun: flags['dry-run'] === true,
|
|
419
|
+
env,
|
|
420
|
+
})
|
|
421
|
+
const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
422
|
+
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
409
423
|
return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
410
424
|
}
|
|
425
|
+
case 'status': {
|
|
426
|
+
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
427
|
+
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
|
428
|
+
// health gate); an EMPTY memory slot is a valid state and never fails.
|
|
429
|
+
const { hostStatus, formatHostStatus } = await import('../status/index.ts')
|
|
430
|
+
const s = await hostStatus({ env })
|
|
431
|
+
out(formatHostStatus(s))
|
|
432
|
+
return s.daemon.healthy ? 0 : 1
|
|
433
|
+
}
|
|
411
434
|
case 'install-runtime': {
|
|
412
435
|
// §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
|
|
413
436
|
// the built-in runtime→package registry, or --package; self-deploys bin +
|
package/src/index.ts
CHANGED
|
@@ -25,5 +25,8 @@ export * from './install/index.ts'
|
|
|
25
25
|
export * from './update/index.ts'
|
|
26
26
|
// Onboard — the host-phase (idempotent marketplace registration in claude + codex).
|
|
27
27
|
export * from './onboard/index.ts'
|
|
28
|
+
// Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
|
|
29
|
+
export * from './status/index.ts'
|
|
30
|
+
export * from './onboard/memory.ts'
|
|
28
31
|
export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
|
|
29
32
|
export type { GatherPromptOptions } from './launch/composeSystemPrompt.ts'
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
})
|