@agfpd/iapeer 0.4.1 → 0.4.2

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.
@@ -47,25 +47,32 @@ You'll see the core version, the daemon's health, and the memory-slot state. The
47
47
 
48
48
  A breakdown of each onboard step is in [02 — Quick start](02-quickstart.md); runtimes and memory in detail — in [09](09-runtimes.md) and [11](11-extensions-and-memory.md).
49
49
 
50
- ## Updating the core
50
+ ## Updating
51
51
 
52
52
  ```bash
53
- iapeer update # to the latest version
54
- iapeer update 0.2.80 # to a specific version
55
- iapeer update --force # reinstall even if the version is already in place
53
+ iapeer update # cascade-update the WHOLE stack: core + runtimes + memory
54
+ iapeer update --foundation-only # update just the core
55
+ iapeer update 0.2.80 # pin the CORE to a specific version (core only)
56
+ iapeer update --force # reinstall even if the version is already in place
56
57
  ```
57
58
 
58
- An update pulls the core version from npm and restarts the daemon onto it. Step by step:
59
+ A bare `iapeer update` updates the **whole host stack** in one command — the core (foundation), every installed runtime, and the memory provider — so "updated" means the whole machine, not just the core. The core goes first; the runtimes and memory follow only if the core came up healthy.
60
+
61
+ **1. Core (foundation)** — and it ABORTS the cascade on a hard failure (never update runtimes onto a broken core):
59
62
 
60
63
  1. Learns the target version from npm. If the installed one already matches and there's no `--force` — it does nothing.
