@agfpd/iapeer 0.2.27 → 0.2.29
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/cli.test.ts +54 -0
- package/src/cli/index.ts +64 -2
- package/src/enable/memoryPlugin.test.ts +94 -0
- package/src/enable/memoryPlugin.ts +78 -1
- package/src/enable/provisionCommand.test.ts +107 -0
- package/src/enable/provisionCommand.ts +112 -0
- package/src/provision/index.ts +51 -5
- package/src/provision/provision.test.ts +63 -0
- package/src/status/index.ts +39 -1
- package/src/status/status.test.ts +27 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -40,6 +40,60 @@ async function register(personality: string, runtime = 'claude', intelligence: '
|
|
|
40
40
|
await upsertPeer({ personality, runtime, cwd: `/tmp/${personality}`, intelligence }, { rootDir: root })
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// ─── v1.2 provision-инверсия at the CLI joints (memory-plugin printer + remove) ───
|
|
44
|
+
|
|
45
|
+
describe('memory-plugin / remove with a v1.2 provision slot', () => {
|
|
46
|
+
function declareV12Slot(): string {
|
|
47
|
+
const journal = join(root, 'journal.txt')
|
|
48
|
+
const script = join(root, 'fake-provider.sh')
|
|
49
|
+
writeFileSync(script, `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`, { mode: 0o755 })
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(root, 'memory-provider.json'),
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
provider: 'fake-mem',
|
|
54
|
+
package: '@x/fake',
|
|
55
|
+
version: '0.0.1',
|
|
56
|
+
registeredAt: 'x',
|
|
57
|
+
provision: { command: script, args: ['provision-peer', '--occasion', '{occasion}'] },
|
|
58
|
+
unprovision: { command: script, args: ['unprovision-peer', '--cwd', '{cwd}', '--occasion', '{occasion}'] },
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
return journal
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('memory-plugin on --peer: printer survives plugin=null (v1.2 path), exit 0 (live-caught 11.06 smoke)', async () => {
|
|
65
|
+
declareV12Slot()
|
|
66
|
+
await register('alpha')
|
|
67
|
+
let captured = ''
|
|
68
|
+
const origWrite = process.stdout.write
|
|
69
|
+
process.stdout.write = ((s: string | Uint8Array) => {
|
|
70
|
+
captured += typeof s === 'string' ? s : Buffer.from(s).toString('utf8')
|
|
71
|
+
return true
|
|
72
|
+
}) as typeof process.stdout.write
|
|
73
|
+
try {
|
|
74
|
+
const code = await runCli(['memory-plugin', 'on', '--peer', 'alpha'], env())
|
|
75
|
+
expect(code).toBe(0)
|
|
76
|
+
expect(captured).toContain('provision: provider command')
|
|
77
|
+
expect(captured).toContain('alpha (claude): ok')
|
|
78
|
+
} finally {
|
|
79
|
+
process.stdout.write = origWrite
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('remove: unprovision runs with occasion=remove BEFORE the purge, outcome reported', async () => {
|
|
84
|
+
const journal = declareV12Slot()
|
|
85
|
+
await register('beta')
|
|
86
|
+
const o = await removePeerCli('beta', { env: env() })
|
|
87
|
+
expect(o.action).toBe('removed')
|
|
88
|
+
expect(o.unprovision).toEqual(['claude:ok'])
|
|
89
|
+
const { readFileSync } = await import('fs')
|
|
90
|
+
const j = readFileSync(journal, 'utf8')
|
|
91
|
+
expect(j).toContain('unprovision-peer')
|
|
92
|
+
expect(j).toContain('--cwd\n/tmp/beta')
|
|
93
|
+
expect(j).toContain('remove')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
43
97
|
describe('list', () => {
|
|
44
98
|
test('lists registered peers with per-runtime liveness (asleep / stopped)', async () => {
|
|
45
99
|
await register('alpha')
|
package/src/cli/index.ts
CHANGED
|
@@ -48,6 +48,9 @@ import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
|
48
48
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
49
49
|
import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
50
50
|
import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
|
|
51
|
+
import { readMemoryProvider } from '../status/index.ts'
|
|
52
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
53
|
+
import { pluginLogsDir } from '../storage/index.ts'
|
|
51
54
|
|
|
52
55
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
56
|
// list — registry + per-runtime liveness (contract Примитивы §list)
|
|
@@ -274,6 +277,9 @@ export interface RemoveOutcome {
|
|
|
274
277
|
* deliberately keeps the folder — user data is never deleted by a registry reap
|
|
275
278
|
* (boris's finding 10.06: say so in the output instead of leaving silent orphans). */
|
|
276
279
|
cwd?: string
|
|
280
|
+
/** v1.2: per-runtime unprovision outcomes (`<rt>:<state>`), present when the
|
|
281
|
+
* slot declares an unprovision command (occasion=remove ran before the purge). */
|
|
282
|
+
unprovision?: string[]
|
|
277
283
|
/** Identity-keyed lifecycle artifacts purged with the record (state/lifecycle/
|
|
278
284
|
* `<identity>.*` per runtime). Without this purge a NEWBORN peer reusing the
|
|
279
285
|
* personality inherits the dead namesake's parking (live defect, boris 10.06:
|
|
@@ -309,13 +315,59 @@ export async function removePeerCli(
|
|
|
309
315
|
}
|
|
310
316
|
}
|
|
311
317
|
await removePeer(personality, { env })
|
|
318
|
+
// v1.2 UNPROVISION joint (контракт §Provision провайдера): a provision-declaring
|
|
319
|
+
// slot gets its unprovision command per agentic runtime with occasion=remove —
|
|
320
|
+
// BEFORE purgeIdentityState, so the provider sees the peer's last consistent
|
|
321
|
+
// state while unwinding its surfaces. Best-effort: a provider hiccup must not
|
|
322
|
+
// block the reap (the outcome line says what happened; repair is the provider's
|
|
323
|
+
// verify sweep).
|
|
324
|
+
const unprovisionOutcomes: string[] = []
|
|
325
|
+
try {
|
|
326
|
+
const slot = readMemoryProvider(env)
|
|
327
|
+
if (slot?.unprovision) {
|
|
328
|
+
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
329
|
+
const agentic = peer.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
330
|
+
for (const rt of agentic) {
|
|
331
|
+
const o = runProvisionCommand({
|
|
332
|
+
block: slot.unprovision,
|
|
333
|
+
cwd: peer.cwd,
|
|
334
|
+
runtime: rt,
|
|
335
|
+
personality,
|
|
336
|
+
occasion: 'remove',
|
|
337
|
+
env,
|
|
338
|
+
})
|
|
339
|
+
appendLifecycleEvent(
|
|
340
|
+
pluginLogsDir('iapeer', { env }),
|
|
341
|
+
{
|
|
342
|
+
ev: 'memory-provision',
|
|
343
|
+
identity: `${rt}-${personality}`,
|
|
344
|
+
occasion: 'remove',
|
|
345
|
+
state: o.state,
|
|
346
|
+
exit: o.exitCode ?? undefined,
|
|
347
|
+
ms: o.durationMs,
|
|
348
|
+
detail: o.detail,
|
|
349
|
+
},
|
|
350
|
+
{ env },
|
|
351
|
+
)
|
|
352
|
+
unprovisionOutcomes.push(`${rt}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch (e) {
|
|
356
|
+
unprovisionOutcomes.push(`failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
357
|
+
}
|
|
312
358
|
// Purge identity-keyed lifecycle state WITH the record (per runtime): stale
|
|
313
359
|
// .stopped/.idle-reaped/... must never outlive the peer and ambush a future
|
|
314
360
|
// namesake (purgeIdentityState doc). After the registry write, so a failed
|
|
315
361
|
// remove never half-purges a still-registered peer.
|
|
316
362
|
const cfg = loadLifecycleConfig(env)
|
|
317
363
|
const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
|
|
318
|
-
return {
|
|
364
|
+
return {
|
|
365
|
+
personality,
|
|
366
|
+
action: 'removed',
|
|
367
|
+
cwd: peer.cwd,
|
|
368
|
+
purgedState,
|
|
369
|
+
...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
|
|
370
|
+
}
|
|
319
371
|
}
|
|
320
372
|
|
|
321
373
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -616,7 +668,13 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
616
668
|
errOut(`memory-plugin: ${r.error}\n`)
|
|
617
669
|
return 1
|
|
618
670
|
}
|
|
619
|
-
|
|
671
|
+
// v1.2: a provision-declaring slot routes through the provider's commands —
|
|
672
|
+
// r.plugin is null then (there is no marketplace plugin in play to print).
|
|
673
|
+
out(
|
|
674
|
+
r.plugin
|
|
675
|
+
? `plugin: ${r.plugin.name}@${r.plugin.marketplace} (provider-declared)\n`
|
|
676
|
+
: `provision: provider command (declaration v1.2, occasion=${state === 'on' ? 'sweep-on' : flags.all === true ? 'off-all' : 'off-peer'})\n`,
|
|
677
|
+
)
|
|
620
678
|
for (const o of r.outcomes) {
|
|
621
679
|
out(`${o.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
622
680
|
}
|
|
@@ -764,6 +822,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
764
822
|
const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
|
|
765
823
|
if (o.action === 'removed') {
|
|
766
824
|
out(`removed "${o.personality}" from the registry\n`)
|
|
825
|
+
// v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
|
|
826
|
+
if (o.unprovision?.length) {
|
|
827
|
+
out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
|
|
828
|
+
}
|
|
767
829
|
// Stale identity-keyed markers must die with the record (boris 10.06: a
|
|
768
830
|
// namesake newborn inherited a dead peer's .stopped → refused to wake).
|
|
769
831
|
if (o.purgedState?.length) {
|
|
@@ -101,3 +101,97 @@ describe('memory-plugin gating (slot-derived)', () => {
|
|
|
101
101
|
expect(codexOutcomes.filter(x => x.state === 'already').length).toBe(1)
|
|
102
102
|
})
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
// ─── v1.2 provision-инверсия (контракт §Provision провайдера, ADR-009) ───────
|
|
106
|
+
|
|
107
|
+
import { chmodSync, readFileSync, existsSync } from 'fs'
|
|
108
|
+
|
|
109
|
+
/** Declare a v1.2 slot whose provision/unprovision are fake journaling scripts. */
|
|
110
|
+
function declareProvisionSlot(o: { provision?: boolean; unprovision?: boolean; alsoPlugin?: boolean; failing?: boolean }): {
|
|
111
|
+
journal: string
|
|
112
|
+
} {
|
|
113
|
+
const journal = join(root, 'journal.txt')
|
|
114
|
+
const script = join(root, 'fake-provider.sh')
|
|
115
|
+
writeFileSync(
|
|
116
|
+
script,
|
|
117
|
+
o.failing ? `#!/bin/sh\necho provider broken >&2\nexit 7\n` : `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`,
|
|
118
|
+
)
|
|
119
|
+
chmodSync(script, 0o755)
|
|
120
|
+
const block = (occ: string) => ({
|
|
121
|
+
command: script,
|
|
122
|
+
args: [occ, '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
|
|
123
|
+
})
|
|
124
|
+
writeFileSync(
|
|
125
|
+
memoryProviderPath(env()),
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
...DECLARATION,
|
|
128
|
+
...(o.alsoPlugin ? { plugin: PLUGIN } : {}),
|
|
129
|
+
...(o.provision !== false ? { provision: block('provision-peer') } : {}),
|
|
130
|
+
...(o.unprovision !== false ? { unprovision: block('unprovision-peer') } : {}),
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
return { journal }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe('memory-plugin v1.2 provision routing', () => {
|
|
137
|
+
test('on --peer: provision command invoked per agentic runtime with occasion=sweep-on, substituted argv', async () => {
|
|
138
|
+
const { journal } = declareProvisionSlot({})
|
|
139
|
+
await upsertPeer({ personality: 'alpha', runtime: 'claude', cwd: join(root, 'alpha'), intelligence: 'artificial' }, { rootDir: root })
|
|
140
|
+
const r = memoryPluginApply('on', { peer: 'alpha', env: env() })
|
|
141
|
+
expect(r.ok).toBe(true)
|
|
142
|
+
expect(r.outcomes).toEqual([{ personality: 'alpha', runtime: 'claude', state: 'ok', detail: undefined }])
|
|
143
|
+
const j = readFileSync(journal, 'utf8').split('\n')
|
|
144
|
+
expect(j[0]).toBe('provision-peer')
|
|
145
|
+
expect(j.slice(1, 9)).toEqual(['--cwd', join(root, 'alpha'), '--runtime', 'claude', '--personality', 'alpha', '--occasion', 'sweep-on'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('PRECEDENCE: slot with BOTH blocks routes to provision; the plugin path is never touched', async () => {
|
|
149
|
+
const { journal } = declareProvisionSlot({ alsoPlugin: true })
|
|
150
|
+
await upsertPeer({ personality: 'beta', runtime: 'claude', cwd: join(root, 'beta'), intelligence: 'artificial' }, { rootDir: root })
|
|
151
|
+
const r = memoryPluginApply('on', { peer: 'beta', env: env() })
|
|
152
|
+
expect(r.ok).toBe(true)
|
|
153
|
+
// plugin path on this sandbox would yield 'runtime-missing' (bins at /nonexistent);
|
|
154
|
+
// 'ok' from the journaling script proves the provision command ran instead.
|
|
155
|
+
expect(r.outcomes[0]?.state).toBe('ok')
|
|
156
|
+
expect(readFileSync(journal, 'utf8')).toContain('provision-peer')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('provision failure → ok=false with detail, NO fallback to the plugin path', async () => {
|
|
160
|
+
declareProvisionSlot({ failing: true, alsoPlugin: true })
|
|
161
|
+
await upsertPeer({ personality: 'gamma', runtime: 'claude', cwd: join(root, 'gamma'), intelligence: 'artificial' }, { rootDir: root })
|
|
162
|
+
const r = memoryPluginApply('on', { peer: 'gamma', env: env() })
|
|
163
|
+
expect(r.ok).toBe(false)
|
|
164
|
+
expect(r.outcomes[0]?.state).toBe('failed')
|
|
165
|
+
expect(r.outcomes[0]?.detail).toContain('provider broken')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('off --peer → unprovision occasion=off-peer; off --all → off-all; codex has NO host-global special-casing', async () => {
|
|
169
|
+
const { journal } = declareProvisionSlot({})
|
|
170
|
+
await upsertPeer({ personality: 'cx', runtime: 'codex', cwd: join(root, 'cx'), intelligence: 'artificial' }, { rootDir: root })
|
|
171
|
+
const r1 = memoryPluginApply('off', { peer: 'cx', env: env() })
|
|
172
|
+
expect(r1.ok).toBe(true)
|
|
173
|
+
expect(r1.outcomes[0]).toEqual({ personality: 'cx', runtime: 'codex', state: 'ok', detail: undefined })
|
|
174
|
+
const r2 = memoryPluginApply('off', { all: true, env: env() })
|
|
175
|
+
expect(r2.ok).toBe(true)
|
|
176
|
+
const j = readFileSync(journal, 'utf8')
|
|
177
|
+
expect(j).toContain('off-peer')
|
|
178
|
+
expect(j).toContain('off-all')
|
|
179
|
+
expect(j).toContain('unprovision-peer')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('missing direction block → explicit refusal (no silent skip, no plugin fallback)', async () => {
|
|
183
|
+
declareProvisionSlot({ unprovision: false, alsoPlugin: true })
|
|
184
|
+
await upsertPeer({ personality: 'delta', runtime: 'claude', cwd: join(root, 'delta'), intelligence: 'artificial' }, { rootDir: root })
|
|
185
|
+
const r = memoryPluginApply('off', { peer: 'delta', env: env() })
|
|
186
|
+
expect(r.ok).toBe(false)
|
|
187
|
+
expect(r.error).toContain('declares no unprovision command')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('non-agentic peer → explicit skip (provision never invoked)', async () => {
|
|
191
|
+
const { journal } = declareProvisionSlot({})
|
|
192
|
+
await upsertPeer({ personality: 'tim2', runtime: 'notifier', cwd: join(root, 'tim2'), intelligence: 'absent' }, { rootDir: root })
|
|
193
|
+
const r = memoryPluginApply('on', { peer: 'tim2', env: env() })
|
|
194
|
+
expect(r.outcomes[0]?.state).toBe('skipped-no-agentic-runtime')
|
|
195
|
+
expect(existsSync(journal)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
// the declaration — agreed AUTO-removal: a dead provider's plugin actively
|
|
13
13
|
// errors in every session, unlike the native-memory restore asymmetry).
|
|
14
14
|
|
|
15
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
15
16
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
16
|
-
import { readMemoryProvider, type MemoryProviderPlugin } from '../status/index.ts'
|
|
17
|
+
import { readMemoryProvider, type MemoryProviderPlugin, type MemoryProviderProvision } from '../status/index.ts'
|
|
18
|
+
import { pluginLogsDir } from '../storage/index.ts'
|
|
17
19
|
import {
|
|
18
20
|
enableCapabilityForCwd,
|
|
19
21
|
removeClaudeForCwd,
|
|
20
22
|
removeCodexGlobal,
|
|
21
23
|
type CapabilityRuntime,
|
|
22
24
|
} from './index.ts'
|
|
25
|
+
import { runProvisionCommand, type ProvisionOccasion } from './provisionCommand.ts'
|
|
23
26
|
|
|
24
27
|
export interface MemoryPluginOutcome {
|
|
25
28
|
personality: string
|
|
@@ -58,6 +61,13 @@ export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions
|
|
|
58
61
|
if (!slot) {
|
|
59
62
|
return { ok: false, plugin: null, error: 'memory slot is EMPTY — no provider declared, nothing to install', outcomes: [] }
|
|
60
63
|
}
|
|
64
|
+
// v1.2 PRECEDENCE (контракт §Provision провайдера): a provision-declaring slot
|
|
65
|
+
// routes BOTH verb directions through the provider's commands; the deprecated
|
|
66
|
+
// v1.1 plugin block is ignored entirely (no fallback — masking a provision
|
|
67
|
+
// failure would hide a broken provider behind a stale plugin install).
|
|
68
|
+
if (slot.provision || slot.unprovision) {
|
|
69
|
+
return memoryProvisionApply(state, slot.provision, slot.unprovision, slot.provider, opts, env)
|
|
70
|
+
}
|
|
61
71
|
if (!slot.plugin) {
|
|
62
72
|
return {
|
|
63
73
|
ok: false,
|
|
@@ -131,3 +141,70 @@ export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions
|
|
|
131
141
|
const failed = outcomes.some(o => o.state === 'failed' || o.state === 'setup-failed')
|
|
132
142
|
return { ok: !failed, plugin, outcomes }
|
|
133
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* v1.2 verb routing through the provider's provision/unprovision commands
|
|
147
|
+
* (occasion: on → sweep-on; off --peer → off-peer; off --all → off-all). One
|
|
148
|
+
* invocation per target peer × agentic runtime — NO host-global special-casing
|
|
149
|
+
* here: ref-counting of host-global surfaces (codex) moved to the PROVIDER with
|
|
150
|
+
* the inversion (contract requirement 4). A direction whose command block is
|
|
151
|
+
* absent is an explicit refusal (never silently skipped, never plugin-fallback).
|
|
152
|
+
*/
|
|
153
|
+
function memoryProvisionApply(
|
|
154
|
+
state: 'on' | 'off',
|
|
155
|
+
provision: MemoryProviderProvision | undefined,
|
|
156
|
+
unprovision: MemoryProviderProvision | undefined,
|
|
157
|
+
provider: string,
|
|
158
|
+
opts: MemoryPluginOptions,
|
|
159
|
+
env: NodeJS.ProcessEnv,
|
|
160
|
+
): MemoryPluginResult {
|
|
161
|
+
const block = state === 'on' ? provision : unprovision
|
|
162
|
+
if (!block) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
plugin: null,
|
|
166
|
+
error:
|
|
167
|
+
`provider "${provider}" declares no ${state === 'on' ? 'provision' : 'unprovision'} command ` +
|
|
168
|
+
`(declaration v1.2 requires both directions) — fix the provider's declaration`,
|
|
169
|
+
outcomes: [],
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const index = readPeersIndex({ env })
|
|
173
|
+
const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
|
|
174
|
+
if (targets.length === 0) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
plugin: null,
|
|
178
|
+
error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
|
|
179
|
+
outcomes: [],
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const occasion: ProvisionOccasion = state === 'on' ? 'sweep-on' : opts.all === true ? 'off-all' : 'off-peer'
|
|
183
|
+
const outcomes: MemoryPluginOutcome[] = []
|
|
184
|
+
for (const p of targets) {
|
|
185
|
+
const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
186
|
+
if (agentic.length === 0) {
|
|
187
|
+
outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
for (const rt of agentic) {
|
|
191
|
+
const o = runProvisionCommand({ block, cwd: p.cwd, runtime: rt, personality: p.personality, occasion, env })
|
|
192
|
+
appendLifecycleEvent(
|
|
193
|
+
pluginLogsDir('iapeer', { env }),
|
|
194
|
+
{
|
|
195
|
+
ev: 'memory-provision',
|
|
196
|
+
identity: `${rt}-${p.personality}`,
|
|
197
|
+
occasion,
|
|
198
|
+
state: o.state,
|
|
199
|
+
exit: o.exitCode ?? undefined,
|
|
200
|
+
ms: o.durationMs,
|
|
201
|
+
detail: o.detail,
|
|
202
|
+
},
|
|
203
|
+
{ env },
|
|
204
|
+
)
|
|
205
|
+
outcomes.push({ personality: p.personality, runtime: rt, state: o.state, detail: o.detail })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const failed = outcomes.some(o => o.state !== 'ok' && o.state !== 'skipped-no-agentic-runtime')
|
|
209
|
+
return { ok: !failed, plugin: null, outcomes }
|
|
210
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Provision-command executor — the v1.2 inversion joint. Pins the four contract
|
|
2
|
+
// requirements: per-arg placeholder substitution WITHOUT a shell (injection
|
|
3
|
+
// class), absolute command, timeout, structured outcomes. The fake provider is
|
|
4
|
+
// a tmp shell script that journals its argv — no real provider involved.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { runProvisionCommand } from './provisionCommand.ts'
|
|
11
|
+
|
|
12
|
+
let dir: string
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
dir = mkdtempSync(join(tmpdir(), 'iapeer-provcmd-'))
|
|
15
|
+
})
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** A fake provider command journaling each argv element on its own line. */
|
|
21
|
+
function fakeProvider(name: string, body?: string): string {
|
|
22
|
+
const p = join(dir, name)
|
|
23
|
+
writeFileSync(p, `#!/bin/sh\n${body ?? `printf '%s\\n' "$@" > '${dir}/journal.txt'`}\n`)
|
|
24
|
+
chmodSync(p, 0o755)
|
|
25
|
+
return p
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('runProvisionCommand (v1.2 executor)', () => {
|
|
29
|
+
test('per-arg substitution of {cwd}/{runtime}/{personality}/{occasion}, argv passed verbatim (no shell)', () => {
|
|
30
|
+
const cmd = fakeProvider('prov.sh')
|
|
31
|
+
const o = runProvisionCommand({
|
|
32
|
+
block: {
|
|
33
|
+
command: cmd,
|
|
34
|
+
// both shapes: bare placeholder arg and embedded `--k={v}`; plus an arg
|
|
35
|
+
// full of shell metacharacters that MUST stay literal (no shell layer)
|
|
36
|
+
args: ['provision-peer', '--cwd', '{cwd}', '--runtime={runtime}', '--occasion', '{occasion}', '$(reboot) `id` ; rm -rf /'],
|
|
37
|
+
},
|
|
38
|
+
cwd: '/tmp/some peer dir',
|
|
39
|
+
runtime: 'claude',
|
|
40
|
+
personality: 'tw-prov',
|
|
41
|
+
occasion: 'birth',
|
|
42
|
+
})
|
|
43
|
+
expect(o.state).toBe('ok')
|
|
44
|
+
expect(o.exitCode).toBe(0)
|
|
45
|
+
const journal = readFileSync(join(dir, 'journal.txt'), 'utf8').split('\n')
|
|
46
|
+
expect(journal[0]).toBe('provision-peer')
|
|
47
|
+
expect(journal[1]).toBe('--cwd')
|
|
48
|
+
expect(journal[2]).toBe('/tmp/some peer dir') // spaces survive — single argv element
|
|
49
|
+
expect(journal[3]).toBe('--runtime=claude') // embedded substitution
|
|
50
|
+
expect(journal[4]).toBe('--occasion')
|
|
51
|
+
expect(journal[5]).toBe('birth')
|
|
52
|
+
expect(journal[6]).toBe('$(reboot) `id` ; rm -rf /') // LITERAL — never shell-parsed
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('{personality} placeholder is supported (optional for the provider)', () => {
|
|
56
|
+
const cmd = fakeProvider('prov.sh')
|
|
57
|
+
const o = runProvisionCommand({
|
|
58
|
+
block: { command: cmd, args: ['{personality}'] },
|
|
59
|
+
cwd: '/x',
|
|
60
|
+
runtime: 'codex',
|
|
61
|
+
personality: 'boris',
|
|
62
|
+
occasion: 'sweep-on',
|
|
63
|
+
})
|
|
64
|
+
expect(o.state).toBe('ok')
|
|
65
|
+
expect(readFileSync(join(dir, 'journal.txt'), 'utf8').trim()).toBe('boris')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('non-zero exit → failed with exit code and stderr tail', () => {
|
|
69
|
+
const cmd = fakeProvider('prov.sh', `echo 'no such peer' >&2; exit 3`)
|
|
70
|
+
const o = runProvisionCommand({
|
|
71
|
+
block: { command: cmd, args: [] },
|
|
72
|
+
cwd: '/x',
|
|
73
|
+
runtime: 'claude',
|
|
74
|
+
personality: 'p',
|
|
75
|
+
occasion: 'off-peer',
|
|
76
|
+
})
|
|
77
|
+
expect(o.state).toBe('failed')
|
|
78
|
+
expect(o.exitCode).toBe(3)
|
|
79
|
+
expect(o.detail).toContain('no such peer')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('timeout → state=timeout, never hangs the joint', () => {
|
|
83
|
+
const cmd = fakeProvider('prov.sh', 'sleep 30')
|
|
84
|
+
const o = runProvisionCommand({
|
|
85
|
+
block: { command: cmd, args: [] },
|
|
86
|
+
cwd: '/x',
|
|
87
|
+
runtime: 'claude',
|
|
88
|
+
personality: 'p',
|
|
89
|
+
occasion: 'remove',
|
|
90
|
+
timeoutMs: 300,
|
|
91
|
+
})
|
|
92
|
+
expect(o.state).toBe('timeout')
|
|
93
|
+
expect(o.durationMs).toBeLessThan(5000)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('missing/non-executable command → not-executable (never throws)', () => {
|
|
97
|
+
const o = runProvisionCommand({
|
|
98
|
+
block: { command: join(dir, 'nope.sh'), args: [] },
|
|
99
|
+
cwd: '/x',
|
|
100
|
+
runtime: 'claude',
|
|
101
|
+
personality: 'p',
|
|
102
|
+
occasion: 'birth',
|
|
103
|
+
})
|
|
104
|
+
expect(o.state).toBe('not-executable')
|
|
105
|
+
expect(o.exitCode).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Provision-command executor — the v1.2 inversion joint (контракт «Слот памяти»
|
|
2
|
+
// §Provision провайдера; ADR-009, решение Артура 10.06, схема сверена с
|
|
3
|
+
// iapeer-memory 11.06). The slot declares provision/unprovision COMMANDS; the
|
|
4
|
+
// core shells into them at the lifecycle joints and knows NOTHING about surface
|
|
5
|
+
// forms. The four contract requirements live here:
|
|
6
|
+
// 1. argv-form {command, args[]} with PER-ARG placeholder substitution —
|
|
7
|
+
// spawned WITHOUT a shell (injection/quoting class; бэктик-грабли 10.06).
|
|
8
|
+
// 2. `command` is ABSOLUTE (parser refuses relative — launchd minimal PATH).
|
|
9
|
+
// 3. Timeout 120 s; best-effort semantics live at the CALL SITES (LOUD warn,
|
|
10
|
+
// the birth/remove flow continues) — this module only reports structurally.
|
|
11
|
+
// 4. {occasion} vocabulary: birth | sweep-on | off-peer | off-all | remove —
|
|
12
|
+
// ref-counting of host-global surfaces is the PROVIDER's business.
|
|
13
|
+
// Idempotency is the provider's obligation by construction; the provider holds
|
|
14
|
+
// its own lock against concurrent provisions.
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'child_process'
|
|
17
|
+
import { accessSync, constants as FS } from 'fs'
|
|
18
|
+
import type { MemoryProviderProvision } from '../status/index.ts'
|
|
19
|
+
|
|
20
|
+
/** The agreed occasion vocabulary (финален, сверено с iapeer-memory). */
|
|
21
|
+
export type ProvisionOccasion = 'birth' | 'sweep-on' | 'off-peer' | 'off-all' | 'remove'
|
|
22
|
+
|
|
23
|
+
export const PROVISION_TIMEOUT_MS = 120_000
|
|
24
|
+
|
|
25
|
+
export interface ProvisionCommandSpec {
|
|
26
|
+
/** The declared command block (provision or unprovision). */
|
|
27
|
+
block: MemoryProviderProvision
|
|
28
|
+
/** Placeholder values — substituted PER-ARG, substring-level (so both
|
|
29
|
+
* `--cwd {cwd}` and `--cwd={cwd}` argv shapes work). */
|
|
30
|
+
cwd: string
|
|
31
|
+
runtime: string
|
|
32
|
+
personality: string
|
|
33
|
+
occasion: ProvisionOccasion
|
|
34
|
+
env?: NodeJS.ProcessEnv
|
|
35
|
+
timeoutMs?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProvisionCommandOutcome {
|
|
39
|
+
state: 'ok' | 'failed' | 'timeout' | 'not-executable'
|
|
40
|
+
exitCode: number | null
|
|
41
|
+
/** stderr tail (last ~400 chars) — the structured detail for eventlog/warns. */
|
|
42
|
+
detail?: string
|
|
43
|
+
durationMs: number
|
|
44
|
+
/** The fully substituted argv (command first) — logged for postmortems. */
|
|
45
|
+
argv: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function substitute(arg: string, s: ProvisionCommandSpec): string {
|
|
49
|
+
return arg
|
|
50
|
+
.replaceAll('{cwd}', s.cwd)
|
|
51
|
+
.replaceAll('{runtime}', s.runtime)
|
|
52
|
+
.replaceAll('{personality}', s.personality)
|
|
53
|
+
.replaceAll('{occasion}', s.occasion)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tail(text: string, max = 400): string | undefined {
|
|
57
|
+
const t = text.trim()
|
|
58
|
+
if (!t) return undefined
|
|
59
|
+
return t.length <= max ? t : `…${t.slice(-max)}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run ONE provider provision/unprovision command for ONE peer × runtime ×
|
|
64
|
+
* occasion. Synchronous (the call sites are the synchronous provision/remove
|
|
65
|
+
* flows), no shell, bounded by timeout. NEVER throws — every failure is a
|
|
66
|
+
* structured outcome the call site warns about (best-effort: a provider hiccup
|
|
67
|
+
* must not kill a birth or a remove).
|
|
68
|
+
*/
|
|
69
|
+
export function runProvisionCommand(spec: ProvisionCommandSpec): ProvisionCommandOutcome {
|
|
70
|
+
const env = spec.env ?? process.env
|
|
71
|
+
const argv = [spec.block.command, ...spec.block.args.map(a => substitute(a, spec))]
|
|
72
|
+
const started = Date.now()
|
|
73
|
+
try {
|
|
74
|
+
accessSync(spec.block.command, FS.X_OK)
|
|
75
|
+
} catch {
|
|
76
|
+
return {
|
|
77
|
+
state: 'not-executable',
|
|
78
|
+
exitCode: null,
|
|
79
|
+
detail: `command not found or not executable: ${spec.block.command}`,
|
|
80
|
+
durationMs: Date.now() - started,
|
|
81
|
+
argv,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const r = spawnSync(argv[0]!, argv.slice(1), {
|
|
86
|
+
env: env as Record<string, string>,
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
timeout: spec.timeoutMs ?? PROVISION_TIMEOUT_MS,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
})
|
|
91
|
+
const durationMs = Date.now() - started
|
|
92
|
+
// node/bun signal a timeout via error.code ETIMEDOUT + signal SIGTERM
|
|
93
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
94
|
+
return { state: 'timeout', exitCode: null, detail: `timed out after ${spec.timeoutMs ?? PROVISION_TIMEOUT_MS} ms`, durationMs, argv }
|
|
95
|
+
}
|
|
96
|
+
if (r.error) {
|
|
97
|
+
return { state: 'failed', exitCode: null, detail: r.error.message, durationMs, argv }
|
|
98
|
+
}
|
|
99
|
+
if (r.status !== 0) {
|
|
100
|
+
return { state: 'failed', exitCode: r.status, detail: tail(r.stderr ?? ''), durationMs, argv }
|
|
101
|
+
}
|
|
102
|
+
return { state: 'ok', exitCode: 0, detail: tail(r.stderr ?? ''), durationMs, argv }
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return {
|
|
105
|
+
state: 'failed',
|
|
106
|
+
exitCode: null,
|
|
107
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
108
|
+
durationMs: Date.now() - started,
|
|
109
|
+
argv,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/provision/index.ts
CHANGED
|
@@ -121,13 +121,59 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
121
121
|
)
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
// Provider PROVISION command (контракт §Provision провайдера, declaration
|
|
125
|
+
// v1.2 — ADR-009 inversion; решение Артура 10.06, схема сверена с
|
|
126
|
+
// iapeer-memory 11.06): an occupied slot WITH a provision block shells into
|
|
127
|
+
// the PROVIDER's command per agentic runtime (occasion=birth) — the core
|
|
128
|
+
// knows nothing about surface forms. PRECEDENCE: provision WINS over the
|
|
129
|
+
// deprecated v1.1 plugin block, with NO runtime fallback to plugin on
|
|
130
|
+
// failure (masking a provision refusal would hide a broken provider —
|
|
131
|
+
// LOUD warn + repair-hint instead). Runs AFTER preTrustCodexCwd above, so
|
|
132
|
+
// the provider's per-peer codex surfaces land in an already-trusted cwd.
|
|
133
|
+
if (slot.provision) {
|
|
134
|
+
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
135
|
+
const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
|
|
136
|
+
const { pluginLogsDir } = await import('../storage/index.ts')
|
|
137
|
+
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
138
|
+
for (const rt of agentic) {
|
|
139
|
+
const o = runProvisionCommand({
|
|
140
|
+
block: slot.provision,
|
|
141
|
+
cwd,
|
|
142
|
+
runtime: rt,
|
|
143
|
+
personality: profile.personality,
|
|
144
|
+
occasion: 'birth',
|
|
145
|
+
env,
|
|
146
|
+
})
|
|
147
|
+
appendLifecycleEvent(
|
|
148
|
+
pluginLogsDir('iapeer', { env }),
|
|
149
|
+
{
|
|
150
|
+
ev: 'memory-provision',
|
|
151
|
+
identity: `${rt}-${profile.personality}`,
|
|
152
|
+
occasion: 'birth',
|
|
153
|
+
state: o.state,
|
|
154
|
+
exit: o.exitCode ?? undefined,
|
|
155
|
+
ms: o.durationMs,
|
|
156
|
+
detail: o.detail,
|
|
157
|
+
},
|
|
158
|
+
{ env },
|
|
159
|
+
)
|
|
160
|
+
if (o.state !== 'ok') {
|
|
161
|
+
opts.warn?.(
|
|
162
|
+
`memory provision (${rt}) ${o.state.toUpperCase()} for "${profile.personality}"` +
|
|
163
|
+
`${o.detail ? `: ${o.detail}` : ''} — память пира не подключена; ` +
|
|
164
|
+
`repair: iapeer memory-plugin on --peer ${profile.personality}`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
124
169
|
// Provider PLUGIN auto-install (контракт §Плагин провайдера, declaration
|
|
125
|
-
// v1.1; agreed iapeer-memory + boris 10.06): an
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
170
|
+
// v1.1, DEPRECATED by §Provision; agreed iapeer-memory + boris 10.06): an
|
|
171
|
+
// occupied slot WITH a plugin block and WITHOUT a provision block installs
|
|
172
|
+
// the provider's marketplace plugin into the newborn's scope (claude
|
|
173
|
+
// project-scope in cwd; codex host-global, idempotent) — «создал пира —
|
|
174
|
+
// память работает». A v1 declaration (no block) installs nothing.
|
|
129
175
|
// Best-effort with a LOUD warn; repair = `iapeer memory-plugin on --peer`.
|
|
130
|
-
if (slot.plugin) {
|
|
176
|
+
else if (slot.plugin) {
|
|
131
177
|
const { enableCapabilityForCwd } = await import('../enable/index.ts')
|
|
132
178
|
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
133
179
|
if (agentic.length > 0) {
|
|
@@ -223,4 +223,67 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
223
223
|
// the native lever still applied (same slot-gated block)
|
|
224
224
|
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
225
225
|
})
|
|
226
|
+
|
|
227
|
+
test('v1.2: slot with provision command → birth shells into it per runtime (occasion=birth), plugin path NOT taken', async () => {
|
|
228
|
+
const root = mkTmp()
|
|
229
|
+
const env = envFor(root)
|
|
230
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
231
|
+
const journal = join(root, 'journal.txt')
|
|
232
|
+
const script = join(root, 'fake-provider.sh')
|
|
233
|
+
const { chmodSync } = await import('fs')
|
|
234
|
+
writeFileSync(script, `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`)
|
|
235
|
+
chmodSync(script, 0o755)
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
238
|
+
JSON.stringify({
|
|
239
|
+
provider: 'iapeer-memory',
|
|
240
|
+
package: '@agfpd/iapeer-memory',
|
|
241
|
+
version: '0.2.0',
|
|
242
|
+
registeredAt: 'x',
|
|
243
|
+
// BOTH blocks declared → provision must WIN (precedence, no plugin attempt)
|
|
244
|
+
plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
|
|
245
|
+
provision: {
|
|
246
|
+
command: script,
|
|
247
|
+
args: ['provision-peer', '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
const cwd = join(root, 'wprov')
|
|
252
|
+
const warns: string[] = []
|
|
253
|
+
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
254
|
+
expect(r.personality).toBe('wprov')
|
|
255
|
+
expect(warns).toEqual([]) // provision ok → no warn at all
|
|
256
|
+
const j = readFileSync(journal, 'utf8').split('\n')
|
|
257
|
+
expect(j[0]).toBe('provision-peer')
|
|
258
|
+
expect(j.slice(1, 9)).toEqual(['--cwd', cwd, '--runtime', 'claude', '--personality', 'wprov', '--occasion', 'birth'])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('v1.2: provision command FAILS → LOUD warn with repair-hint, provision of the peer still SUCCEEDS (best-effort)', async () => {
|
|
262
|
+
const root = mkTmp()
|
|
263
|
+
const env = envFor(root)
|
|
264
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
265
|
+
const script = join(root, 'fake-provider.sh')
|
|
266
|
+
const { chmodSync } = await import('fs')
|
|
267
|
+
writeFileSync(script, `#!/bin/sh\necho 'vault offline' >&2\nexit 9\n`)
|
|
268
|
+
chmodSync(script, 0o755)
|
|
269
|
+
writeFileSync(
|
|
270
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
provider: 'iapeer-memory',
|
|
273
|
+
package: '@agfpd/iapeer-memory',
|
|
274
|
+
version: '0.2.0',
|
|
275
|
+
registeredAt: 'x',
|
|
276
|
+
provision: { command: script, args: ['{occasion}'] },
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
const cwd = join(root, 'wfail')
|
|
280
|
+
const warns: string[] = []
|
|
281
|
+
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
282
|
+
expect(r.personality).toBe('wfail') // birth completed despite the provider hiccup
|
|
283
|
+
const w = warns.find(x => x.includes('memory provision'))
|
|
284
|
+
expect(w).toBeDefined()
|
|
285
|
+
expect(w).toContain('FAILED')
|
|
286
|
+
expect(w).toContain('vault offline')
|
|
287
|
+
expect(w).toContain('memory-plugin on --peer wfail') // repair-hint
|
|
288
|
+
})
|
|
226
289
|
})
|
package/src/status/index.ts
CHANGED
|
@@ -32,6 +32,21 @@ export interface MemoryProviderPlugin {
|
|
|
32
32
|
marketplaceRef: string
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** The provider's provision command (declaration v1.2, контракт §Provision
|
|
36
|
+
* провайдера — ADR-009 inversion, решение Артура 10.06 + дизайн-консенсус
|
|
37
|
+
* сторон). The slot declares not surfaces but a COMMAND the core shells into at
|
|
38
|
+
* the lifecycle joints (birth / verb sweeps / remove); surface forms and their
|
|
39
|
+
* uninstall accounting live entirely with the provider. The `command` must be
|
|
40
|
+
* ABSOLUTE (the daemon runs under launchd's minimal PATH); `args` get PER-ARG
|
|
41
|
+
* placeholder substitution ({cwd}/{runtime}/{personality}/{occasion}) and are
|
|
42
|
+
* spawned WITHOUT a shell (injection/quoting class — бэктик-грабли 10.06). */
|
|
43
|
+
export interface MemoryProviderProvision {
|
|
44
|
+
/** Absolute path to the provider's executable. */
|
|
45
|
+
command: string
|
|
46
|
+
/** Argv tail; placeholders are substituted per-argument, never shell-parsed. */
|
|
47
|
+
args: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
export interface MemoryProvider {
|
|
36
51
|
/** Provider name occupying the slot (e.g. "iapeer-memory"). */
|
|
37
52
|
provider: string
|
|
@@ -42,8 +57,15 @@ export interface MemoryProvider {
|
|
|
42
57
|
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
43
58
|
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
44
59
|
heartbeat?: string
|
|
45
|
-
/** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin.
|
|
60
|
+
/** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin.
|
|
61
|
+
* DEPRECATED by v1.2: when `provision` is also present, provision WINS and
|
|
62
|
+
* the plugin block is ignored (no runtime fallback — an explicit precedence,
|
|
63
|
+
* agreed iapeer-memory 11.06). */
|
|
46
64
|
plugin?: MemoryProviderPlugin
|
|
65
|
+
/** Optional v1.2 provision command (peer-birth / sweep-on joints). */
|
|
66
|
+
provision?: MemoryProviderProvision
|
|
67
|
+
/** Optional v1.2 unprovision command (off-peer / off-all / remove joints). */
|
|
68
|
+
unprovision?: MemoryProviderProvision
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
/** Parse the optional v1.1 `plugin` block; anything short of three non-empty
|
|
@@ -57,6 +79,18 @@ function parsePluginBlock(raw: unknown): MemoryProviderPlugin | undefined {
|
|
|
57
79
|
return { name: o.name.trim(), marketplace: o.marketplace.trim(), marketplaceRef: o.marketplaceRef.trim() }
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
/** Parse an optional v1.2 `provision`/`unprovision` block. Fail-open like the
|
|
83
|
+
* plugin block: anything short of an ABSOLUTE command string + an array of
|
|
84
|
+
* strings → undefined (treated as absent). A relative command is INVALID by
|
|
85
|
+
* contract (launchd minimal PATH would resolve it differently per caller). */
|
|
86
|
+
function parseProvisionBlock(raw: unknown): MemoryProviderProvision | undefined {
|
|
87
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
88
|
+
const o = raw as Record<string, unknown>
|
|
89
|
+
if (typeof o.command !== 'string' || !o.command.trim().startsWith('/')) return undefined
|
|
90
|
+
if (!Array.isArray(o.args) || o.args.some(a => typeof a !== 'string')) return undefined
|
|
91
|
+
return { command: o.command.trim(), args: (o.args as string[]).slice() }
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
61
95
|
return join(resolveGlobalRoot(env), MEMORY_PROVIDER_FILE)
|
|
62
96
|
}
|
|
@@ -74,6 +108,8 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
74
108
|
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
75
109
|
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
76
110
|
const plugin = parsePluginBlock(o.plugin)
|
|
111
|
+
const provision = parseProvisionBlock(o.provision)
|
|
112
|
+
const unprovision = parseProvisionBlock(o.unprovision)
|
|
77
113
|
return {
|
|
78
114
|
provider: o.provider.trim(),
|
|
79
115
|
package: o.package.trim(),
|
|
@@ -81,6 +117,8 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
81
117
|
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
82
118
|
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
83
119
|
...(plugin ? { plugin } : {}),
|
|
120
|
+
...(provision ? { provision } : {}),
|
|
121
|
+
...(unprovision ? { unprovision } : {}),
|
|
84
122
|
}
|
|
85
123
|
} catch {
|
|
86
124
|
return null // empty slot — bare core is valid
|
|
@@ -81,6 +81,33 @@ describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
|
81
81
|
expect(p?.plugin).toBeUndefined()
|
|
82
82
|
}
|
|
83
83
|
})
|
|
84
|
+
|
|
85
|
+
test('v1.2 provision/unprovision blocks: parsed when valid; relative command or bad args = absent (fail-open)', () => {
|
|
86
|
+
const root = mkTmp()
|
|
87
|
+
const env = envFor(root)
|
|
88
|
+
const provision = { command: '/usr/local/bin/iapeer-memory', args: ['provision-peer', '--cwd', '{cwd}'] }
|
|
89
|
+
const unprovision = { command: '/usr/local/bin/iapeer-memory', args: ['unprovision-peer'] }
|
|
90
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision, unprovision }))
|
|
91
|
+
const p = readMemoryProvider(env)
|
|
92
|
+
expect(p?.provision).toEqual(provision)
|
|
93
|
+
expect(p?.unprovision).toEqual(unprovision)
|
|
94
|
+
// empty args array is VALID (command alone)
|
|
95
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: { command: '/x/y', args: [] } }))
|
|
96
|
+
expect(readMemoryProvider(env)?.provision).toEqual({ command: '/x/y', args: [] })
|
|
97
|
+
// invalid shapes → block absent, declaration itself stays valid
|
|
98
|
+
for (const bad of [
|
|
99
|
+
{ command: 'iapeer-memory', args: [] }, // RELATIVE command (launchd minimal PATH) — contract-invalid
|
|
100
|
+
{ command: '/x/y' }, // args missing
|
|
101
|
+
{ command: '/x/y', args: ['ok', 42] }, // non-string arg
|
|
102
|
+
{ args: ['provision-peer'] }, // command missing
|
|
103
|
+
'provision-me', // not an object
|
|
104
|
+
]) {
|
|
105
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: bad }))
|
|
106
|
+
const r = readMemoryProvider(env)
|
|
107
|
+
expect(r).not.toBeNull()
|
|
108
|
+
expect(r?.provision).toBeUndefined()
|
|
109
|
+
}
|
|
110
|
+
})
|
|
84
111
|
})
|
|
85
112
|
|
|
86
113
|
describe('heartbeatAgeSecs', () => {
|