@agfpd/iapeer 0.2.48 → 0.2.50
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 -1
- package/src/cli/index.ts +149 -20
- package/src/launch/index.ts +4 -0
- package/src/launch/launchd.ts +41 -15
- package/src/update/index.ts +62 -6
- package/src/update/update.test.ts +20 -3
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
7
7
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { addRuntime, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { addRuntime, compactPeer, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
12
|
import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
14
|
+
import { ok } from '../core/errors.ts'
|
|
14
15
|
|
|
15
16
|
let root: string
|
|
16
17
|
let laDir: string
|
|
@@ -452,6 +453,58 @@ describe('new (unconditional fresh restart)', () => {
|
|
|
452
453
|
})
|
|
453
454
|
})
|
|
454
455
|
|
|
456
|
+
// ─── compact-resume — control addresses the dialogue, not just a live session ──
|
|
457
|
+
|
|
458
|
+
describe('compact (resume sleeping dialogue)', () => {
|
|
459
|
+
test('clean idle-reaped asleep peer → explicit resume wake, then /compact control', async () => {
|
|
460
|
+
await register('sleepy', 'codex')
|
|
461
|
+
const e = env()
|
|
462
|
+
const cfg = loadLifecycleConfig(e)
|
|
463
|
+
setIdleReaped(cfg, 'codex-sleepy')
|
|
464
|
+
const wakeCalls: unknown[] = []
|
|
465
|
+
const controlCalls: unknown[] = []
|
|
466
|
+
const o = await compactPeer('sleepy', 'codex', {
|
|
467
|
+
env: e,
|
|
468
|
+
wakeFn: async args => {
|
|
469
|
+
wakeCalls.push(args)
|
|
470
|
+
return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
|
|
471
|
+
},
|
|
472
|
+
controlFn: (personality, runtime, command) => {
|
|
473
|
+
controlCalls.push({ personality, runtime, command })
|
|
474
|
+
return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
|
|
475
|
+
},
|
|
476
|
+
})
|
|
477
|
+
expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
|
|
478
|
+
expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
|
|
479
|
+
expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
|
|
480
|
+
expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
|
|
484
|
+
await register('freshy', 'codex')
|
|
485
|
+
let woke = false
|
|
486
|
+
const o = await compactPeer('freshy', 'codex', {
|
|
487
|
+
env: env(),
|
|
488
|
+
wakeFn: async () => ((woke = true), { status: 'READY', woke: true }),
|
|
489
|
+
})
|
|
490
|
+
expect(o.action).toBe('nothing-to-compact')
|
|
491
|
+
expect(o.reason).toContain('nothing to compact')
|
|
492
|
+
expect(woke).toBe(false)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test('idle marker but no transcript/session to resume → nothing-to-compact (not fresh)', async () => {
|
|
496
|
+
await register('empty', 'codex')
|
|
497
|
+
const e = env()
|
|
498
|
+
setIdleReaped(loadLifecycleConfig(e), 'codex-empty')
|
|
499
|
+
const o = await compactPeer('empty', 'codex', {
|
|
500
|
+
env: e,
|
|
501
|
+
wakeFn: async args => ({ status: 'FAILED', woke: false, runtime: args.runtime, reason: 'no codex session to resume' }),
|
|
502
|
+
})
|
|
503
|
+
expect(o.action).toBe('nothing-to-compact')
|
|
504
|
+
expect(o.reason).toContain('nothing to compact')
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
455
508
|
// ─── add-runtime / default-runtime — the fleet-switch levers (codex-parity) ───
|
|
456
509
|
|
|
457
510
|
describe('add-runtime / default-runtime (fleet-switch levers)', () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type Runtime,
|
|
21
21
|
} from '../core/constants.ts'
|
|
22
22
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
23
|
-
import { updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
|
|
23
|
+
import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
|
|
24
24
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
25
25
|
import { ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
26
26
|
import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
|
|
@@ -31,10 +31,12 @@ import {
|
|
|
31
31
|
clearNewEager,
|
|
32
32
|
clearStopped,
|
|
33
33
|
folderLaunch,
|
|
34
|
+
hasIdleReaped,
|
|
34
35
|
isLaunchdManaged,
|
|
35
36
|
isEphemeralPeer,
|
|
36
37
|
isStopped,
|
|
37
38
|
killSession,
|
|
39
|
+
lastActiveRuntime,
|
|
38
40
|
loadLifecycleConfig,
|
|
39
41
|
purgeIdentityState,
|
|
40
42
|
removeSessionState,
|
|
@@ -63,6 +65,22 @@ import { readMemoryProvider } from '../status/index.ts'
|
|
|
63
65
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
64
66
|
import { pluginLogsDir } from '../storage/index.ts'
|
|
65
67
|
|
|
68
|
+
function infraRecycleNote(infra: readonly { personality: string; state: string; detail?: string }[] | undefined): string {
|
|
69
|
+
if (!infra?.length) return ''
|
|
70
|
+
const touched = infra.filter(i => i.state === 'restarted').map(i => i.personality)
|
|
71
|
+
const failed = infra.filter(i => i.state === 'failed')
|
|
72
|
+
const passive = infra.filter(i => i.state !== 'restarted' && i.state !== 'failed')
|
|
73
|
+
const parts: string[] = []
|
|
74
|
+
if (touched.length) parts.push(`infra recycled: ${touched.join(', ')}`)
|
|
75
|
+
if (passive.length) parts.push(`infra ${passive.map(i => `${i.personality} ${i.state}`).join(', ')}`)
|
|
76
|
+
if (failed.length) parts.push(`infra FAILED: ${failed.map(i => `${i.personality}${i.detail ? ` (${i.detail})` : ''}`).join('; ')}`)
|
|
77
|
+
return parts.length ? `; ${parts.join('; ')}` : ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function infraRecycleFailed(infra: readonly { state: string }[] | undefined): boolean {
|
|
81
|
+
return (infra ?? []).some(i => i.state === 'failed')
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
85
|
// list — registry + per-runtime liveness (contract Примитивы §list)
|
|
68
86
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -487,6 +505,95 @@ export async function newPeer(
|
|
|
487
505
|
return { personality, runtime: rt, action: 'fresh' }
|
|
488
506
|
}
|
|
489
507
|
|
|
508
|
+
export interface CompactPeerOutcome {
|
|
509
|
+
personality: string
|
|
510
|
+
runtime: string
|
|
511
|
+
action: 'compacted' | 'nothing-to-compact' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
|
|
512
|
+
woke?: boolean
|
|
513
|
+
reason?: string
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
|
|
518
|
+
* session. A live target receives the in-session `/compact` control as before. A
|
|
519
|
+
* cleanly idle-reaped target is first resumed into the same dialogue, then compacted.
|
|
520
|
+
* A crashed / never-run / non-resumable target returns an honest "nothing to compact"
|
|
521
|
+
* instead of starting a fresh empty session and compacting that.
|
|
522
|
+
*/
|
|
523
|
+
export async function compactPeer(
|
|
524
|
+
personality: string,
|
|
525
|
+
runtime: string | undefined,
|
|
526
|
+
opts: CliEnvOptions & {
|
|
527
|
+
wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
|
|
528
|
+
controlFn?: typeof routeControl
|
|
529
|
+
} = {},
|
|
530
|
+
): Promise<CompactPeerOutcome> {
|
|
531
|
+
const env = opts.env ?? process.env
|
|
532
|
+
const cfg = loadLifecycleConfig(env)
|
|
533
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
534
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
535
|
+
if (isForeignLaunchd(personality, env)) {
|
|
536
|
+
return {
|
|
537
|
+
personality,
|
|
538
|
+
runtime: peer.runtime,
|
|
539
|
+
action: 'refused-foreign-launchd',
|
|
540
|
+
reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not control it`,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
544
|
+
if (runtime && peer.runtime !== runtime && !peer.runtimes.includes(runtime as Runtime)) {
|
|
545
|
+
return {
|
|
546
|
+
personality,
|
|
547
|
+
runtime,
|
|
548
|
+
action: 'refused-undeclared-runtime',
|
|
549
|
+
reason: `peer "${personality}" does not declare runtime "${runtime}" (declared: ${peer.runtimes.join(', ')})`,
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const control = opts.controlFn ?? routeControl
|
|
554
|
+
const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
|
|
555
|
+
if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
|
|
556
|
+
const r = control(personality, runtime, { name: 'compact' })
|
|
557
|
+
if (!r.ok) return { personality, runtime: runtime ?? peer.runtime, action: 'failed', reason: r.error.message }
|
|
558
|
+
return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const rt: Runtime = (runtime as Runtime | undefined) ?? lastActiveRuntime(peer, cfg) ?? peer.runtime
|
|
562
|
+
if (!peer.runtimes.includes(rt)) {
|
|
563
|
+
return {
|
|
564
|
+
personality,
|
|
565
|
+
runtime: rt,
|
|
566
|
+
action: 'refused-undeclared-runtime',
|
|
567
|
+
reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (isInfraRuntime(rt)) {
|
|
571
|
+
return {
|
|
572
|
+
personality,
|
|
573
|
+
runtime: rt,
|
|
574
|
+
action: 'refused-infra',
|
|
575
|
+
reason: `infra runtime "${rt}" is launchd-held and has no compactable TUI dialogue`,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const identity = buildProcessAddress(rt, personality)
|
|
579
|
+
if (!hasIdleReaped(cfg, identity)) {
|
|
580
|
+
return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
|
|
581
|
+
}
|
|
582
|
+
const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
|
|
583
|
+
const w = await wake({ personality, runtime: rt, task: '', resume: true })
|
|
584
|
+
if (w.status !== 'READY') {
|
|
585
|
+
const reason = w.reason ?? 'wake failed'
|
|
586
|
+
if (/no .*session to resume|no transcript to resume|nothing to resume/i.test(reason)) {
|
|
587
|
+
return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
|
|
588
|
+
}
|
|
589
|
+
return { personality, runtime: rt, action: 'failed', reason }
|
|
590
|
+
}
|
|
591
|
+
clearIdleReaped(cfg, identity)
|
|
592
|
+
const r = control(personality, rt, { name: 'compact' })
|
|
593
|
+
if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
|
|
594
|
+
return { personality, runtime: rt, action: 'compacted', woke: true }
|
|
595
|
+
}
|
|
596
|
+
|
|
490
597
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
491
598
|
// remove — delete a peer's record from the registry through the LOCKED writer
|
|
492
599
|
// (registry.removePeer). Direct edits of peers-profiles.json are refused at
|
|
@@ -760,8 +867,8 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
|
|
|
760
867
|
|
|
761
868
|
const USAGE = `usage: iapeer <verb> [args]
|
|
762
869
|
install build binary + global scaffold + daemon plist (one bootstrap)
|
|
763
|
-
update [version] [--force] pull latest (or
|
|
764
|
-
rollback revert to the previous binary (.prev) + restart
|
|
870
|
+
update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
|
|
871
|
+
rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
|
|
765
872
|
version | --version | -v print the installed binary's version
|
|
766
873
|
help | --help | -h print this usage (works appended to any verb; executes nothing)
|
|
767
874
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
@@ -788,7 +895,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
788
895
|
default-runtime <runtime> (--peer <p> | --all) flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command
|
|
789
896
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
790
897
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
791
|
-
compact <peer> [runtime] compact the peer's
|
|
898
|
+
compact <peer> [runtime] compact the peer's dialogue (/compact); resumes clean-asleep first
|
|
792
899
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
793
900
|
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
794
901
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
@@ -1324,7 +1431,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1324
1431
|
`roll back now: iapeer rollback\n`)
|
|
1325
1432
|
return 1
|
|
1326
1433
|
}
|
|
1327
|
-
|
|
1434
|
+
const note = infraRecycleNote(r.infra)
|
|
1435
|
+
if (infraRecycleFailed(r.infra)) {
|
|
1436
|
+
errOut(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1437
|
+
return 1
|
|
1438
|
+
}
|
|
1439
|
+
out(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1328
1440
|
return 0
|
|
1329
1441
|
}
|
|
1330
1442
|
const daemonNote =
|
|
@@ -1333,8 +1445,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1333
1445
|
: r.daemon === 'failed'
|
|
1334
1446
|
? `WARNING — ${r.reason}; roll back with: iapeer rollback`
|
|
1335
1447
|
: String(r.daemon)
|
|
1336
|
-
|
|
1337
|
-
|
|
1448
|
+
const note = infraRecycleNote(r.infra)
|
|
1449
|
+
const failed = r.daemon === 'failed' || infraRecycleFailed(r.infra)
|
|
1450
|
+
;(failed ? errOut : out)(`updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
|
|
1451
|
+
return failed ? 1 : 0
|
|
1338
1452
|
}
|
|
1339
1453
|
case 'rollback': {
|
|
1340
1454
|
// Recovery: restore the .prev binary kept by the last install, restart the
|
|
@@ -1348,23 +1462,26 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1348
1462
|
return 1
|
|
1349
1463
|
}
|
|
1350
1464
|
const restart = cycleDaemon(env)
|
|
1465
|
+
const infra = recycleFoundationOwnedInfraJobs(env)
|
|
1466
|
+
const infraNote = infraRecycleNote(infra)
|
|
1467
|
+
const infraFailed = infraRecycleFailed(infra)
|
|
1351
1468
|
if (restart.state === 'restarted') {
|
|
1352
1469
|
const h = await waitForDaemonHealthy({ env })
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
return h.healthy ? 0 : 1
|
|
1470
|
+
const msg = h.healthy
|
|
1471
|
+
? `rolled back to the previous binary; daemon restarted and healthy${infraNote}\n`
|
|
1472
|
+
: `rolled back, but the daemon is NOT healthy after restart (${h.detail})${infraNote}\n`
|
|
1473
|
+
;(h.healthy && !infraFailed ? out : errOut)(msg)
|
|
1474
|
+
return h.healthy && !infraFailed ? 0 : 1
|
|
1359
1475
|
}
|
|
1360
|
-
|
|
1476
|
+
const msg =
|
|
1361
1477
|
`rolled back to the previous binary; ${
|
|
1362
1478
|
restart.state === 'not-loaded'
|
|
1363
1479
|
? 'daemon not loaded — previous binary will be used on next start'
|
|
1364
1480
|
: `daemon restart ${restart.state}${restart.detail ? ` (${restart.detail})` : ''}`
|
|
1365
|
-
}\n
|
|
1366
|
-
|
|
1367
|
-
|
|
1481
|
+
}${infraNote}\n`
|
|
1482
|
+
const failed = restart.state === 'failed' || infraFailed
|
|
1483
|
+
;(failed ? errOut : out)(msg)
|
|
1484
|
+
return failed ? 1 : 0
|
|
1368
1485
|
}
|
|
1369
1486
|
case 'install': {
|
|
1370
1487
|
// UNIFIED foundation install (contract Установка §1 — "один npx ставит
|
|
@@ -1495,10 +1612,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1495
1612
|
killSession(sock, identity)
|
|
1496
1613
|
return 0
|
|
1497
1614
|
}
|
|
1498
|
-
case 'interrupt':
|
|
1499
|
-
case 'compact': {
|
|
1615
|
+
case 'interrupt': {
|
|
1500
1616
|
// In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
|
|
1501
|
-
// turn (Escape)
|
|
1617
|
+
// turn (Escape). UNCONDITIONAL — acts on the live session.
|
|
1502
1618
|
if (!positionals[0]) return usage(errOut)
|
|
1503
1619
|
const r = routeControl(positionals[0], positionals[1], { name: verb })
|
|
1504
1620
|
if (!r.ok) {
|
|
@@ -1508,6 +1624,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1508
1624
|
out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
|
|
1509
1625
|
return 0
|
|
1510
1626
|
}
|
|
1627
|
+
case 'compact': {
|
|
1628
|
+
// Dialogue control (UX directive 12.06): if the session is merely asleep
|
|
1629
|
+
// after a clean idle-reap, resume the same dialogue and compact it; if there
|
|
1630
|
+
// is no resumable dialogue, fail honestly instead of compacting a fresh one.
|
|
1631
|
+
if (!positionals[0]) return usage(errOut)
|
|
1632
|
+
const o = await compactPeer(positionals[0], positionals[1], { env })
|
|
1633
|
+
if (o.action === 'compacted') {
|
|
1634
|
+
out(`compact → ${o.personality} (${o.runtime})${o.woke ? ' after resume' : ''}\n`)
|
|
1635
|
+
return 0
|
|
1636
|
+
}
|
|
1637
|
+
errOut(`compact: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
1638
|
+
return 1
|
|
1639
|
+
}
|
|
1511
1640
|
case 'connect': {
|
|
1512
1641
|
// Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
|
|
1513
1642
|
// `connect telegram <peer> [--token <t>]`. The human owes only the token;
|
package/src/launch/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export {
|
|
|
61
61
|
isFoundationOwnedPlist,
|
|
62
62
|
launchctlBootstrap,
|
|
63
63
|
bootstrapJobCore,
|
|
64
|
+
cycleLaunchdJob,
|
|
65
|
+
cycleLaunchdJobCore,
|
|
64
66
|
cycleDaemon,
|
|
65
67
|
cycleDaemonCore,
|
|
66
68
|
resolveExecutable,
|
|
@@ -75,6 +77,8 @@ export type {
|
|
|
75
77
|
BootstrapCoreResult,
|
|
76
78
|
DaemonRestartResult,
|
|
77
79
|
DaemonRestartState,
|
|
80
|
+
LaunchdJobCycleResult,
|
|
81
|
+
LaunchdJobCycleState,
|
|
78
82
|
LaunchctlRunner,
|
|
79
83
|
} from './launchd.ts'
|
|
80
84
|
|
package/src/launch/launchd.ts
CHANGED
|
@@ -298,38 +298,41 @@ export function bootstrapJobCore(
|
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
export type
|
|
302
|
-
| 'restarted' // bootout + bootstrap succeeded → the
|
|
303
|
-
| 'not-loaded' //
|
|
304
|
-
// will be used whenever it IS next started; nothing to do here)
|
|
301
|
+
export type LaunchdJobCycleState =
|
|
302
|
+
| 'restarted' // bootout + bootstrap succeeded → the job runs with the currently-referenced binaries
|
|
303
|
+
| 'not-loaded' // the label is not in the gui domain → nothing to restart (next start will use the current plist/binaries)
|
|
305
304
|
| 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
|
|
306
305
|
| 'failed' // bootout/bootstrap failed — the result carries the manual rescue recipe
|
|
307
306
|
|
|
308
|
-
export interface
|
|
309
|
-
state:
|
|
307
|
+
export interface LaunchdJobCycleResult {
|
|
308
|
+
state: LaunchdJobCycleState
|
|
310
309
|
detail?: string
|
|
311
310
|
}
|
|
312
311
|
|
|
312
|
+
export type DaemonRestartState = LaunchdJobCycleState
|
|
313
|
+
export interface DaemonRestartResult extends LaunchdJobCycleResult {}
|
|
314
|
+
|
|
313
315
|
/**
|
|
314
|
-
* DI core of
|
|
316
|
+
* DI core of cycleLaunchdJob (unit-testable without launchctl): bootout → wait-for-gone
|
|
315
317
|
* → bootstrap, composed on bootstrapJobCore's undead-job-safe wait/backoff.
|
|
316
318
|
* Disambiguation of bootstrapJobCore's 'already-loaded': at attempts=0 it means the
|
|
317
319
|
* job NEVER unloaded after our bootout (undead beyond budget) → FAIL loud with the
|
|
318
320
|
* manual recipe; at attempts>0 it means a retry found the job loaded (our previous
|
|
319
321
|
* attempt half-succeeded or KeepAlive raced us in) → the job IS up → success.
|
|
320
322
|
*/
|
|
321
|
-
export function
|
|
323
|
+
export function cycleLaunchdJobCore(
|
|
322
324
|
uid: string,
|
|
325
|
+
label: string,
|
|
323
326
|
plistPath: string,
|
|
324
327
|
deps: BootstrapCoreDeps,
|
|
325
|
-
):
|
|
326
|
-
const listed = () => deps.run(['print', `gui/${uid}/${
|
|
328
|
+
): LaunchdJobCycleResult {
|
|
329
|
+
const listed = () => deps.run(['print', `gui/${uid}/${label}`]).status === 0
|
|
327
330
|
if (!listed()) return { state: 'not-loaded' }
|
|
328
|
-
const bo = deps.run(['bootout', `gui/${uid}/${
|
|
331
|
+
const bo = deps.run(['bootout', `gui/${uid}/${label}`])
|
|
329
332
|
if (bo.status !== 0) {
|
|
330
333
|
return { state: 'failed', detail: `launchctl bootout failed: ${bo.stderr.trim() || `exit ${bo.status}`}` }
|
|
331
334
|
}
|
|
332
|
-
const core = bootstrapJobCore(uid,
|
|
335
|
+
const core = bootstrapJobCore(uid, label, plistPath, deps)
|
|
333
336
|
if (core.state === 'loaded' || (core.state === 'already-loaded' && core.attempts > 0)) {
|
|
334
337
|
return { state: 'restarted' }
|
|
335
338
|
}
|
|
@@ -338,7 +341,7 @@ export function cycleDaemonCore(
|
|
|
338
341
|
state: 'failed',
|
|
339
342
|
detail:
|
|
340
343
|
`job never unloaded after bootout (undead beyond budget) — manual rescue: ` +
|
|
341
|
-
`launchctl bootout gui/${uid}/${
|
|
344
|
+
`launchctl bootout gui/${uid}/${label} && launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
342
345
|
}
|
|
343
346
|
}
|
|
344
347
|
return {
|
|
@@ -347,6 +350,15 @@ export function cycleDaemonCore(
|
|
|
347
350
|
}
|
|
348
351
|
}
|
|
349
352
|
|
|
353
|
+
/** Back-compat wrapper for the foundation daemon's label. */
|
|
354
|
+
export function cycleDaemonCore(
|
|
355
|
+
uid: string,
|
|
356
|
+
plistPath: string,
|
|
357
|
+
deps: BootstrapCoreDeps,
|
|
358
|
+
): DaemonRestartResult {
|
|
359
|
+
return cycleLaunchdJobCore(uid, DAEMON_PLIST_LABEL, plistPath, deps)
|
|
360
|
+
}
|
|
361
|
+
|
|
350
362
|
/**
|
|
351
363
|
* Restart the foundation daemon (`com.agfpd.iapeer`) onto the binary currently on
|
|
352
364
|
* disk — `launchctl bootout` + `bootstrap`, DELIBERATELY NOT `kickstart -k`.
|
|
@@ -368,12 +380,26 @@ export function cycleDaemonCore(
|
|
|
368
380
|
* version-stable (write-if-changed) and is NOT rewritten here. Sandbox-safe.
|
|
369
381
|
*/
|
|
370
382
|
export function cycleDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestartResult {
|
|
383
|
+
return cycleLaunchdJob(DAEMON_PLIST_LABEL, join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`), env)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Restart any launchd job onto the executable(s) currently referenced by its plist:
|
|
388
|
+
* bootout → bootstrap, never kickstart. Used by the daemon update path AND by
|
|
389
|
+
* foundation-owned infra peers (`com.iapeer.*`) whose ProgramArguments also point at
|
|
390
|
+
* the installed `iapeer` binary. The caller owns any fleet/ownership guard; this
|
|
391
|
+
* primitive only cycles the concrete label/plist pair. Sandbox-safe.
|
|
392
|
+
*/
|
|
393
|
+
export function cycleLaunchdJob(
|
|
394
|
+
label: string,
|
|
395
|
+
plistPath: string,
|
|
396
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
397
|
+
): LaunchdJobCycleResult {
|
|
371
398
|
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
|
|
372
399
|
return { state: 'skipped-sandbox' }
|
|
373
400
|
}
|
|
374
401
|
const uid = currentUid()
|
|
375
|
-
|
|
376
|
-
return cycleDaemonCore(uid, plistPath, {
|
|
402
|
+
return cycleLaunchdJobCore(uid, label, plistPath, {
|
|
377
403
|
run: args => {
|
|
378
404
|
const r = spawnSync('launchctl', args, { encoding: 'utf8' })
|
|
379
405
|
return { status: r.status, stderr: r.stderr ?? '' }
|
package/src/update/index.ts
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
// and run ITS own source installer. DELIBERATELY NOT `npx … install` (see
|
|
12
12
|
// defaultRunInstall for why npx is unsafe here).
|
|
13
13
|
// 4. cycle com.agfpd.iapeer IF loaded (bootout+bootstrap) (activate the new binary; kickstart hits the LWCR EX_CONFIG(78) bomb)
|
|
14
|
+
// 5. recycle loaded FOUNDATION-OWNED infra jobs (com.iapeer.* with the sentinel)
|
|
15
|
+
// because their plists also run the just-replaced `iapeer run-infra` binary.
|
|
14
16
|
//
|
|
15
|
-
// Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon + launchd
|
|
18
|
+
// registrations of foundation-owned infra jobs that point at that binary). It never
|
|
19
|
+
// rewrites those plists, never updates runtime packages (telegram-runtime /
|
|
20
|
+
// notifier-runtime / MergeMind), and never touches foreign / persistent-peer fleet
|
|
21
|
+
// plists — only sentinel-marked foundation-owned com.iapeer.* jobs are recycled.
|
|
19
22
|
//
|
|
20
23
|
// updateIapeer takes its three side-effects as INJECTED deps so the version-gate is
|
|
21
24
|
// unit-testable with no network and no launchctl; the defaults are the real impls.
|
|
@@ -25,9 +28,19 @@ import { mkdtempSync, readdirSync, rmSync } from 'fs'
|
|
|
25
28
|
import { connect } from 'net'
|
|
26
29
|
import { tmpdir } from 'os'
|
|
27
30
|
import { join } from 'path'
|
|
31
|
+
import { isInfraRuntime } from '../core/constants.ts'
|
|
28
32
|
import { IapError } from '../core/errors.ts'
|
|
29
33
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
30
|
-
import {
|
|
34
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
35
|
+
import {
|
|
36
|
+
cycleDaemon,
|
|
37
|
+
cycleLaunchdJob,
|
|
38
|
+
isFoundationOwnedPlist,
|
|
39
|
+
launchdLabel,
|
|
40
|
+
launchdPlistPath,
|
|
41
|
+
type DaemonRestartResult,
|
|
42
|
+
type LaunchdJobCycleResult,
|
|
43
|
+
} from '../launch/launchd.ts'
|
|
31
44
|
import { defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
32
45
|
|
|
33
46
|
/** The npm package the foundation publishes / updates from. */
|
|
@@ -45,11 +58,22 @@ export interface UpdateDeps {
|
|
|
45
58
|
/** Resolve a spec ('latest' or an exact 'X.Y.Z') to the concrete published version it
|
|
46
59
|
* names, or null on a miss / npm error (default: `npm view @agfpd/iapeer@<spec> version`). */
|
|
47
60
|
resolveVersion?: (spec: string, env: NodeJS.ProcessEnv) => string | null
|
|
48
|
-
/** Pull + rebuild the binary for `version` (default:
|
|
61
|
+
/** Pull + rebuild the binary for `version` (default: npm pack → extract → source install).
|
|
49
62
|
* Returns true on success. */
|
|
50
63
|
runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
|
|
51
64
|
/** Restart the daemon onto the new binary (default: cycleDaemon — bootout+bootstrap, the LWCR-safe cycle). */
|
|
52
65
|
restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
|
|
66
|
+
/** Re-register loaded foundation-owned infra launchd jobs whose plists run the
|
|
67
|
+
* installed iapeer binary (default: registry scan + sentinel guard). */
|
|
68
|
+
recycleInfraJobs?: (env: NodeJS.ProcessEnv) => InfraJobRecycleResult[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface InfraJobRecycleResult {
|
|
72
|
+
personality: string
|
|
73
|
+
runtime: string
|
|
74
|
+
label: string
|
|
75
|
+
state: LaunchdJobCycleResult['state']
|
|
76
|
+
detail?: string
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
export interface UpdateResult {
|
|
@@ -62,6 +86,8 @@ export interface UpdateResult {
|
|
|
62
86
|
to?: string
|
|
63
87
|
/** What happened to the live daemon (only on 'updated'). */
|
|
64
88
|
daemon?: DaemonRestartResult['state']
|
|
89
|
+
/** Foundation-owned infra jobs recycled after the binary swap (only on 'updated'). */
|
|
90
|
+
infra?: InfraJobRecycleResult[]
|
|
65
91
|
/** Human reason on 'failed' / extra detail on a daemon restart hiccup. */
|
|
66
92
|
reason?: string
|
|
67
93
|
}
|
|
@@ -195,6 +221,30 @@ function defaultRunInstall(version: string, env: NodeJS.ProcessEnv): boolean {
|
|
|
195
221
|
}
|
|
196
222
|
}
|
|
197
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Re-register every loaded FOUNDATION-owned infra peer job after the installed
|
|
226
|
+
* iapeer binary is replaced. These plists run `iapeer run-infra <peer> <runtime>`;
|
|
227
|
+
* if launchd keeps the old managed launch constraint, a later KeepAlive respawn can
|
|
228
|
+
* hit the same EX_CONFIG(78) LWCR crash-loop that bit the daemon. Full bootout →
|
|
229
|
+
* bootstrap clears the registration. Foreign / persistent-peer plists are skipped
|
|
230
|
+
* by the sentinel guard, preserving H4.
|
|
231
|
+
*/
|
|
232
|
+
export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process.env): InfraJobRecycleResult[] {
|
|
233
|
+
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return []
|
|
234
|
+
const out: InfraJobRecycleResult[] = []
|
|
235
|
+
const index = readPeersIndex({ env })
|
|
236
|
+
for (const peer of index.peers) {
|
|
237
|
+
const runtime = (isInfraRuntime(peer.runtime) ? peer.runtime : peer.runtimes.find(isInfraRuntime)) ?? ''
|
|
238
|
+
if (!runtime) continue
|
|
239
|
+
const plist = launchdPlistPath(peer.personality, env)
|
|
240
|
+
if (!isFoundationOwnedPlist(plist)) continue
|
|
241
|
+
const label = launchdLabel(peer.personality)
|
|
242
|
+
const r = cycleLaunchdJob(label, plist, env)
|
|
243
|
+
out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
198
248
|
/**
|
|
199
249
|
* Update the foundation to the latest published version (cloud-only) and restart
|
|
200
250
|
* the daemon onto it. Idempotent + version-gated: a no-op "already-latest" when the
|
|
@@ -242,12 +292,18 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
|
242
292
|
|
|
243
293
|
const restart = deps.restartDaemon ?? cycleDaemon
|
|
244
294
|
const d = restart(env)
|
|
295
|
+
const recycleInfra = deps.recycleInfraJobs ?? recycleFoundationOwnedInfraJobs
|
|
296
|
+
// If the daemon re-registration itself failed, do NOT widen the blast radius by
|
|
297
|
+
// cycling infra jobs onto a binary whose daemon could not come up. Rollback will
|
|
298
|
+
// re-cycle them after restoring `.prev`.
|
|
299
|
+
const infra = d.state === 'failed' ? [] : recycleInfra(env)
|
|
245
300
|
return {
|
|
246
301
|
status: 'updated',
|
|
247
302
|
from,
|
|
248
303
|
to: desired,
|
|
249
304
|
latest: desired,
|
|
250
305
|
daemon: d.state,
|
|
306
|
+
infra,
|
|
251
307
|
reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
|
|
252
308
|
}
|
|
253
309
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// exercised too (a test must never npx-install over the prod binary).
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test'
|
|
7
|
-
import { IAPEER_VERSION, updateIapeer, waitForDaemonHealthy } from '../index.ts'
|
|
7
|
+
import { IAPEER_VERSION, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../index.ts'
|
|
8
8
|
import type { DaemonRestartResult } from '../launch/launchd.ts'
|
|
9
9
|
|
|
10
10
|
const restarted = (): DaemonRestartResult => ({ state: 'restarted' })
|
|
@@ -17,9 +17,10 @@ function harness(opts: {
|
|
|
17
17
|
target?: string
|
|
18
18
|
installOk?: boolean
|
|
19
19
|
restart?: DaemonRestartResult
|
|
20
|
+
recycle?: ReturnType<typeof recycleFoundationOwnedInfraJobs>
|
|
20
21
|
force?: boolean
|
|
21
22
|
}) {
|
|
22
|
-
const calls = { install: [] as string[], restart: 0, resolved: [] as string[] }
|
|
23
|
+
const calls = { install: [] as string[], restart: 0, recycle: 0, resolved: [] as string[] }
|
|
23
24
|
const result = updateIapeer({
|
|
24
25
|
env: { IAPEER_TEST_SANDBOX: '1' },
|
|
25
26
|
force: opts.force,
|
|
@@ -37,6 +38,10 @@ function harness(opts: {
|
|
|
37
38
|
calls.restart++
|
|
38
39
|
return opts.restart ?? restarted()
|
|
39
40
|
},
|
|
41
|
+
recycleInfraJobs: () => {
|
|
42
|
+
calls.recycle++
|
|
43
|
+
return opts.recycle ?? []
|
|
44
|
+
},
|
|
40
45
|
})
|
|
41
46
|
return { result, calls }
|
|
42
47
|
}
|
|
@@ -49,6 +54,7 @@ describe('updateIapeer — version gate', () => {
|
|
|
49
54
|
expect(result.latest).toBe('0.2.2')
|
|
50
55
|
expect(calls.install).toEqual([])
|
|
51
56
|
expect(calls.restart).toBe(0)
|
|
57
|
+
expect(calls.recycle).toBe(0)
|
|
52
58
|
})
|
|
53
59
|
|
|
54
60
|
test('--force reinstalls + restarts even when already latest', () => {
|
|
@@ -65,8 +71,18 @@ describe('updateIapeer — version gate', () => {
|
|
|
65
71
|
expect(result.from).toBe('0.2.2')
|
|
66
72
|
expect(result.to).toBe('0.3.0')
|
|
67
73
|
expect(result.daemon).toBe('restarted')
|
|
74
|
+
expect(result.infra).toEqual([])
|
|
68
75
|
expect(calls.install).toEqual(['0.3.0']) // installs latest, not current
|
|
69
76
|
expect(calls.restart).toBe(1)
|
|
77
|
+
expect(calls.recycle).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('after binary install, loaded foundation-owned infra jobs are recycled too', () => {
|
|
81
|
+
const recycle = [{ personality: 'timer', runtime: 'notifier', label: 'com.iapeer.timer', state: 'restarted' as const }]
|
|
82
|
+
const { result, calls } = harness({ current: '0.2.2', latest: '0.3.0', recycle })
|
|
83
|
+
expect(result.status).toBe('updated')
|
|
84
|
+
expect(result.infra).toEqual(recycle)
|
|
85
|
+
expect(calls.recycle).toBe(1)
|
|
70
86
|
})
|
|
71
87
|
|
|
72
88
|
test('no target → resolves the "latest" spec', () => {
|
|
@@ -125,7 +141,7 @@ describe('updateIapeer — failure paths', () => {
|
|
|
125
141
|
})
|
|
126
142
|
|
|
127
143
|
test('binary updated but restart failed → updated with a warning reason', () => {
|
|
128
|
-
const { result } = harness({
|
|
144
|
+
const { result, calls } = harness({
|
|
129
145
|
current: '0.2.2',
|
|
130
146
|
latest: '0.3.0',
|
|
131
147
|
restart: { state: 'failed', detail: 'kickstart exit 1' },
|
|
@@ -133,6 +149,7 @@ describe('updateIapeer — failure paths', () => {
|
|
|
133
149
|
expect(result.status).toBe('updated')
|
|
134
150
|
expect(result.daemon).toBe('failed')
|
|
135
151
|
expect(result.reason).toMatch(/restart failed.*kickstart exit 1/i)
|
|
152
|
+
expect(calls.recycle).toBe(0)
|
|
136
153
|
})
|
|
137
154
|
})
|
|
138
155
|
|