61
- 2. Downloads and builds the new binary. This is done via `npm pack`, not `npx`: the binary doesn't rebuild itself.
64
+ 2. Downloads and builds the new binary via `npm pack`, not `npx` (the binary doesn't rebuild itself).
62
65
  3. Restarts the daemon onto the new binary.
63
- 4. Restarts the infrastructure launchd jobs the core owns (`com.iapeer.*` — the `timer`, `watcher`, Telegram routers), moving them onto the just-replaced binary. Their plists launch the same core binary as the daemon — without a restart a job would stay on the old code. It's the job that's restarted (booted out and back in); the runtimes' **package code** (telegram-runtime, notifier-runtime) is not updated by this — that's the separate `update-runtime` operation. The memory provider (`iapeer-memory`) and ordinary peers' plists aren't touched at all. If the daemon didn't come up in step 3, this step is skipped — no point moving jobs onto a binary the daemon didn't revive on.
64
- 5. Verifies the daemon actually came up healthy — not by the restart command exiting successfully, but by a probe: it connects to the daemon's socket (the one the daemon opens right before the readiness signal). The daemon counts as healthy only when the socket accepts a connection **twice in a row** — a single probe would also pass for a daemon that bound the socket and immediately crashed. The check is given 15 seconds; if a run of successful probes doesn't accumulate in that time, the command says so directly and advises rolling back.
66
+ 4. Restarts the infrastructure launchd jobs the core owns (`com.iapeer.*` — the `timer`, `watcher`, Telegram routers), moving them onto the just-replaced binary. It's the JOBS that restart, not the runtimes' package code — that's the next step. If the daemon didn't come up in step 3, this is skipped.
67
+ 5. Verifies the daemon came up healthy — by a probe, not by the restart command exiting: it connects to the daemon's socket and counts it healthy only when it accepts a connection **twice in a row** (a single probe would also pass a daemon that bound the socket and immediately crashed). The check is given 15 seconds; on failure the command says so and advises rolling back.
68
+
69
+ **2. Runtimes** — each installed runtime package (telegram-runtime, notifier-runtime) is version-gated, re-installed, re-provisioned, and its peers restarted (via the regular stop/start — the persistent-peer fleet is refused, never forced).
70
+
71
+ **3. Memory provider** — if a provider is claimed, its own update verb runs (the provider owns its update, its slot version, and its memory-daemon restart).
65
72
 
66
- An update touches **only the core**. The daemon plist and third-party packages stay as they are.
73
+ Runtimes and memory are **best-effort**: a component failure is reported and the rest still run; the command exits non-zero if anything failed. Agentic peers (Claude/Codex) pick up new core doctrine lazily on their next wake.
67
74
 
68
- Naming a specific version (`iapeer update <version>`) is useful when you need to roll back deeper than one step, or pin a proven version.
75
+ `iapeer update --foundation-only` does just step 1 (the narrow case). A pinned `iapeer update <version>` is **core-only** by design — a version pin is core-specific (downgrade, or pin a proven version, deeper than one rollback step).
69
76
 
70
77
  ## Rollback
71
78
 
@@ -47,25 +47,32 @@ iapeer status
47
47
 
48
48
  Разбор каждого шага onboard — в [02 — Быстрый старт](02-быстрый-старт.md); рантаймы и память подробно — в [09](09-рантаймы.md) и [11](11-расширения-и-память.md).
49
49
 
50
- ## Обновление ядра
50
+ ## Обновление
51
51
 
52
52
  ```bash
53
- iapeer update # до последней версии
54
- iapeer update 0.2.80 # до конкретной версии
55
- iapeer update --force # переустановить, даже если версия уже стоит
53
+ iapeer update # каскад: обновить ВЕСЬ стек — ядро + рантаймы + память
54
+ iapeer update --foundation-only # обновить только ядро
55
+ iapeer update 0.2.80 # запинить ЯДРО на конкретную версию (только ядро)
56
+ iapeer update --force # переустановить, даже если версия уже стоит
56
57
  ```
57
58
 
58
- Обновление тянет версию ядра из npm и перезапускает демон на неё. По шагам:
59
+ Голый `iapeer update` обновляет **весь хостовый стек** одной командой — ядро (фундамент), каждый установленный рантайм и провайдер памяти, — чтобы «обновлено» значило всю машину, а не только ядро. Ядро идёт первым; рантаймы и память следуют, только если ядро поднялось здоровым.
60
+
61
+ **1. Ядро (фундамент)** — и оно ПРЕРЫВАЕТ каскад при жёсткой ошибке (не обновлять рантаймы на сломанное ядро):
59
62
 
60
63
  1. Узнаёт нужную версию из npm. Если установленная уже совпадает и нет `--force` — ничего не делает.
61
- 2. Скачивает и собирает новый бинарь. Это делается через `npm pack`, а не `npx`: бинарь не пересобирает себя сам.
64
+ 2. Скачивает и собирает новый бинарь через `npm pack`, а не `npx` (бинарь не пересобирает себя сам).
62
65
  3. Перезапускает демон на новый бинарь.
63
- 4. Перезапускает launchd-джобы инфраструктуры, которыми владеет ядро (`com.iapeer.*` — роутеры `timer`, `watcher`, Telegram), переводя их на только что заменённый бинарь. Их плисты запускают тот же бинарь ядра, что и демон, — без перезапуска джоб остался бы на старом коде. Перезапускается именно джоб (снимается и поднимается заново); **пакетный код** рантаймов (telegram-runtime, notifier-runtime) при этом не обновляется — это отдельная операция `update-runtime`. Провайдер памяти (`iapeer-memory`) и плисты обычных пиров не трогаются вовсе. Если демон на шаге 3 не поднялся, этот шаг пропускается — нет смысла переводить джобы на бинарь, на котором демон не ожил.
64
- 5. Проверяет, что демон действительно поднялся здоровым, — не по тому, что команда перезапуска завершилась успешно, а пробой: подключается к сокету демона (тому самому, что демон открывает прямо перед сигналом готовности). Здоровым демон считается, только когда сокет принимает соединение **дважды подряд** — одиночная проба прошла бы и у демона, который привязал сокет и тут же упал. На проверку отведено 15 секунд; если за это время серия успешных проб не набралась — команда говорит об этом прямо и советует откатиться.
66
+ 4. Перезапускает launchd-джобы инфраструктуры, которыми владеет ядро (`com.iapeer.*` — роутеры `timer`, `watcher`, Telegram), переводя их на заменённый бинарь. Перезапускаются именно ДЖОБЫ, а не пакетный код рантаймов — это следующий шаг. Если демон на шаге 3 не поднялся, шаг пропускается.
67
+ 5. Проверяет, что демон поднялся здоровым, — пробой, а не кодом возврата команды перезапуска: подключается к сокету демона и считает здоровым, только когда тот принимает соединение **дважды подряд** (одиночная проба прошла бы и у демона, который привязал сокет и тут же упал). На проверку 15 секунд; при провале команда говорит и советует откатиться.
68
+
69
+ **2. Рантаймы** — каждый установленный рантайм-пакет (telegram-runtime, notifier-runtime) проходит version-gate, переустановку, ре-провижн, и его пиры перезапускаются (через обычный stop/start — флот persistent-peer отклоняется, не форсится).
70
+
71
+ **3. Провайдер памяти** — если слот занят, запускается его собственный update-verb (провайдер владеет своим обновлением, версией слота и перезапуском memory-демона).
65
72
 
66
- Обновление трогает **только ядро**. Плист демона и сторонние пакеты остаются как есть.
73
+ Рантаймы и память — **best-effort**: ошибка компонента сообщается, остальные всё равно отрабатывают; код возврата ненулевой, если что-то упало. Агентские пиры (Claude/Codex) подхватывают новую доктрину ядра лениво на следующем пробуждении.
67
74
 
68
- Указать конкретную версию (`iapeer update <версия>`) полезно, когда нужно откатиться глубже, чем на один шаг назад, или встать на проверенную версию.
75
+ `iapeer update --foundation-only` делает только шаг 1 (узкий случай). Запиненный `iapeer update <версия>` — **только ядро** by design: пин версии специфичен для ядра (откат глубже одного шага или фиксация проверенной версии).
69
76
 
70
77
  ## Откат
71
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  type Runtime,
22
22
  } from '../core/constants.ts'
