@agfpd/iapeer 0.2.47 → 0.2.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/index.ts +42 -16
- package/src/launch/index.ts +4 -0
- package/src/launch/launchd.ts +41 -15
- package/src/lifecycle/index.ts +13 -4
- package/src/lifecycle/lifecycle.test.ts +32 -0
- package/src/update/index.ts +62 -6
- package/src/update/update.test.ts +20 -3
package/package.json
CHANGED
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'
|
|
@@ -63,6 +63,22 @@ import { readMemoryProvider } from '../status/index.ts'
|
|
|
63
63
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
64
64
|
import { pluginLogsDir } from '../storage/index.ts'
|
|
65
65
|
|
|
66
|
+
function infraRecycleNote(infra: readonly { personality: string; state: string; detail?: string }[] | undefined): string {
|
|
67
|
+
if (!infra?.length) return ''
|
|
68
|
+
const touched = infra.filter(i => i.state === 'restarted').map(i => i.personality)
|
|
69
|
+
const failed = infra.filter(i => i.state === 'failed')
|
|
70
|
+
const passive = infra.filter(i => i.state !== 'restarted' && i.state !== 'failed')
|
|
71
|
+
const parts: string[] = []
|
|
72
|
+
if (touched.length) parts.push(`infra recycled: ${touched.join(', ')}`)
|
|
73
|
+
if (passive.length) parts.push(`infra ${passive.map(i => `${i.personality} ${i.state}`).join(', ')}`)
|
|
74
|
+
if (failed.length) parts.push(`infra FAILED: ${failed.map(i => `${i.personality}${i.detail ? ` (${i.detail})` : ''}`).join('; ')}`)
|
|
75
|
+
return parts.length ? `; ${parts.join('; ')}` : ''
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function infraRecycleFailed(infra: readonly { state: string }[] | undefined): boolean {
|
|
79
|
+
return (infra ?? []).some(i => i.state === 'failed')
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
83
|
// list — registry + per-runtime liveness (contract Примитивы §list)
|
|
68
84
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -760,8 +776,8 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
|
|
|
760
776
|
|
|
761
777
|
const USAGE = `usage: iapeer <verb> [args]
|
|
762
778
|
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
|
|
779
|
+
update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
|
|
780
|
+
rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
|
|
765
781
|
version | --version | -v print the installed binary's version
|
|
766
782
|
help | --help | -h print this usage (works appended to any verb; executes nothing)
|
|
767
783
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
@@ -1324,7 +1340,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1324
1340
|
`roll back now: iapeer rollback\n`)
|
|
1325
1341
|
return 1
|
|
1326
1342
|
}
|
|
1327
|
-
|
|
1343
|
+
const note = infraRecycleNote(r.infra)
|
|
1344
|
+
if (infraRecycleFailed(r.infra)) {
|
|
1345
|
+
errOut(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1346
|
+
return 1
|
|
1347
|
+
}
|
|
1348
|
+
out(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1328
1349
|
return 0
|
|
1329
1350
|
}
|
|
1330
1351
|
const daemonNote =
|
|
@@ -1333,8 +1354,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1333
1354
|
: r.daemon === 'failed'
|
|
1334
1355
|
? `WARNING — ${r.reason}; roll back with: iapeer rollback`
|
|
1335
1356
|
: String(r.daemon)
|
|
1336
|
-
|
|
1337
|
-
|
|
1357
|
+
const note = infraRecycleNote(r.infra)
|
|
1358
|
+
const failed = r.daemon === 'failed' || infraRecycleFailed(r.infra)
|
|
1359
|
+
;(failed ? errOut : out)(`updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
|
|
1360
|
+
return failed ? 1 : 0
|
|
1338
1361
|
}
|
|
1339
1362
|
case 'rollback': {
|
|
1340
1363
|
// Recovery: restore the .prev binary kept by the last install, restart the
|
|
@@ -1348,23 +1371,26 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1348
1371
|
return 1
|
|
1349
1372
|
}
|
|
1350
1373
|
const restart = cycleDaemon(env)
|
|
1374
|
+
const infra = recycleFoundationOwnedInfraJobs(env)
|
|
1375
|
+
const infraNote = infraRecycleNote(infra)
|
|
1376
|
+
const infraFailed = infraRecycleFailed(infra)
|
|
1351
1377
|
if (restart.state === 'restarted') {
|
|
1352
1378
|
const h = await waitForDaemonHealthy({ env })
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
return h.healthy ? 0 : 1
|
|
1379
|
+
const msg = h.healthy
|
|
1380
|
+
? `rolled back to the previous binary; daemon restarted and healthy${infraNote}\n`
|
|
1381
|
+
: `rolled back, but the daemon is NOT healthy after restart (${h.detail})${infraNote}\n`
|
|
1382
|
+
;(h.healthy && !infraFailed ? out : errOut)(msg)
|
|
1383
|
+
return h.healthy && !infraFailed ? 0 : 1
|
|
1359
1384
|
}
|
|
1360
|
-
|
|
1385
|
+
const msg =
|
|
1361
1386
|
`rolled back to the previous binary; ${
|
|
1362
1387
|
restart.state === 'not-loaded'
|
|
1363
1388
|
? 'daemon not loaded — previous binary will be used on next start'
|
|
1364
1389
|
: `daemon restart ${restart.state}${restart.detail ? ` (${restart.detail})` : ''}`
|
|
1365
|
-
}\n
|
|
1366
|
-
|
|
1367
|
-
|
|
1390
|
+
}${infraNote}\n`
|
|
1391
|
+
const failed = restart.state === 'failed' || infraFailed
|
|
1392
|
+
;(failed ? errOut : out)(msg)
|
|
1393
|
+
return failed ? 1 : 0
|
|
1368
1394
|
}
|
|
1369
1395
|
case 'install': {
|
|
1370
1396
|
// UNIFIED foundation install (contract Установка §1 — "один npx ставит
|
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/lifecycle/index.ts
CHANGED
|
@@ -1251,20 +1251,29 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1251
1251
|
* consumed BEFORE the relaunch so a relaunch failure does not loop on the same eager mark
|
|
1252
1252
|
* (it then fresh-wakes lazily on its next message — branch 3a — never lost). NB: a /new'd
|
|
1253
1253
|
* peer is expected to carry an initial_prompt; without one the first turn delivers nothing.
|
|
1254
|
+
*
|
|
1255
|
+
* Runtime is DELIBERATELY NOT pinned to the dead session's runtime: the relaunch passes
|
|
1256
|
+
* no runtime so resolveWakeRuntime falls through to the registry default (H5 — registry
|
|
1257
|
+
* is the only source of wake-runtime truth). Pinning broke the fleet-switch procedure
|
|
1258
|
+
* (`default-runtime <rt>` + `self-fresh`): the peer silently resurrected on the OLD
|
|
1259
|
+
* runtime, ignoring the just-flipped default (live incident 12.06, claude-iapeer). A
|
|
1260
|
+
* session deliberately living on a non-default runtime has explicit paths back (wake
|
|
1261
|
+
* with an explicit runtime); an owner /new returns the peer to its declared default.
|
|
1254
1262
|
*/
|
|
1255
1263
|
export async function processEagerRelaunches(
|
|
1256
1264
|
cfg: LifecycleConfig,
|
|
1257
1265
|
outcomes: SuperviseOutcome[],
|
|
1258
|
-
deps: WakeDeps = {},
|
|
1266
|
+
deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult> } = {},
|
|
1259
1267
|
): Promise<WakeResult[]> {
|
|
1268
|
+
const wake = deps.wakeFn ?? wakeOrSpawn
|
|
1260
1269
|
const results: WakeResult[] = []
|
|
1261
1270
|
for (const o of outcomes) {
|
|
1262
|
-
if (o.action !== 'needs-eager-fresh' || !o.personality
|
|
1271
|
+
if (o.action !== 'needs-eager-fresh' || !o.personality) continue
|
|
1263
1272
|
clearNewEager(cfg, o.identity) // consume the eager mark — the relaunch is fresh by construction
|
|
1264
1273
|
try {
|
|
1265
1274
|
results.push(
|
|
1266
|
-
await
|
|
1267
|
-
{ personality: o.personality,
|
|
1275
|
+
await wake(
|
|
1276
|
+
{ personality: o.personality, task: '', resume: false },
|
|
1268
1277
|
{ cfg, env: deps.env },
|
|
1269
1278
|
),
|
|
1270
1279
|
)
|
|
@@ -621,6 +621,38 @@ describe('superviseTick death-cause accounting (TARGET)', () => {
|
|
|
621
621
|
}
|
|
622
622
|
})
|
|
623
623
|
|
|
624
|
+
test('eager relaunch does NOT pin the dead session runtime — re-resolves from the registry (H5 fleet-switch)', async () => {
|
|
625
|
+
// Live incident 12.06: `default-runtime codex` + `self-fresh` resurrected the peer
|
|
626
|
+
// on the OLD runtime — the relaunch passed the dead session's runtime EXPLICITLY,
|
|
627
|
+
// so resolveWakeRuntime never consulted the just-flipped registry default.
|
|
628
|
+
const { env, cfg, root, laDir } = deadSessionEnv('rt')
|
|
629
|
+
try {
|
|
630
|
+
setNewEager(cfg, 'claude-rt')
|
|
631
|
+
const outcomes = superviseTick(cfg, { env })
|
|
632
|
+
const o = outcomes.find(x => x.identity === 'claude-rt')
|
|
633
|
+
expect(o?.action).toBe('needs-eager-fresh')
|
|
634
|
+
const seen: Array<{ personality: string; runtime?: string; resume?: boolean; task: string }> = []
|
|
635
|
+
const { processEagerRelaunches } = await import('./index.ts')
|
|
636
|
+
await processEagerRelaunches(cfg, outcomes, {
|
|
637
|
+
env,
|
|
638
|
+
wakeFn: async args => {
|
|
639
|
+
seen.push({ personality: args.personality, runtime: args.runtime, resume: args.resume, task: args.task })
|
|
640
|
+
return { status: 'READY', woke: true }
|
|
641
|
+
},
|
|
642
|
+
})
|
|
643
|
+
expect(seen.length).toBe(1)
|
|
644
|
+
expect(seen[0].personality).toBe('rt')
|
|
645
|
+
expect(seen[0].runtime).toBeUndefined() // ← the fix: wakeOrSpawn re-resolves from the registry
|
|
646
|
+
expect(seen[0].resume).toBe(false)
|
|
647
|
+
expect(seen[0].task).toBe('')
|
|
648
|
+
// the mark was consumed by the relaunch
|
|
649
|
+
expect(hasNewEager(cfg, 'claude-rt')).toBe(false)
|
|
650
|
+
} finally {
|
|
651
|
+
rmSync(root, { recursive: true, force: true })
|
|
652
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
|
|
624
656
|
test('a DEAD session with NO .new-eager → reaped-gone, NO .idle-reaped written (crash leaves no marker)', () => {
|
|
625
657
|
const { env, cfg, root, laDir } = deadSessionEnv('w')
|
|
626
658
|
try {
|
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
|
|