@agfpd/iapeer 0.2.24 → 0.2.26
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 +11 -0
- package/src/cli/index.ts +92 -1
- package/src/enable/index.ts +165 -25
- package/src/enable/memoryPlugin.test.ts +103 -0
- package/src/enable/memoryPlugin.ts +133 -0
- package/src/lifecycle/index.ts +35 -0
- package/src/lifecycle/lifecycle.test.ts +63 -0
- package/src/onboard/index.ts +55 -9
- package/src/provision/index.ts +35 -2
- package/src/provision/provision.test.ts +25 -0
- package/src/status/index.ts +31 -0
- package/src/status/status.test.ts +20 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -130,6 +130,17 @@ describe('remove (registry record via the locked writer)', () => {
|
|
|
130
130
|
expect((await removePeerCli('twice', { env: e })).action).toBe('removed')
|
|
131
131
|
expect((await removePeerCli('twice', { env: e })).action).toBe('absent')
|
|
132
132
|
})
|
|
133
|
+
test('self-done arms the caller\'s own quiet-reap (non-waking silent finish); refuses without PEER_IDENTITY', async () => {
|
|
134
|
+
const e = env()
|
|
135
|
+
// no PEER_IDENTITY → self-call refusal
|
|
136
|
+
expect(await runCli(['self-done'], e)).toBe(1)
|
|
137
|
+
// with PEER_IDENTITY → marker set, exit 0, nobody contacted
|
|
138
|
+
const e2 = { ...e, PEER_IDENTITY: 'claude-silentworker' }
|
|
139
|
+
expect(await runCli(['self-done'], e2)).toBe(0)
|
|
140
|
+
const { hasEphemeralArmed } = await import('../lifecycle/index.ts')
|
|
141
|
+
expect(hasEphemeralArmed(loadLifecycleConfig(e2), 'claude-silentworker')).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
133
144
|
test('purges identity-keyed lifecycle state with the record — a namesake newborn must not inherit a dead peer\'s parking (boris 10.06)', async () => {
|
|
134
145
|
await register('reborn')
|
|
135
146
|
const e = env()
|
package/src/cli/index.ts
CHANGED
|
@@ -30,11 +30,13 @@ import {
|
|
|
30
30
|
clearStopped,
|
|
31
31
|
folderLaunch,
|
|
32
32
|
isLaunchdManaged,
|
|
33
|
+
isEphemeralPeer,
|
|
33
34
|
isStopped,
|
|
34
35
|
killSession,
|
|
35
36
|
loadLifecycleConfig,
|
|
36
37
|
purgeIdentityState,
|
|
37
38
|
removeSessionState,
|
|
39
|
+
setEphemeralArmed,
|
|
38
40
|
setIdleReaped,
|
|
39
41
|
setNewEager,
|
|
40
42
|
setStopped,
|
|
@@ -348,8 +350,9 @@ export async function sendMessage(
|
|
|
348
350
|
// unawaited in-process wake would die with it; the daemon's supervise-tick
|
|
349
351
|
// drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
|
|
350
352
|
// kicks, not a new mechanism.
|
|
351
|
-
const { makeEphemeralRouteDeps } = await import('../daemon/main.ts')
|
|
353
|
+
const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
|
|
352
354
|
const cfg = loadLifecycleConfig(env)
|
|
355
|
+
const t0 = Date.now()
|
|
353
356
|
const result = await routeSend(
|
|
354
357
|
caller,
|
|
355
358
|
{
|
|
@@ -361,7 +364,39 @@ export async function sendMessage(
|
|
|
361
364
|
},
|
|
362
365
|
{ wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
|
|
363
366
|
)
|
|
367
|
+
// delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
|
|
368
|
+
// routed through the CLI left to=<peer> at ZERO for the day while real wakes
|
|
369
|
+
// happened; the daemon tool-path logs, this path was blind). Same fields, plus
|
|
370
|
+
// path=cli so the two entry points are distinguishable. Both branches logged.
|
|
371
|
+
const { appendDeliveryEvent } = await import('../daemon/deliverylog.ts')
|
|
372
|
+
appendDeliveryEvent(cfg.eventLogDir, {
|
|
373
|
+
ev: 'delivery',
|
|
374
|
+
path: 'cli',
|
|
375
|
+
caller: caller.address,
|
|
376
|
+
to: opts.target,
|
|
377
|
+
rt: opts.runtime,
|
|
378
|
+
ok: String(result.ok),
|
|
379
|
+
via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
|
|
380
|
+
woke: result.ok ? String(result.value.woke) : undefined,
|
|
381
|
+
queued: result.ok && result.value.queued ? 'true' : undefined,
|
|
382
|
+
qd: result.ok ? result.value.queueDepth : undefined,
|
|
383
|
+
ms: Date.now() - t0,
|
|
384
|
+
len: opts.message.length,
|
|
385
|
+
att: opts.attachments?.length || undefined,
|
|
386
|
+
topic: opts.topic,
|
|
387
|
+
err: result.ok ? undefined : result.error.message,
|
|
388
|
+
})
|
|
364
389
|
if (!result.ok) throw new Error(result.error.message)
|
|
390
|
+
// M2 arm-on-outbound — CLI-path parity (live gap 10.06: an ephemeral worker's
|
|
391
|
+
// final reply sent through the CLI fallback — e.g. inside a daemon-restart
|
|
392
|
+
// window, four deploys that day — never armed, so the worker idled to the
|
|
393
|
+
// unarmed bound and stalled its FIFO). Same hook the daemon path uses; ONLY on
|
|
394
|
+
// an ok outcome, errors swallowed (arming is best-effort, never fails the send).
|
|
395
|
+
try {
|
|
396
|
+
makeArmEphemeralOnDelivered(cfg)(caller)
|
|
397
|
+
} catch {
|
|
398
|
+
/* best-effort */
|
|
399
|
+
}
|
|
365
400
|
return {
|
|
366
401
|
ok: true,
|
|
367
402
|
delivered_to: result.value.delivered_to,
|
|
@@ -432,7 +467,9 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
432
467
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
433
468
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
434
469
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
470
|
+
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
435
471
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
472
|
+
memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
|
|
436
473
|
`
|
|
437
474
|
|
|
438
475
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -562,6 +599,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
562
599
|
}
|
|
563
600
|
return failed ? 1 : 0
|
|
564
601
|
}
|
|
602
|
+
case 'memory-plugin': {
|
|
603
|
+
// Slot-derived provider-plugin install/removal (контракт «Слот памяти»
|
|
604
|
+
// §Плагин провайдера; agreed iapeer-memory + boris 10.06). The plugin
|
|
605
|
+
// identity comes from the slot declaration's v1.1 plugin block — empty
|
|
606
|
+
// slot / v1 declaration → explicit refusal (generic `enable` exists for
|
|
607
|
+
// arbitrary plugins). Forms live in enable/ (ONE home); codex off is
|
|
608
|
+
// host-global (only --all removes; --peer reports it explicitly).
|
|
609
|
+
const state = positionals[0]
|
|
610
|
+
if (state !== 'on' && state !== 'off') return usage(errOut)
|
|
611
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
612
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
613
|
+
const { memoryPluginApply } = await import('../enable/memoryPlugin.ts')
|
|
614
|
+
const r = memoryPluginApply(state, { peer: peerName, all: flags.all === true, env })
|
|
615
|
+
if (r.error) {
|
|
616
|
+
errOut(`memory-plugin: ${r.error}\n`)
|
|
617
|
+
return 1
|
|
618
|
+
}
|
|
619
|
+
out(`plugin: ${r.plugin!.name}@${r.plugin!.marketplace} (provider-declared)\n`)
|
|
620
|
+
for (const o of r.outcomes) {
|
|
621
|
+
out(`${o.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
622
|
+
}
|
|
623
|
+
return r.ok ? 0 : 1
|
|
624
|
+
}
|
|
565
625
|
case 'status': {
|
|
566
626
|
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
567
627
|
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
|
@@ -889,6 +949,37 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
889
949
|
if (!positionals[0] || !positionals[1]) return usage(errOut)
|
|
890
950
|
return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
|
|
891
951
|
}
|
|
952
|
+
case 'self-done': {
|
|
953
|
+
// SILENT-FINISH self-call for an ephemeral worker (контракт ЖЦ §wake_policy;
|
|
954
|
+
// развилка boris 10.06): a worker whose task produced NOTHING to send must
|
|
955
|
+
// still release its M3 FIFO — but an EMPTY report would violate Артур's
|
|
956
|
+
// invariant «событие-всё-отфильтровано = тишина» (no empty wakes of the
|
|
957
|
+
// target). This verb is the non-waking arm: it sets the worker's OWN
|
|
958
|
+
// .ephemeral-armed (same marker the ok-outbound hook sets), so the quiet
|
|
959
|
+
// window reaps it within seconds and the drain feeds the next task — nobody
|
|
960
|
+
// is woken. Doctrine for silent finishers: «нечего отправлять → iapeer
|
|
961
|
+
// self-done вместо ответа». The unarmed idle bound (ephemeralUnarmedIdleSecs)
|
|
962
|
+
// remains the backstop for workers that do neither. On a NON-ephemeral peer
|
|
963
|
+
// the marker is inert (quiet-reap keys on wake_policy) — warn, exit 0.
|
|
964
|
+
const identity = env.PEER_IDENTITY?.trim()
|
|
965
|
+
if (!identity) {
|
|
966
|
+
errOut('self-done: PEER_IDENTITY is not set — this verb is an agent self-call from inside a session\n')
|
|
967
|
+
return 1
|
|
968
|
+
}
|
|
969
|
+
if (!parseSessionName(identity)) {
|
|
970
|
+
errOut(`self-done: invalid PEER_IDENTITY "${identity}" — expected <runtime>-<personality>\n`)
|
|
971
|
+
return 1
|
|
972
|
+
}
|
|
973
|
+
const cfg = loadLifecycleConfig(env)
|
|
974
|
+
setEphemeralArmed(cfg, identity)
|
|
975
|
+
const ephemeral = isEphemeralPeer(process.cwd())
|
|
976
|
+
out(
|
|
977
|
+
`self-done: armed ${identity} for the quiet-window reap (no one woken)` +
|
|
978
|
+
(ephemeral ? '' : ' — NOTE: this peer is not wake_policy:ephemeral, the marker is inert') +
|
|
979
|
+
'\n',
|
|
980
|
+
)
|
|
981
|
+
return 0
|
|
982
|
+
}
|
|
892
983
|
case 'self-fresh': {
|
|
893
984
|
// /new AGENT-FACING TRIGGER (TARGET redesign). Run BY the agent itself as the
|
|
894
985
|
// FINAL step of a /new graceful wind-down (the owner triggers it via a per-peer
|
package/src/enable/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { spawnSync } from 'child_process'
|
|
|
23
23
|
import { existsSync, readFileSync, realpathSync } from 'fs'
|
|
24
24
|
import { basename, join } from 'path'
|
|
25
25
|
import { homedir } from 'os'
|
|
26
|
-
import { MARKETPLACE_NAME } from '../onboard/index.ts'
|
|
26
|
+
import { MARKETPLACE_NAME, isMarketplaceRegisteredAs, refreshMarketplace, registerMarketplace } from '../onboard/index.ts'
|
|
27
27
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
28
28
|
import { IapError } from '../core/errors.ts'
|
|
29
29
|
import { normalizeNameCandidate } from '../core/normalize.ts'
|
|
@@ -214,14 +214,20 @@ function isExecutable(bin: string, env: NodeJS.ProcessEnv): boolean {
|
|
|
214
214
|
* live — the same entry lists enabled=false from another dir, true from its own), so
|
|
215
215
|
* the list MUST run with cwd = the peer cwd to read the authoritative enabled-state.
|
|
216
216
|
* maxBuffer is raised — a configured host's plugin list exceeds the 1 MB default. */
|
|
217
|
-
function claudeEntry(
|
|
217
|
+
function claudeEntry(
|
|
218
|
+
bin: string,
|
|
219
|
+
plugin: string,
|
|
220
|
+
marketplace: string,
|
|
221
|
+
peerCwd: string,
|
|
222
|
+
env: NodeJS.ProcessEnv,
|
|
223
|
+
): InstalledEntry | null {
|
|
218
224
|
const list = spawnSync(bin, ['plugin', 'list', '--json'], {
|
|
219
225
|
cwd: peerCwd,
|
|
220
226
|
encoding: 'utf8',
|
|
221
227
|
env: env as Record<string, string>,
|
|
222
228
|
maxBuffer: 64 * 1024 * 1024,
|
|
223
229
|
})
|
|
224
|
-
return findPeerScopedEntry(parseInstalledPlugins(list.stdout ?? ''), plugin,
|
|
230
|
+
return findPeerScopedEntry(parseInstalledPlugins(list.stdout ?? ''), plugin, marketplace, peerCwd)
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
/**
|
|
@@ -232,11 +238,11 @@ function claudeEntry(bin: string, plugin: string, peerCwd: string, env: NodeJS.P
|
|
|
232
238
|
* enabled"). Idempotent + fleet-safe: the entry is keyed by THIS peer's projectPath
|
|
233
239
|
* (realpath), so it never reads or mutates another peer's install.
|
|
234
240
|
*/
|
|
235
|
-
function enableClaude(plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
241
|
+
function enableClaude(plugin: string, marketplace: string, peerCwd: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
236
242
|
const bin = claudeBin(env)
|
|
237
243
|
if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
|
|
238
|
-
const id = `${plugin}@${
|
|
239
|
-
const existing = claudeEntry(bin, plugin, peerCwd, env)
|
|
244
|
+
const id = `${plugin}@${marketplace}`
|
|
245
|
+
const existing = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
240
246
|
if (existing?.enabled) return { runtime: 'claude', state: 'already-enabled', installPath: existing.installPath }
|
|
241
247
|
|
|
242
248
|
if (!existing) {
|
|
@@ -251,14 +257,14 @@ function enableClaude(plugin: string, peerCwd: string, env: NodeJS.ProcessEnv):
|
|
|
251
257
|
}
|
|
252
258
|
// the entry now exists but is DISABLED (fresh install) or was pre-existing-disabled →
|
|
253
259
|
// enable it (and ONLY then; an already-enabled entry would error).
|
|
254
|
-
const after = claudeEntry(bin, plugin, peerCwd, env)
|
|
260
|
+
const after = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
255
261
|
if (after && !after.enabled) {
|
|
256
262
|
const en = spawnSync(bin, ['plugin', 'enable', id, '--scope', 'project'], {
|
|
257
263
|
cwd: peerCwd,
|
|
258
264
|
encoding: 'utf8',
|
|
259
265
|
env: env as Record<string, string>,
|
|
260
266
|
})
|
|
261
|
-
const confirmed = claudeEntry(bin, plugin, peerCwd, env)
|
|
267
|
+
const confirmed = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
262
268
|
if (!confirmed?.enabled) {
|
|
263
269
|
return { runtime: 'claude', state: 'failed', detail: (en.stderr || en.stdout || `exit ${en.status}`).trim() }
|
|
264
270
|
}
|
|
@@ -293,10 +299,10 @@ function codexState(bin: string, id: string, env: NodeJS.ProcessEnv): 'enabled'
|
|
|
293
299
|
* ENABLES (verified live: config.toml `[plugins."<id>"] enabled = true`) and is
|
|
294
300
|
* idempotent (re-add exits 0). Idempotency: skip when already enabled. installPath is
|
|
295
301
|
* parsed from the add output so setup-detection works for a codex-only peer. */
|
|
296
|
-
function enableCodex(plugin: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
302
|
+
function enableCodex(plugin: string, marketplace: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
297
303
|
const bin = codexBin(env)
|
|
298
304
|
if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
|
|
299
|
-
const id = `${plugin}@${
|
|
305
|
+
const id = `${plugin}@${marketplace}`
|
|
300
306
|
const before = codexState(bin, id, env)
|
|
301
307
|
if (before === 'enabled') return { runtime: 'codex', state: 'already-enabled' }
|
|
302
308
|
const add = spawnSync(bin, ['plugin', 'add', id], { encoding: 'utf8', env: env as Record<string, string> })
|
|
@@ -338,19 +344,76 @@ function callSetup(
|
|
|
338
344
|
return { ok: true }
|
|
339
345
|
}
|
|
340
346
|
|
|
347
|
+
/** Registry-free enable input — the cwd-level core (контракт §Плагин провайдера:
|
|
348
|
+
* birth-time hook runs BEFORE the registry upsert, so it cannot resolve a peer). */
|
|
349
|
+
export interface CwdEnableOptions {
|
|
350
|
+
plugin: string
|
|
351
|
+
/** Marketplace NAME the plugin id keys on (default: agfpd). */
|
|
352
|
+
marketplace?: string
|
|
353
|
+
/** Source ref: when given, the path ENSURES the marketplace is registered for the
|
|
354
|
+
* runtime (add on missing) before installing — the third-party-provider path. */
|
|
355
|
+
marketplaceRef?: string
|
|
356
|
+
cwd: string
|
|
357
|
+
personality: string
|
|
358
|
+
runtimes: CapabilityRuntime[]
|
|
359
|
+
noSetup?: boolean
|
|
360
|
+
env?: NodeJS.ProcessEnv
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One install attempt for one runtime, WITH the stale-snapshot resilience the
|
|
364
|
+
* contract requires (просьба iapeer-memory): a failed attempt refreshes the
|
|
365
|
+
* runtime's local marketplace snapshot (claude `marketplace update` / codex
|
|
366
|
+
* `marketplace upgrade`) and retries ONCE — a plugin registered in the
|
|
367
|
+
* marketplace after the host's last pull reads as «unknown plugin» until then. */
|
|
368
|
+
function enableRuntimeWithRetry(
|
|
369
|
+
rt: CapabilityRuntime,
|
|
370
|
+
plugin: string,
|
|
371
|
+
marketplace: string,
|
|
372
|
+
cwd: string,
|
|
373
|
+
env: NodeJS.ProcessEnv,
|
|
374
|
+
): RuntimeEnableResult {
|
|
375
|
+
const attempt = (): RuntimeEnableResult =>
|
|
376
|
+
rt === 'claude' ? enableClaude(plugin, marketplace, cwd, env) : enableCodex(plugin, marketplace, env)
|
|
377
|
+
const first = attempt()
|
|
378
|
+
if (first.state !== 'failed') return first
|
|
379
|
+
const refresh = refreshMarketplace(rt, marketplace, env)
|
|
380
|
+
if (!refresh.ok) {
|
|
381
|
+
return { ...first, detail: `${first.detail ?? 'failed'}; marketplace refresh also failed: ${refresh.detail ?? ''}` }
|
|
382
|
+
}
|
|
383
|
+
const second = attempt()
|
|
384
|
+
return second.state === 'failed'
|
|
385
|
+
? { ...second, detail: `${second.detail ?? 'failed'} (after marketplace refresh)` }
|
|
386
|
+
: second
|
|
387
|
+
}
|
|
388
|
+
|
|
341
389
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
390
|
+
* The registry-FREE enable core: install per-runtime (claude project-scope IN cwd /
|
|
391
|
+
* codex global) + enable + `setup` ONLY if declared — with marketplace ensure
|
|
392
|
+
* (marketplaceRef given + not registered → add) and the stale-snapshot retry.
|
|
393
|
+
* Consumers: enableCapability (peer-resolved verb), provisionPeer's birth-time
|
|
394
|
+
* memory-plugin hook, and the `memory-plugin` verb — ONE home of the install forms.
|
|
346
395
|
*/
|
|
347
|
-
export function
|
|
348
|
-
const env =
|
|
349
|
-
const
|
|
350
|
-
const targetRuntimes = opts.runtimes ?? peer.runtimes
|
|
396
|
+
export function enableCapabilityForCwd(o: CwdEnableOptions): EnableResult {
|
|
397
|
+
const env = o.env ?? process.env
|
|
398
|
+
const marketplace = o.marketplace ?? MARKETPLACE_NAME
|
|
351
399
|
const results: RuntimeEnableResult[] = []
|
|
352
|
-
for (const rt of
|
|
353
|
-
|
|
400
|
+
for (const rt of o.runtimes) {
|
|
401
|
+
// runtime-missing wins FIRST (before any marketplace work): a host without
|
|
402
|
+
// the runtime binary is a clean skip, not a marketplace-add failure.
|
|
403
|
+
if (!isExecutable(rt === 'claude' ? claudeBin(env) : codexBin(env), env)) {
|
|
404
|
+
results.push({ runtime: rt, state: 'runtime-missing' })
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
// Marketplace ensure — only when the caller supplied a source ref (the slot's
|
|
408
|
+
// marketplaceRef); the generic agfpd path is guaranteed by onboard already.
|
|
409
|
+
if (o.marketplaceRef && !isMarketplaceRegisteredAs(rt, marketplace, o.marketplaceRef, env)) {
|
|
410
|
+
const reg = registerMarketplace(rt, env, o.marketplaceRef)
|
|
411
|
+
if (!reg.ok) {
|
|
412
|
+
results.push({ runtime: rt, state: 'failed', detail: `marketplace add ${o.marketplaceRef} failed: ${reg.detail ?? ''}` })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
results.push(enableRuntimeWithRetry(rt, o.plugin, marketplace, o.cwd, env))
|
|
354
417
|
}
|
|
355
418
|
|
|
356
419
|
// setup: read the manifest from whichever runtime gave us an installPath (the source
|
|
@@ -361,21 +424,98 @@ export function enableCapability(opts: EnableOptions): EnableResult {
|
|
|
361
424
|
const setup = readSetupDescriptor(installPath)
|
|
362
425
|
const anyOk = results.some(r => r.state === 'installed' || r.state === 'enabled' || r.state === 'already-enabled')
|
|
363
426
|
if (setup && anyOk) {
|
|
364
|
-
if (
|
|
427
|
+
if (o.noSetup) {
|
|
365
428
|
setupState = 'skipped'
|
|
366
429
|
} else {
|
|
367
|
-
const s = callSetup(setup, installPath as string,
|
|
430
|
+
const s = callSetup(setup, installPath as string, { personality: o.personality, cwd: o.cwd }, env)
|
|
368
431
|
setupState = s.ok ? 'called' : 'failed'
|
|
369
432
|
setupDetail = s.detail
|
|
370
433
|
}
|
|
371
434
|
}
|
|
372
435
|
|
|
373
436
|
return {
|
|
374
|
-
plugin:
|
|
375
|
-
personality:
|
|
376
|
-
cwd:
|
|
437
|
+
plugin: o.plugin,
|
|
438
|
+
personality: o.personality,
|
|
439
|
+
cwd: o.cwd,
|
|
377
440
|
runtimes: results,
|
|
378
441
|
setup: setupState,
|
|
379
442
|
setupDetail,
|
|
380
443
|
}
|
|
381
444
|
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Enable a capability plugin on a peer (contract Установка §3): install per-runtime
|
|
448
|
+
* (claude project-scope / codex global) + enable + call `setup` ONLY if the plugin
|
|
449
|
+
* declares it. Idempotent and fleet-safe — a peer already enabled is a no-op; the
|
|
450
|
+
* claude path is keyed by the peer's projectPath so it never touches another peer.
|
|
451
|
+
* Thin peer-resolving wrapper over enableCapabilityForCwd (the ONE forms home).
|
|
452
|
+
*/
|
|
453
|
+
export function enableCapability(opts: EnableOptions): EnableResult {
|
|
454
|
+
const env = opts.env ?? process.env
|
|
455
|
+
const peer = resolvePeer(opts)
|
|
456
|
+
return enableCapabilityForCwd({
|
|
457
|
+
plugin: opts.plugin,
|
|
458
|
+
cwd: peer.cwd,
|
|
459
|
+
personality: peer.personality,
|
|
460
|
+
runtimes: opts.runtimes ?? peer.runtimes,
|
|
461
|
+
noSetup: opts.noSetup,
|
|
462
|
+
env,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
467
|
+
// Removal forms (контракт §Плагин провайдера, off-direction) — verb names
|
|
468
|
+
// verified live 10.06: claude `plugin uninstall <id> --scope project` (run in the
|
|
469
|
+
// peer cwd), codex `plugin remove <id>` (host-global).
|
|
470
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
export type RuntimeRemoveState = 'removed' | 'absent' | 'runtime-missing' | 'failed'
|
|
473
|
+
|
|
474
|
+
export interface RuntimeRemoveResult {
|
|
475
|
+
runtime: CapabilityRuntime
|
|
476
|
+
state: RuntimeRemoveState
|
|
477
|
+
detail?: string
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** claude: uninstall the peer-scoped (projectPath-keyed) entry — fleet-safe by the
|
|
481
|
+
* same key enable uses; a peer without an entry is 'absent' (idempotent). */
|
|
482
|
+
export function removeClaudeForCwd(
|
|
483
|
+
plugin: string,
|
|
484
|
+
marketplace: string,
|
|
485
|
+
peerCwd: string,
|
|
486
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
487
|
+
): RuntimeRemoveResult {
|
|
488
|
+
const bin = claudeBin(env)
|
|
489
|
+
if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
|
|
490
|
+
const id = `${plugin}@${marketplace}`
|
|
491
|
+
const existing = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
492
|
+
if (!existing) return { runtime: 'claude', state: 'absent' }
|
|
493
|
+
const r = spawnSync(bin, ['plugin', 'uninstall', id, '--scope', 'project'], {
|
|
494
|
+
cwd: peerCwd,
|
|
495
|
+
encoding: 'utf8',
|
|
496
|
+
env: env as Record<string, string>,
|
|
497
|
+
})
|
|
498
|
+
const after = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
499
|
+
if (after) {
|
|
500
|
+
return { runtime: 'claude', state: 'failed', detail: (r.stderr || r.stdout || `exit ${r.status}`).trim() }
|
|
501
|
+
}
|
|
502
|
+
return { runtime: 'claude', state: 'removed' }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** codex: remove the HOST-GLOBAL plugin (codex has no project scope) — callers gate
|
|
506
|
+
* this on an explicit fleet-wide intent (`memory-plugin off --all`). */
|
|
507
|
+
export function removeCodexGlobal(
|
|
508
|
+
plugin: string,
|
|
509
|
+
marketplace: string,
|
|
510
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
511
|
+
): RuntimeRemoveResult {
|
|
512
|
+
const bin = codexBin(env)
|
|
513
|
+
if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
|
|
514
|
+
const id = `${plugin}@${marketplace}`
|
|
515
|
+
if (codexState(bin, id, env) === 'absent') return { runtime: 'codex', state: 'absent' }
|
|
516
|
+
const r = spawnSync(bin, ['plugin', 'remove', id], { encoding: 'utf8', env: env as Record<string, string> })
|
|
517
|
+
if (codexState(bin, id, env) !== 'absent') {
|
|
518
|
+
return { runtime: 'codex', state: 'failed', detail: (r.stderr || r.stdout || `exit ${r.status}`).trim() }
|
|
519
|
+
}
|
|
520
|
+
return { runtime: 'codex', state: 'removed' }
|
|
521
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// memory-plugin — slot-derived gating + target resolution + codex host-global
|
|
2
|
+
// semantics (контракт §Плагин провайдера). Install forms are NOT exercised here
|
|
3
|
+
// (they spawn runtime CLIs); the bins are pointed at /nonexistent so every
|
|
4
|
+
// runtime resolves 'runtime-missing' — the orchestration around them is what
|
|
5
|
+
// these tests pin.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { upsertPeer } from '../registry/index.ts'
|
|
12
|
+
import { memoryProviderPath } from '../status/index.ts'
|
|
13
|
+
import { memoryPluginApply } from './memoryPlugin.ts'
|
|
14
|
+
|
|
15
|
+
let root: string
|
|
16
|
+
function env(): NodeJS.ProcessEnv {
|
|
17
|
+
const e: NodeJS.ProcessEnv = {
|
|
18
|
+
...process.env,
|
|
19
|
+
IAPEER_ROOT: root,
|
|
20
|
+
IAPEER_CLAUDE_BIN: '/nonexistent/claude',
|
|
21
|
+
IAPEER_CODEX_BIN: '/nonexistent/codex',
|
|
22
|
+
}
|
|
23
|
+
delete e.PEER_PERSONALITY
|
|
24
|
+
delete e.PEER_IDENTITY
|
|
25
|
+
delete e.PEER_RUNTIME
|
|
26
|
+
return e
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-memplugin-'))
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(root, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const DECLARATION = {
|
|
37
|
+
provider: 'iapeer-memory',
|
|
38
|
+
package: '@agfpd/iapeer-memory',
|
|
39
|
+
version: '0.1.0',
|
|
40
|
+
registeredAt: '2026-06-10T00:00:00Z',
|
|
41
|
+
}
|
|
42
|
+
const PLUGIN = { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' }
|
|
43
|
+
|
|
44
|
+
function declareSlot(withPlugin: boolean): void {
|
|
45
|
+
writeFileSync(memoryProviderPath(env()), JSON.stringify(withPlugin ? { ...DECLARATION, plugin: PLUGIN } : DECLARATION))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('memory-plugin gating (slot-derived)', () => {
|
|
49
|
+
test('EMPTY slot → explicit refusal, no outcomes', () => {
|
|
50
|
+
const r = memoryPluginApply('on', { all: true, env: env() })
|
|
51
|
+
expect(r.ok).toBe(false)
|
|
52
|
+
expect(r.error).toContain('EMPTY')
|
|
53
|
+
expect(r.outcomes).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('v1 declaration (no plugin block) → explicit refusal pointing at generic enable', () => {
|
|
57
|
+
declareSlot(false)
|
|
58
|
+
const r = memoryPluginApply('on', { all: true, env: env() })
|
|
59
|
+
expect(r.ok).toBe(false)
|
|
60
|
+
expect(r.error).toContain('declares no plugin block')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('unknown --peer → explicit refusal', async () => {
|
|
64
|
+
declareSlot(true)
|
|
65
|
+
const r = memoryPluginApply('on', { peer: 'ghost', env: env() })
|
|
66
|
+
expect(r.ok).toBe(false)
|
|
67
|
+
expect(r.error).toContain('not in the iapeer peers index')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('on --all: plugin derived from the slot; runtime-missing host → clean per-runtime outcomes, ok', async () => {
|
|
71
|
+
declareSlot(true)
|
|
72
|
+
await upsertPeer({ personality: 'alpha', runtime: 'claude', cwd: join(root, 'alpha'), intelligence: 'artificial' }, { rootDir: root })
|
|
73
|
+
await upsertPeer({ personality: 'tim', runtime: 'notifier', cwd: join(root, 'tim'), intelligence: 'absent' }, { rootDir: root })
|
|
74
|
+
const r = memoryPluginApply('on', { all: true, env: env() })
|
|
75
|
+
expect(r.plugin).toEqual(PLUGIN)
|
|
76
|
+
expect(r.ok).toBe(true) // runtime-missing is a skip, not a failure
|
|
77
|
+
const alpha = r.outcomes.find(o => o.personality === 'alpha')
|
|
78
|
+
expect(alpha?.state).toBe('runtime-missing')
|
|
79
|
+
// a peer with NO agentic runtime is skipped explicitly
|
|
80
|
+
const tim = r.outcomes.find(o => o.personality === 'tim')
|
|
81
|
+
expect(tim?.state).toBe('skipped-no-agentic-runtime')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('off --peer on a codex peer → explicit host-global message, NOT a silent skip', async () => {
|
|
85
|
+
declareSlot(true)
|
|
86
|
+
await upsertPeer({ personality: 'cx', runtime: 'codex', cwd: join(root, 'cx'), intelligence: 'artificial' }, { rootDir: root })
|
|
87
|
+
const r = memoryPluginApply('off', { peer: 'cx', env: env() })
|
|
88
|
+
const o = r.outcomes.find(x => x.personality === 'cx' && x.runtime === 'codex')
|
|
89
|
+
expect(o?.state).toBe('skipped-host-global')
|
|
90
|
+
expect(o?.detail).toContain('--all')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('off --all: codex host-global remove runs ONCE across the fleet', async () => {
|
|
94
|
+
declareSlot(true)
|
|
95
|
+
await upsertPeer({ personality: 'cx1', runtime: 'codex', cwd: join(root, 'cx1'), intelligence: 'artificial' }, { rootDir: root })
|
|
96
|
+
await upsertPeer({ personality: 'cx2', runtime: 'codex', cwd: join(root, 'cx2'), intelligence: 'artificial' }, { rootDir: root })
|
|
97
|
+
const r = memoryPluginApply('off', { all: true, env: env() })
|
|
98
|
+
const codexOutcomes = r.outcomes.filter(x => x.runtime === 'codex')
|
|
99
|
+
// one real attempt (runtime-missing on this sandbox) + one 'already' dedupe
|
|
100
|
+
expect(codexOutcomes.filter(x => x.state === 'runtime-missing').length).toBe(1)
|
|
101
|
+
expect(codexOutcomes.filter(x => x.state === 'already').length).toBe(1)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// memory-plugin — the slot-derived operator verb (контракт «Слот памяти»
|
|
2
|
+
// §Плагин провайдера — авто-установка при рождении; agreed iapeer-memory + boris
|
|
3
|
+
// 10.06). By analogy with native-memory: `iapeer memory-plugin <on|off>
|
|
4
|
+
// (--peer <p> | --all)`. The plugin identity is DERIVED from the slot
|
|
5
|
+
// declaration's v1.1 `plugin` block — an empty slot or a v1 declaration without
|
|
6
|
+
// the block is an EXPLICIT refusal (for arbitrary plugins the generic `iapeer
|
|
7
|
+
// enable` exists). Install/removal forms live in enable/index.ts (the ONE home);
|
|
8
|
+
// this module only orchestrates targets and the codex host-global semantics.
|
|
9
|
+
//
|
|
10
|
+
// Consumers: the operator, the provider's init-sweep (`on --all` after writing
|
|
11
|
+
// the declaration), and the provider's uninstall (`off --all` BEFORE deleting
|
|
12
|
+
// the declaration — agreed AUTO-removal: a dead provider's plugin actively
|
|
13
|
+
// errors in every session, unlike the native-memory restore asymmetry).
|
|
14
|
+
|
|
15
|
+
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
16
|
+
import { readMemoryProvider, type MemoryProviderPlugin } from '../status/index.ts'
|
|
17
|
+
import {
|
|
18
|
+
enableCapabilityForCwd,
|
|
19
|
+
removeClaudeForCwd,
|
|
20
|
+
removeCodexGlobal,
|
|
21
|
+
type CapabilityRuntime,
|
|
22
|
+
} from './index.ts'
|
|
23
|
+
|
|
24
|
+
export interface MemoryPluginOutcome {
|
|
25
|
+
personality: string
|
|
26
|
+
runtime: CapabilityRuntime | 'none'
|
|
27
|
+
/** enable states (on) | remove states (off) | orchestration-level skips. */
|
|
28
|
+
state: string
|
|
29
|
+
detail?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MemoryPluginResult {
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** The derived plugin (null when the slot refused — see error). */
|
|
35
|
+
plugin: MemoryProviderPlugin | null
|
|
36
|
+
/** Refusal reason (empty slot / v1 declaration / no targets). */
|
|
37
|
+
error?: string
|
|
38
|
+
outcomes: MemoryPluginOutcome[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MemoryPluginOptions {
|
|
42
|
+
peer?: string
|
|
43
|
+
all?: boolean
|
|
44
|
+
env?: NodeJS.ProcessEnv
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Apply the slot-declared provider plugin across targets. `on` = install per the
|
|
49
|
+
* canonical forms (claude project-scope per peer cwd; codex host-global once —
|
|
50
|
+
* idempotent). `off` = claude per-peer project-scope uninstall; codex is
|
|
51
|
+
* HOST-GLOBAL, so off with `--peer` on a codex runtime reports an explicit
|
|
52
|
+
* «host-global — use --all» (no silent skip, сверка boris) and the global remove
|
|
53
|
+
* runs only under `--all` (once).
|
|
54
|
+
*/
|
|
55
|
+
export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions = {}): MemoryPluginResult {
|
|
56
|
+
const env = opts.env ?? process.env
|
|
57
|
+
const slot = readMemoryProvider(env)
|
|
58
|
+
if (!slot) {
|
|
59
|
+
return { ok: false, plugin: null, error: 'memory slot is EMPTY — no provider declared, nothing to install', outcomes: [] }
|
|
60
|
+
}
|
|
61
|
+
if (!slot.plugin) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
plugin: null,
|
|
65
|
+
error: `provider "${slot.provider}" declares no plugin block (declaration v1) — for arbitrary plugins use \`iapeer enable\``,
|
|
66
|
+
outcomes: [],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const plugin = slot.plugin
|
|
70
|
+
const index = readPeersIndex({ env })
|
|
71
|
+
const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
|
|
72
|
+
if (targets.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
plugin,
|
|
76
|
+
error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
|
|
77
|
+
outcomes: [],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outcomes: MemoryPluginOutcome[] = []
|
|
82
|
+
let codexHandledGlobally = false
|
|
83
|
+
for (const p of targets) {
|
|
84
|
+
const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
85
|
+
if (agentic.length === 0) {
|
|
86
|
+
outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (state === 'on') {
|
|
90
|
+
const r = enableCapabilityForCwd({
|
|
91
|
+
plugin: plugin.name,
|
|
92
|
+
marketplace: plugin.marketplace,
|
|
93
|
+
marketplaceRef: plugin.marketplaceRef,
|
|
94
|
+
cwd: p.cwd,
|
|
95
|
+
personality: p.personality,
|
|
96
|
+
runtimes: agentic,
|
|
97
|
+
env,
|
|
98
|
+
})
|
|
99
|
+
for (const rr of r.runtimes) {
|
|
100
|
+
outcomes.push({ personality: p.personality, runtime: rr.runtime, state: rr.state, detail: rr.detail })
|
|
101
|
+
}
|
|
102
|
+
if (r.setup !== 'absent') {
|
|
103
|
+
outcomes.push({ personality: p.personality, runtime: agentic[0]!, state: `setup-${r.setup}`, detail: r.setupDetail })
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
for (const rt of agentic) {
|
|
107
|
+
if (rt === 'claude') {
|
|
108
|
+
const rr = removeClaudeForCwd(plugin.name, plugin.marketplace, p.cwd, env)
|
|
109
|
+
outcomes.push({ personality: p.personality, runtime: 'claude', state: rr.state, detail: rr.detail })
|
|
110
|
+
} else {
|
|
111
|
+
// codex plugin is HOST-GLOBAL: per-peer off is impossible — explicit
|
|
112
|
+
// message (сверка boris), the actual remove runs ONCE under --all.
|
|
113
|
+
if (opts.all !== true) {
|
|
114
|
+
outcomes.push({
|
|
115
|
+
personality: p.personality,
|
|
116
|
+
runtime: 'codex',
|
|
117
|
+
state: 'skipped-host-global',
|
|
118
|
+
detail: 'codex plugin is host-global — use `memory-plugin off --all`',
|
|
119
|
+
})
|
|
120
|
+
} else if (!codexHandledGlobally) {
|
|
121
|
+
codexHandledGlobally = true
|
|
122
|
+
const rr = removeCodexGlobal(plugin.name, plugin.marketplace, env)
|
|
123
|
+
outcomes.push({ personality: p.personality, runtime: 'codex', state: rr.state, detail: rr.detail })
|
|
124
|
+
} else {
|
|
125
|
+
outcomes.push({ personality: p.personality, runtime: 'codex', state: 'already', detail: 'host-global remove already done this run' })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const failed = outcomes.some(o => o.state === 'failed' || o.state === 'setup-failed')
|
|
132
|
+
return { ok: !failed, plugin, outcomes }
|
|
133
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -79,6 +79,16 @@ export interface LifecycleConfig {
|
|
|
79
79
|
* transcript mtime is a LIVENESS proxy — "no longer writing" — not a semantic
|
|
80
80
|
* "done" signal. */
|
|
81
81
|
ephemeralQuietSecs: number
|
|
82
|
+
/** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
|
|
83
|
+
* that never armed (finished silently / lost its arm to a daemon-restart window)
|
|
84
|
+
* is reaped after this much activity-proxy silence. Live case (scriber 10.06):
|
|
85
|
+
* a worker that ended «тихо» without its final outbound stalled its M3 FIFO for
|
|
86
|
+
* the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
|
|
87
|
+
* conveyor per silent worker. Bound chosen ≫ the legitimate silent-tool case
|
|
88
|
+
* (sleep-180) and ≪ idleSecs. DOCUMENTED RISK: an ephemeral worker whose tool
|
|
89
|
+
* stays silent longer than this MID-TASK is reaped and its consumed queue item
|
|
90
|
+
* is lost — ephemeral workers must emit activity (or their reply) within it. */
|
|
91
|
+
ephemeralUnarmedIdleSecs: number
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): LifecycleConfig {
|
|
@@ -102,6 +112,7 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
|
|
|
102
112
|
crashLoopMax: num(env.IAPEER_CRASHLOOP_MAX, 3),
|
|
103
113
|
crashLoopWindowSecs: num(env.IAPEER_CRASHLOOP_WINDOW_SECS, 300),
|
|
104
114
|
ephemeralQuietSecs: num(env.IAPEER_EPHEMERAL_QUIET_SECS, 20),
|
|
115
|
+
ephemeralUnarmedIdleSecs: num(env.IAPEER_EPHEMERAL_UNARMED_IDLE_SECS, 600),
|
|
105
116
|
}
|
|
106
117
|
}
|
|
107
118
|
|
|
@@ -1140,6 +1151,30 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1140
1151
|
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
|
|
1141
1152
|
continue
|
|
1142
1153
|
}
|
|
1154
|
+
// UNARMED ephemeral idle bound (live case scriber 10.06): a worker that ended
|
|
1155
|
+
// SILENTLY (no final outbound → never armed; or its arm was lost to a CLI/
|
|
1156
|
+
// daemon-restart window) used to wait out the FULL generic idleSecs (1 h) —
|
|
1157
|
+
// and the M3 serial drain waits for the session's death, so ONE silent worker
|
|
1158
|
+
// stalled its whole conveyor. This bound is the defense-in-depth backstop:
|
|
1159
|
+
// ≫ the legitimate silent-tool case (sleep-180), ≪ idleSecs. The ШТАТНЫЙ
|
|
1160
|
+
// silent-finish path is `iapeer self-done` (arm without waking anyone —
|
|
1161
|
+
// Артур's invariant «нет пустых пробуждений» stays intact); this branch only
|
|
1162
|
+
// bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
|
|
1163
|
+
// (ephemeral never resumes), NO recordDeath (the ring counts faults).
|
|
1164
|
+
if (isEphemeralPeer(s.cwd) && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1165
|
+
killSession(sock, s.identity)
|
|
1166
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1167
|
+
removeSessionState(cfg, s.identity)
|
|
1168
|
+
out.push({
|
|
1169
|
+
identity: s.identity,
|
|
1170
|
+
action: 'reaped-ephemeral',
|
|
1171
|
+
reason: `unarmed idle ${ageSecs}s (silent-finish backstop; штатный путь — iapeer self-done)`,
|
|
1172
|
+
personality: s.personality,
|
|
1173
|
+
runtime: s.runtime,
|
|
1174
|
+
})
|
|
1175
|
+
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
|
|
1176
|
+
continue
|
|
1177
|
+
}
|
|
1143
1178
|
if (ageSecs > cfg.idleSecs) {
|
|
1144
1179
|
// THE ONLY place .idle-reaped is written: this is the one death the daemon
|
|
1145
1180
|
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
@@ -802,6 +802,69 @@ describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
|
|
|
802
802
|
},
|
|
803
803
|
30000,
|
|
804
804
|
)
|
|
805
|
+
|
|
806
|
+
test.if(tmuxAvailable)(
|
|
807
|
+
'UNARMED ephemeral past the unarmed idle bound → reaped-ephemeral (silent-finish backstop; live case scriber 10.06)',
|
|
808
|
+
() => {
|
|
809
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-eu-root-'))
|
|
810
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eu-la-'))
|
|
811
|
+
const cwd = profileCwd(false, true) // ephemeral worker profile
|
|
812
|
+
const env = {
|
|
813
|
+
...process.env,
|
|
814
|
+
IAPEER_ROOT: root,
|
|
815
|
+
IAPEER_LAUNCHAGENTS_DIR: laDir,
|
|
816
|
+
IAPEER_SOCK_DIR: join(root, 'socks'),
|
|
817
|
+
IAPEER_EPHEMERAL_UNARMED_IDLE_SECS: '30', // ≪ the 60s age below, ≫ quiet 20s
|
|
818
|
+
}
|
|
819
|
+
const cfg = loadLifecycleConfig(env)
|
|
820
|
+
const identity = 'claude-eu'
|
|
821
|
+
const sock = join(root, 'socks', 'tmux-iap-claude-eu.sock')
|
|
822
|
+
const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
|
|
823
|
+
try {
|
|
824
|
+
mkdirSync(join(root, 'socks'), { recursive: true })
|
|
825
|
+
spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
|
|
826
|
+
expect(alive()).toBe(true)
|
|
827
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
828
|
+
writeFileSync(
|
|
829
|
+
join(cfg.stateDir, `${identity}.session`),
|
|
830
|
+
JSON.stringify({ identity, runtime: 'claude', personality: 'eu', cwd, wokeAt: Date.now() - 60_000 }),
|
|
831
|
+
)
|
|
832
|
+
// NOT armed (the worker ended silently) — past the unarmed bound → policy reap
|
|
833
|
+
const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
|
|
834
|
+
expect(o?.action).toBe('reaped-ephemeral')
|
|
835
|
+
expect(o?.reason).toContain('unarmed idle')
|
|
836
|
+
expect(o?.personality).toBe('eu') // M3 drain fields present → queue feeds next
|
|
837
|
+
expect(alive()).toBe(false)
|
|
838
|
+
// policy death: no resume-eligibility, no crash-loop count
|
|
839
|
+
expect(hasIdleReaped(cfg, identity)).toBe(false)
|
|
840
|
+
expect(readDeaths(cfg, identity).length).toBe(0)
|
|
841
|
+
const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
842
|
+
expect(logged).toContain('outcome=ephemeral-unarmed-bound')
|
|
843
|
+
// a NON-ephemeral peer with the same age is untouched by this bound
|
|
844
|
+
// (its session lives on ITS OWN identity-derived socket — the tick keys
|
|
845
|
+
// sockets on runtime-personality, not on the test's prior sock)
|
|
846
|
+
const plainCwd = profileCwd(false, false)
|
|
847
|
+
const eupSock = join(root, 'socks', 'tmux-iap-claude-eup.sock')
|
|
848
|
+
try {
|
|
849
|
+
writeFileSync(
|
|
850
|
+
join(cfg.stateDir, `claude-eup.session`),
|
|
851
|
+
JSON.stringify({ identity: 'claude-eup', runtime: 'claude', personality: 'eup', cwd: plainCwd, wokeAt: Date.now() - 60_000 }),
|
|
852
|
+
)
|
|
853
|
+
spawnSync('tmux', ['-S', eupSock, 'new-session', '-d', '-s', 'claude-eup', 'sleep', '300'])
|
|
854
|
+
expect(superviseTick(cfg, { env }).find(x => x.identity === 'claude-eup')?.action).toBe('alive')
|
|
855
|
+
} finally {
|
|
856
|
+
spawnSync('tmux', ['-S', eupSock, 'kill-server'], { stdio: 'ignore' })
|
|
857
|
+
rmSync(plainCwd, { recursive: true, force: true })
|
|
858
|
+
}
|
|
859
|
+
} finally {
|
|
860
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
861
|
+
rmSync(root, { recursive: true, force: true })
|
|
862
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
863
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
30000,
|
|
867
|
+
)
|
|
805
868
|
})
|
|
806
869
|
|
|
807
870
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/onboard/index.ts
CHANGED
|
@@ -105,25 +105,71 @@ export function isMarketplaceRegistered(runtime: OnboardRuntime, env: NodeJS.Pro
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Pure detector over a `plugin marketplace list` output: is
|
|
109
|
-
* Matches the
|
|
110
|
-
*
|
|
108
|
+
* Pure detector over a `plugin marketplace list` output: is a marketplace present?
|
|
109
|
+
* Matches the GitHub source-ref (claude renders it) OR a standalone name entry
|
|
110
|
+
* (both runtimes render the name). The name match is anchored to a line start
|
|
111
111
|
* (optionally after the `❯` selection glyph) and followed by whitespace/EOL, so a
|
|
112
|
-
* different
|
|
112
|
+
* different `<name>-<something>` token never false-positives. Pure → unit-testable
|
|
113
113
|
* against real claude/codex samples (the fleet-guard hinges on it).
|
|
114
114
|
*/
|
|
115
|
+
export function isMarketplaceInList(listOutput: string, name: string, ref?: string): boolean {
|
|
116
|
+
if (ref && new RegExp(ref.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).test(listOutput)) return true
|
|
117
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
|
118
|
+
return new RegExp(`(^|\\n)\\s*(❯\\s*)?${esc}(\\s|$)`).test(listOutput)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The OUR-marketplace specialization (the original onboard detector). */
|
|
115
122
|
export function isAgfpdInList(listOutput: string): boolean {
|
|
116
|
-
|
|
117
|
-
return /(^|\n)\s*(❯\s*)?agfpd(\s|$)/.test(listOutput)
|
|
123
|
+
return isMarketplaceInList(listOutput, MARKETPLACE_NAME, MARKETPLACE_REF)
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
/** Register
|
|
121
|
-
|
|
126
|
+
/** Register a marketplace for this runtime (`<runtime> plugin marketplace add <ref>`).
|
|
127
|
+
* Exported for the memory-plugin install path (контракт §Плагин провайдера: a
|
|
128
|
+
* third-party provider's marketplaceRef must self-register on a fresh host). */
|
|
129
|
+
export function registerMarketplace(
|
|
130
|
+
runtime: OnboardRuntime,
|
|
131
|
+
env: NodeJS.ProcessEnv,
|
|
132
|
+
ref: string = MARKETPLACE_REF,
|
|
133
|
+
): { ok: boolean; detail?: string } {
|
|
122
134
|
const bin = runtimeBin(runtime, env)
|
|
123
135
|
// Same hard timeout as the list probe (the pre-main wedge class — known live
|
|
124
136
|
// representative: macOS launch-approval pending after a cask update) — a wedged
|
|
125
137
|
// add degrades to a loud 'failed' line instead of freezing the host phase.
|
|
126
|
-
const r = spawnSync(bin, ['plugin', 'marketplace', 'add',
|
|
138
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', 'add', ref], { encoding: 'utf8', timeout: 120_000 })
|
|
139
|
+
return r.status === 0
|
|
140
|
+
? { ok: true }
|
|
141
|
+
: { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (wedged runtime CLI?)' : `exit ${r.status}`) }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Is a marketplace (by name/ref) registered for this runtime? Generalized form of
|
|
145
|
+
* isMarketplaceRegistered for the memory-plugin install path. */
|
|
146
|
+
export function isMarketplaceRegisteredAs(
|
|
147
|
+
runtime: OnboardRuntime,
|
|
148
|
+
name: string,
|
|
149
|
+
ref: string | undefined,
|
|
150
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
151
|
+
): boolean {
|
|
152
|
+
const bin = runtimeBin(runtime, env)
|
|
153
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8', timeout: 60_000 })
|
|
154
|
+
if (r.status !== 0) return false
|
|
155
|
+
return isMarketplaceInList(`${r.stdout ?? ''}`, name, ref)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Refresh a runtime's local marketplace SNAPSHOT (контракт §Плагин провайдера,
|
|
160
|
+
* просьба iapeer-memory): a plugin registered in the marketplace AFTER the host's
|
|
161
|
+
* last pull reads as «unknown plugin» until the snapshot updates. Verb names
|
|
162
|
+
* verified live 10.06: claude `plugin marketplace update <name>`, codex
|
|
163
|
+
* `plugin marketplace upgrade <name>`.
|
|
164
|
+
*/
|
|
165
|
+
export function refreshMarketplace(
|
|
166
|
+
runtime: OnboardRuntime,
|
|
167
|
+
name: string,
|
|
168
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
169
|
+
): { ok: boolean; detail?: string } {
|
|
170
|
+
const bin = runtimeBin(runtime, env)
|
|
171
|
+
const verb = runtime === 'claude' ? 'update' : 'upgrade'
|
|
172
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', verb, name], { encoding: 'utf8', timeout: 120_000 })
|
|
127
173
|
return r.status === 0
|
|
128
174
|
? { ok: true }
|
|
129
175
|
: { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (wedged runtime CLI?)' : `exit ${r.status}`) }
|
package/src/provision/index.ts
CHANGED
|
@@ -98,7 +98,8 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
98
98
|
// the provision — the operator verb `iapeer native-memory off` is the repair).
|
|
99
99
|
try {
|
|
100
100
|
const { readMemoryProvider } = await import('../status/index.ts')
|
|
101
|
-
|
|
101
|
+
const slot = readMemoryProvider(env)
|
|
102
|
+
if (slot) {
|
|
102
103
|
const { applyNativeMemory, preTrustCodexCwd } = await import('../launch/nativeMemory.ts')
|
|
103
104
|
for (const o of applyNativeMemory(cwd, profile.runtimes, 'off')) {
|
|
104
105
|
if (o.state === 'failed') {
|
|
@@ -120,9 +121,41 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
120
121
|
)
|
|
121
122
|
}
|
|
122
123
|
}
|
|
124
|
+
// Provider PLUGIN auto-install (контракт §Плагин провайдера, declaration
|
|
125
|
+
// v1.1; agreed iapeer-memory + boris 10.06): an occupied slot WITH a plugin
|
|
126
|
+
// block installs the provider's marketplace plugin into the newborn's scope
|
|
127
|
+
// (claude project-scope in cwd; codex host-global, idempotent) — «создал
|
|
128
|
+
// пира — память работает». A v1 declaration (no block) installs nothing.
|
|
129
|
+
// Best-effort with a LOUD warn; repair = `iapeer memory-plugin on --peer`.
|
|
130
|
+
if (slot.plugin) {
|
|
131
|
+
const { enableCapabilityForCwd } = await import('../enable/index.ts')
|
|
132
|
+
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
133
|
+
if (agentic.length > 0) {
|
|
134
|
+
const r = enableCapabilityForCwd({
|
|
135
|
+
plugin: slot.plugin.name,
|
|
136
|
+
marketplace: slot.plugin.marketplace,
|
|
137
|
+
marketplaceRef: slot.plugin.marketplaceRef,
|
|
138
|
+
cwd,
|
|
139
|
+
personality: profile.personality,
|
|
140
|
+
runtimes: agentic,
|
|
141
|
+
env,
|
|
142
|
+
})
|
|
143
|
+
for (const rr of r.runtimes) {
|
|
144
|
+
if (rr.state === 'failed') {
|
|
145
|
+
opts.warn?.(
|
|
146
|
+
`memory-plugin install (${rr.runtime}) FAILED for "${profile.personality}": ${rr.detail} — ` +
|
|
147
|
+
`repair: iapeer memory-plugin on --peer ${profile.personality}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (r.setup === 'failed') {
|
|
152
|
+
opts.warn?.(`memory-plugin setup FAILED for "${profile.personality}": ${r.setupDetail ?? ''}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
123
156
|
}
|
|
124
157
|
} catch (e) {
|
|
125
|
-
opts.warn?.(`
|
|
158
|
+
opts.warn?.(`memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
126
159
|
}
|
|
127
160
|
|
|
128
161
|
// wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
|
|
@@ -198,4 +198,29 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
198
198
|
expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(false)
|
|
199
199
|
expect(existsSync(join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
200
200
|
})
|
|
201
|
+
|
|
202
|
+
test('slot with v1.1 plugin block → birth hook attempts the plugin install; runtime-missing host stays silent and provision SUCCEEDS', async () => {
|
|
203
|
+
const root = mkTmp()
|
|
204
|
+
const env = envFor(root)
|
|
205
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
provider: 'iapeer-memory',
|
|
210
|
+
package: '@agfpd/iapeer-memory',
|
|
211
|
+
version: '0.1.0',
|
|
212
|
+
registeredAt: 'x',
|
|
213
|
+
plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
const cwd = join(root, 'wplug')
|
|
217
|
+
const warns: string[] = []
|
|
218
|
+
// HOME-scoped claude bin + no codex on PATH → both runtimes 'runtime-missing':
|
|
219
|
+
// a clean skip (host without the runtime), NOT a failure warn, and never a throw.
|
|
220
|
+
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
221
|
+
expect(r.personality).toBe('wplug')
|
|
222
|
+
expect(warns.filter(w => w.includes('memory-plugin install'))).toEqual([])
|
|
223
|
+
// the native lever still applied (same slot-gated block)
|
|
224
|
+
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
225
|
+
})
|
|
201
226
|
})
|
package/src/status/index.ts
CHANGED
|
@@ -16,6 +16,22 @@ import { waitForDaemonHealthy } from '../update/index.ts'
|
|
|
16
16
|
/** The slot-declaration filename in the storage root (next to peers-profiles.json). */
|
|
17
17
|
export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
|
|
18
18
|
|
|
19
|
+
/** The provider's marketplace plugin (declaration v1.1, контракт §Плагин
|
|
20
|
+
* провайдера — agreed with iapeer-memory + boris 10.06). Declares WHAT the core
|
|
21
|
+
* auto-installs into a newborn peer's scope (and what `memory-plugin` targets).
|
|
22
|
+
* All three fields are required when the block is present; an invalid block is
|
|
23
|
+
* treated as ABSENT (fail-open, like the whole file) = v1 behavior, no install. */
|
|
24
|
+
export interface MemoryProviderPlugin {
|
|
25
|
+
/** Plugin id in the marketplace (forms `<name>@<marketplace>` for installs). */
|
|
26
|
+
name: string
|
|
27
|
+
/** Marketplace NAME the plugin id keys on (e.g. "agfpd"). */
|
|
28
|
+
marketplace: string
|
|
29
|
+
/** Source ref for `plugin marketplace add` when the marketplace is not yet
|
|
30
|
+
* registered on the host (owner/repo or URL) — the third-party-provider path;
|
|
31
|
+
* for the distribution default it matches what onboard already adds. */
|
|
32
|
+
marketplaceRef: string
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export interface MemoryProvider {
|
|
20
36
|
/** Provider name occupying the slot (e.g. "iapeer-memory"). */
|
|
21
37
|
provider: string
|
|
@@ -26,6 +42,19 @@ export interface MemoryProvider {
|
|
|
26
42
|
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
27
43
|
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
28
44
|
heartbeat?: string
|
|
45
|
+
/** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin. */
|
|
46
|
+
plugin?: MemoryProviderPlugin
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse the optional v1.1 `plugin` block; anything short of three non-empty
|
|
50
|
+
* strings → undefined (treated as absent — v1 declaration, core installs nothing). */
|
|
51
|
+
function parsePluginBlock(raw: unknown): MemoryProviderPlugin | undefined {
|
|
52
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
53
|
+
const o = raw as Record<string, unknown>
|
|
54
|
+
if (typeof o.name !== 'string' || !o.name.trim()) return undefined
|
|
55
|
+
if (typeof o.marketplace !== 'string' || !o.marketplace.trim()) return undefined
|
|
56
|
+
if (typeof o.marketplaceRef !== 'string' || !o.marketplaceRef.trim()) return undefined
|
|
57
|
+
return { name: o.name.trim(), marketplace: o.marketplace.trim(), marketplaceRef: o.marketplaceRef.trim() }
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -44,12 +73,14 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
44
73
|
if (typeof o.provider !== 'string' || !o.provider.trim()) return null
|
|
45
74
|
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
46
75
|
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
76
|
+
const plugin = parsePluginBlock(o.plugin)
|
|
47
77
|
return {
|
|
48
78
|
provider: o.provider.trim(),
|
|
49
79
|
package: o.package.trim(),
|
|
50
80
|
version: o.version.trim(),
|
|
51
81
|
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
52
82
|
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
83
|
+
...(plugin ? { plugin } : {}),
|
|
53
84
|
}
|
|
54
85
|
} catch {
|
|
55
86
|
return null // empty slot — bare core is valid
|
|
@@ -61,6 +61,26 @@ describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
|
61
61
|
expect(readMemoryProvider(env)).toBeNull()
|
|
62
62
|
}
|
|
63
63
|
})
|
|
64
|
+
|
|
65
|
+
test('v1.1 plugin block: parsed when complete; INVALID block = absent (v1 behavior), declaration still valid', () => {
|
|
66
|
+
const root = mkTmp()
|
|
67
|
+
const env = envFor(root)
|
|
68
|
+
const plugin = { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' }
|
|
69
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin }))
|
|
70
|
+
expect(readMemoryProvider(env)?.plugin).toEqual(plugin)
|
|
71
|
+
// incomplete / wrong-shape blocks → treated as ABSENT, the declaration itself stays valid
|
|
72
|
+
for (const bad of [
|
|
73
|
+
{ name: 'x', marketplace: 'agfpd' }, // missing marketplaceRef
|
|
74
|
+
{ name: '', marketplace: 'agfpd', marketplaceRef: 'r' }, // empty name
|
|
75
|
+
'iapeer-memory', // not an object
|
|
76
|
+
['x'], // array
|
|
77
|
+
]) {
|
|
78
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin: bad }))
|
|
79
|
+
const p = readMemoryProvider(env)
|
|
80
|
+
expect(p).not.toBeNull()
|
|
81
|
+
expect(p?.plugin).toBeUndefined()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
64
84
|
})
|
|
65
85
|
|
|
66
86
|
describe('heartbeatAgeSecs', () => {
|