23
23
  import { IAPEER_VERSION } from '../core/version.ts'
24
- import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
24
+ import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
25
25
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
26
26
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
27
27
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
@@ -955,7 +955,10 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
955
955
  * narrow terminal). `sig` = the invocation signature, `desc` = one prose line. */
956
956
  const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
957
957
  { sig: 'install', desc: 'build binary + global scaffold + daemon plist (one bootstrap)' },
958
- { sig: 'update [version] [--force]', desc: 'pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs' },
958
+ {
959
+ sig: 'update [version] [--force] [--foundation-only]',
960
+ desc: 'cascade-update the WHOLE stack (foundation + installed runtimes + memory) from npm + restart; --foundation-only = core only; a pinned <version> updates the core only',
961
+ },
959
962
  { sig: 'rollback', desc: 'revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs' },
960
963
  {
961
964
  sig: 'uninstall [--dry-run] [--yes] [--remove-codesign-identity]',
@@ -1716,49 +1719,72 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1716
1719
  return 0
1717
1720
  }
1718
1721
  case 'update': {
1719
- // The single deploy path: pull a version of the foundation from npm and restart
1720
- // the daemon onto it (cloud-only — never a working-tree build). No arg → latest;
1721
- // an explicit `update <version>` pins to that exact version (downgrade / recover
1722
- // deeper than the single .prev). Foundation ONLY; the plist and other host packages
1723
- // are untouched. --force reinstalls even when already at the desired version. (Run
1724
- // from the installed binary; the first-ever install is `npx @agfpd/iapeer`.)
1725
- const r = updateIapeer({ env, force: flags.force === true, targetVersion: positionals[0] })
1722
+ // CASCADE deploy (FU12): a bare `iapeer update` updates the WHOLE host stack —
1723
+ // foundation → every installed runtime → the memory provider — in one command.
1724
+ // Foundation goes FIRST and ABORTS the cascade on a hard failure (never update
1725
+ // runtimes onto a broken core). Runtimes + memory are BEST-EFFORT (reported, not
1726
+ // fatal). Two narrowings stay foundation-ONLY: `--foundation-only` (explicit) and
1727
+ // a pinned `update <version>` (a version pin is foundation-specific — downgrade /
1728
+ // recover). H4 holds throughout (foreign persistent-peer fleet is refused, never
1729
+ // forced — the runtime restart goes via the guarded stop/start). Cloud-only; the
1730
+ // first-ever install is `npx @agfpd/iapeer`.
1731
+ const pinned = positionals[0]
1732
+ const foundationOnly = flags['foundation-only'] === true
1733
+ const cascading = !foundationOnly && !pinned
1734
+ if (cascading) {
1735
+ // no-surprise heads-up: this restarts peers and can run minutes.
1736
+ out(
1737
+ 'iapeer update — updating the WHOLE stack: foundation + installed runtimes + memory provider.\n' +
1738
+ 'This rebuilds the daemon, restarts the telegram/notifier services and the memory daemon,\n' +
1739
+ 'and can take a few minutes; agentic peers pick up the new code on their next wake.\n' +
1740
+ '(`iapeer update --foundation-only` updates just the core.)\n\n',
1741
+ )
1742
+ }
1743
+
1744
+ // ── Foundation (the core; a hard failure aborts the cascade) ──
1745
+ const r = updateIapeer({ env, force: flags.force === true, targetVersion: pinned })
1746
+ let foundationHardFail = false
1726
1747
  if (r.status === 'failed') {
1727
1748
  errOut(`update failed: ${r.reason}\n`)
1728
- return 1
1729
- }
1730
- if (r.status === 'already-latest') {
1731
- out(`already at version ${r.from}\n`)
1732
- return 0
1733
- }
1734
- // 'updated': the binary is swapped. If the daemon was restarted, VERIFY it
1735
- // actually came back up before declaring success — a new binary that fails to
1736
- // boot must not read as a healthy deploy (it's the cue to roll back).
1737
- if (r.daemon === 'restarted') {
1749
+ foundationHardFail = true
1750
+ } else if (r.status === 'already-latest') {
1751
+ out(`foundation: already at version ${r.from}\n`)
1752
+ } else if (r.daemon === 'restarted') {
1753
+ // 'updated' + daemon restarted: VERIFY it actually came back up (a binary that
1754
+ // fails to boot must not read as a healthy deploy — it's the cue to roll back).
1738
1755
  const h = await waitForDaemonHealthy({ env })
1739
1756
  if (!h.healthy) {
1740
- errOut(`updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\n` +
1741
- `roll back now: iapeer rollback\n`)
1742
- return 1
1757
+ errOut(`foundation: updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\nroll back now: iapeer rollback\n`)
1758
+ foundationHardFail = true
1759
+ } else {
1760
+ const note = infraRecycleNote(r.infra)
1761
+ ;(infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1743
1762
  }
1763
+ } else {
1764
+ const daemonNote =
1765
+ r.daemon === 'not-loaded'
1766
+ ? 'daemon not loaded — new binary will be used on next start'
1767
+ : r.daemon === 'failed'
1768
+ ? `WARNING — ${r.reason}; roll back with: iapeer rollback`
1769
+ : String(r.daemon)
1770
+ if (r.daemon === 'failed') foundationHardFail = true
1744
1771
  const note = infraRecycleNote(r.infra)
1745
- if (infraRecycleFailed(r.infra)) {
1746
- errOut(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1747
- return 1
1748
- }
1749
- out(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1750
- return 0
1772
+ ;(r.daemon === 'failed' || infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
1751
1773
  }
1752
- const daemonNote =
1753
- r.daemon === 'not-loaded'
1754
- ? 'daemon not loaded — new binary will be used on next start'
1755
- : r.daemon === 'failed'
1756
- ? `WARNING — ${r.reason}; roll back with: iapeer rollback`
1757
- : String(r.daemon)
1758
- const note = infraRecycleNote(r.infra)
1759
- const failed = r.daemon === 'failed' || infraRecycleFailed(r.infra)
1760
- ;(failed ? errOut : out)(`updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
1761
- return failed ? 1 : 0
1774
+ const foundationExit = foundationHardFail || infraRecycleFailed(r.infra) ? 1 : 0
1775
+
1776
+ // foundation-only / pinned / hard-fail → stop here (don't cascade onto a broken core).
1777
+ if (!cascading || foundationHardFail) return foundationExit
1778
+
1779
+ // ── Cascade tail: runtimes + memory provider (best-effort) ──
1780
+ const { updateAllRuntimes } = await import('../runtime/update.ts')
1781
+ const { updateMemoryProvider } = await import('../onboard/memory.ts')
1782
+ const tail = await cascadeTail({
1783
+ runtimes: () => updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) }),
1784
+ memory: () => updateMemoryProvider({ env }),
1785
+ out,
1786
+ })
1787
+ return foundationExit === 0 && !tail.failed ? 0 : 1
1762
1788
  }
1763
1789
  case 'rollback': {
1764
1790
  // Recovery: restore the .prev binary kept by the last install, restart the
@@ -144,3 +144,74 @@ export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Pr
144
144
  }
145
145
  return { state: 'installed', provider: after }
146
146
  }
147
+
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+ // updateMemoryProvider — the memory leg of the cascade `iapeer update` (FU12)
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+
152
+ export interface MemoryUpdateResult {
153
+ state:
154
+ | 'updated' // provider update ran and the slot version advanced
155
+ | 'already-latest' // ran but the slot version is unchanged
156
+ | 'no-slot' // no provider claimed — nothing to update
157
+ | 'skipped-unavailable' // package not published / no network — soft skip
158
+ | 'failed' // provider update exited non-zero
159
+ package?: string
160
+ from?: string
161
+ to?: string
162
+ detail?: string
163
+ }
164
+
165
+ /** The provider's CLI bin name = the unscoped package name (`@agfpd/iapeer-memory` →
166
+ * `iapeer-memory`). */
167
+ function memoryBinName(pkg: string): string {
168
+ return pkg.replace(/^@[^/]+\//, '')
169
+ }
170
+
171
+ function defaultRunMemoryUpdate(pkg: string, env: NodeJS.ProcessEnv): { status: number | null; unavailable: boolean } {
172
+ // Availability probe FIRST (cheap, bounded): unpublished / no network → soft-skip.
173
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
174
+ if (probe.status !== 0) return { status: probe.status, unavailable: true }
175
+ // The provider ships a COMPILED bin on PATH (~/.local/bin/<bin>), NOT an npm package,
176
+ // so a bare `npx <bin>` / `npx <pkg>@latest` re-runs the STALE installed binary —
177
+ // verified by the provider: it reports the OLD version, "running FROM the installed
178
+ // binary", a SILENT non-update (fatal for the upgrade case). `npm exec
179
+ // --package=<pkg>@latest -- <bin> update` fetches latest + rebuilds, bypassing the PATH
180
+ // name-shadow. The provider OWNS update + slot-write + its own daemon (memoryd) restart.
181
+ const r = spawnSync(
182
+ 'npm',
183
+ ['exec', '--yes', `--package=${pkg}@latest`, '--', memoryBinName(pkg), 'update'],
184
+ { stdio: 'inherit', env },
185
+ )
186
+ return { status: r.status, unavailable: false }
187
+ }
188
+
189
+ /**
190
+ * Update the CLAIMED memory provider via ITS OWN npx update verb (slot contract: the
191
+ * provider owns install/update + slot-write + peer restart; the core only invokes +
192
+ * reads the slot). No slot → nothing to update. Reports from→to by reading the slot
193
+ * version before/after. BEST-EFFORT by the caller (the cascade never aborts on it).
194
+ */
195
+ export function updateMemoryProvider(opts: {
196
+ env?: NodeJS.ProcessEnv
197
+ dryRun?: boolean
198
+ runUpdate?: (pkg: string, env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
199
+ } = {}): MemoryUpdateResult {
200
+ const env = opts.env ?? process.env
201
+ const slot = readMemoryProvider(env)
202
+ if (!slot) return { state: 'no-slot', detail: 'no memory provider claimed — nothing to update' }
203
+ const pkg = slot.package
204
+ const from = slot.version
205
+ if (opts.dryRun) {
206
+ return { state: 'updated', package: pkg, from, detail: `would run: npm exec --package=${pkg}@latest -- ${memoryBinName(pkg)} update` }
207
+ }
208
+ const run = opts.runUpdate ?? defaultRunMemoryUpdate
209
+ const r = run(pkg, env)
210
+ if (r.unavailable) {
211
+ return { state: 'skipped-unavailable', package: pkg, from, detail: `${pkg}@latest not reachable (no network / unpublished)` }
212
+ }
213
+ if (r.status !== 0) return { state: 'failed', package: pkg, from, detail: `provider update exited ${r.status ?? 'null'}` }
214
+ const to = readMemoryProvider(env)?.version
215
+ if (from && to && from === to) return { state: 'already-latest', package: pkg, from, to }
216
+ return { state: 'updated', package: pkg, from, to }
217
+ }
@@ -1,8 +1,13 @@
1
- // `iapeer update` — the SINGLE deploy path: pull the latest published foundation
2
- // from npm (the cloud) and restart the daemon onto it. There is deliberately NO
3
- // "deploy from a working tree" — every host, including the dev host, activates a
1
+ // `updateIapeer` — the FOUNDATION leg of the deploy: pull the latest published
2
+ // foundation from npm (the cloud) and restart the daemon onto it. There is deliberately
3
+ // NO "deploy from a working tree" — every host, including the dev host, activates a
4
4
  // release the same way: `npm run release` (publish) → `iapeer update` (pull + restart).
5
5
  //
6
+ // NB the `iapeer update` VERB cascades the whole stack (FU12): it runs this foundation
7
+ // leg FIRST (aborting on a hard failure), then `updateAllRuntimes` + the memory provider
8
+ // (best-effort) via cascadeTail (below). updateIapeer itself stays foundation-scoped —
9
+ // the cascade is composed in the CLI verb.
10
+ //
6
11
  // Flow:
7
12
  // 1. latest = `npm view @agfpd/iapeer version` (the cloud's truth)
8
13
  // 2. installed == latest && !--force → "already latest" (no needless rebuild/restart)
@@ -43,6 +48,10 @@ import {
43
48
  } from '../launch/launchd.ts'
44
49
  import { cycleShadowJob, SHADOW_PERSONALITY, SHADOW_PLIST_LABEL } from '../shadow/install.ts'
45
50
  import { defaultDaemonSocketPath } from '../daemon/index.ts'
51
+ // Type-only (erased at runtime → no import cycle): the cascade tail renders these
52
+ // shapes but receives the component-updaters as injected deps.
53
+ import type { UpdateRuntimeResult } from '../runtime/update.ts'
54
+ import type { MemoryUpdateResult } from '../onboard/memory.ts'
46
55
 
47
56
  /** The npm package the foundation publishes / updates from. */
48
57
  export const IAPEER_PACKAGE = '@agfpd/iapeer'
@@ -316,3 +325,42 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
316
325
  reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
317
326
  }
318
327
  }
328
+
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ // cascadeTail — the runtimes + memory legs of the cascade `iapeer update` (FU12).
331
+ // Runs AFTER a healthy foundation update (the CLI owns that, abort-on-hard-fail);
332
+ // here we update every installed runtime then the memory provider, BEST-EFFORT — a
333
+ // component failure is reported, never aborts the rest. Pure orchestration over
334
+ // injected component-updaters (no value-import of runtime/onboard → no cycle), so it
335
+ // is hermetically testable. Returns whether anything failed (→ exit≠0).
336
+ // ─────────────────────────────────────────────────────────────────────────────
337
+
338
+ export interface CascadeTailDeps {
339
+ /** = updateAllRuntimes (runtime/update.ts). */
340
+ runtimes: () => Promise<UpdateRuntimeResult[]>
341
+ /** = updateMemoryProvider (onboard/memory.ts). */
342
+ memory: () => MemoryUpdateResult | Promise<MemoryUpdateResult>
343
+ out: (s: string) => void
344
+ }
345
+
346
+ export async function cascadeTail(deps: CascadeTailDeps): Promise<{ failed: boolean }> {
347
+ let failed = false
348
+
349
+ deps.out('runtimes:\n')
350
+ const rs = await deps.runtimes()
351
+ if (rs.length === 0) deps.out(' (none installed)\n')
352
+ for (const r of rs) {
353
+ const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
354
+ deps.out(` ${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
355
+ for (const p of r.restarted) deps.out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
356
+ if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
357
+ if (r.restarted.some(p => p.state === 'failed')) failed = true
358
+ }
359
+
360
+ const m = await deps.memory()
361
+ const mver = m.from || m.to ? ` ${m.from ?? '?'} → ${m.to ?? '?'}` : ''
362
+ deps.out(`memory: ${m.state}${m.package ? ` (${m.package}${mver})` : ''}${m.detail ? ` — ${m.detail}` : ''}\n`)
363
+ if (m.state === 'failed') failed = true
364
+
365
+ return { failed }
366
+ }