@agfpd/iapeer 0.2.15 → 0.2.17
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 +20 -2
- package/src/cli/index.ts +64 -0
- package/src/connect/connect.test.ts +144 -0
- package/src/connect/index.ts +212 -0
- package/src/lifecycle/index.ts +36 -9
- package/src/lifecycle/lifecycle.test.ts +31 -0
- package/src/onboard/index.ts +6 -2
- package/src/runtime/index.ts +6 -1
- package/src/runtime/update.test.ts +192 -0
- package/src/runtime/update.ts +185 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// persistent-peer launchd plist must be refused.
|
|
5
5
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
7
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
|
-
import { isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
|
|
12
|
+
import { hasIdleReaped, isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
14
14
|
|
|
15
15
|
let root: string
|
|
@@ -73,6 +73,24 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
|
|
|
73
73
|
test('stop an unregistered peer → throws', () => {
|
|
74
74
|
expect(() => stopPeer('nobody', undefined, { env: env() })).toThrow(/not registered/)
|
|
75
75
|
})
|
|
76
|
+
test('stop is a CLEAN PARK: leaves the resume marker and drops the session-state (boris 10.06)', async () => {
|
|
77
|
+
await register('parked')
|
|
78
|
+
const e = env()
|
|
79
|
+
const cfg = loadLifecycleConfig(e)
|
|
80
|
+
// a live session-state the daemon would otherwise tag as a death post-kill
|
|
81
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
82
|
+
writeFileSync(
|
|
83
|
+
join(cfg.stateDir, 'claude-parked.session'),
|
|
84
|
+
JSON.stringify({ identity: 'claude-parked', runtime: 'claude', personality: 'parked', cwd: '/tmp/parked', wokeAt: Date.now() }),
|
|
85
|
+
)
|
|
86
|
+
stopPeer('parked', undefined, { env: e })
|
|
87
|
+
expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true) // park marker → post-start wake RESUMES
|
|
88
|
+
expect(existsSync(join(cfg.stateDir, 'claude-parked.session'))).toBe(false) // not a death for supervise
|
|
89
|
+
// start clears only the stop flag — the park marker survives for the wake
|
|
90
|
+
startPeer('parked', undefined, { env: e })
|
|
91
|
+
expect(isStopped(cfg, 'claude-parked')).toBe(false)
|
|
92
|
+
expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
|
|
93
|
+
})
|
|
76
94
|
})
|
|
77
95
|
|
|
78
96
|
describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
isStopped,
|
|
34
34
|
killSession,
|
|
35
35
|
loadLifecycleConfig,
|
|
36
|
+
removeSessionState,
|
|
37
|
+
setIdleReaped,
|
|
36
38
|
setNewEager,
|
|
37
39
|
setStopped,
|
|
38
40
|
wakeOrSpawn,
|
|
@@ -196,8 +198,15 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
196
198
|
killSession(sock, identity)
|
|
197
199
|
out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
|
|
198
200
|
} else {
|
|
201
|
+
// A deliberate stop is a CLEAN PARK, not a death (boris 10.06 — stop→start
|
|
202
|
+
// must survive ≥ idle-reap): park-mark BEFORE the kill so the post-`start`
|
|
203
|
+
// wake RESUMES, and drop the supervise session-state with the session so
|
|
204
|
+
// the tick never tags this kill as a death (crash-loop ring stays clean,
|
|
205
|
+
// no reaped-gone death class for a state the daemon knows 100%).
|
|
199
206
|
setStopped(cfg, identity)
|
|
207
|
+
setIdleReaped(cfg, identity)
|
|
200
208
|
killSession(sock, identity)
|
|
209
|
+
removeSessionState(cfg, identity)
|
|
201
210
|
out.push({ personality, runtime: rt, action: 'stopped' })
|
|
202
211
|
}
|
|
203
212
|
}
|
|
@@ -363,6 +372,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
363
372
|
backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
|
|
364
373
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
365
374
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
375
|
+
update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
|
|
366
376
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
367
377
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
368
378
|
list [--json] registered peers + per-runtime liveness
|
|
@@ -371,6 +381,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
371
381
|
remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
|
|
372
382
|
send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
|
|
373
383
|
<runtime> launch the cwd's peer (ALWAYS fresh)
|
|
384
|
+
connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
|
|
374
385
|
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
375
386
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
376
387
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
@@ -543,6 +554,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
543
554
|
out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
|
|
544
555
|
return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
|
|
545
556
|
}
|
|
557
|
+
case 'update-runtime': {
|
|
558
|
+
// §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
|
|
559
|
+
// forced re-npx → idempotent re-provision (same path as install-runtime) →
|
|
560
|
+
// restart the runtime's peers via the regular stop/start. The core's own
|
|
561
|
+
// `update` stays foundation-only — this is the runtimes' counterpart.
|
|
562
|
+
const all = flags.all === true
|
|
563
|
+
if (!all && !positionals[0]) return usage(errOut)
|
|
564
|
+
const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
|
|
565
|
+
const results = all
|
|
566
|
+
? await updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })
|
|
567
|
+
: [await updateRuntime({ runtime: positionals[0] as Runtime, force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })]
|
|
568
|
+
let failed = false
|
|
569
|
+
for (const r of results) {
|
|
570
|
+
const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
|
|
571
|
+
out(`${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
|
|
572
|
+
for (const p of r.peers) out(` re-provisioned ${p.personality}: self-config ${p.selfConfig ?? 'n/a'}\n`)
|
|
573
|
+
for (const p of r.restarted) out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
|
|
574
|
+
if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
|
|
575
|
+
if (r.restarted.some(p => p.state === 'failed')) failed = true
|
|
576
|
+
if (!all && r.state === 'not-installed') failed = true
|
|
577
|
+
}
|
|
578
|
+
return failed ? 1 : 0
|
|
579
|
+
}
|
|
546
580
|
case 'init': {
|
|
547
581
|
// cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
|
|
548
582
|
// identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
|
|
@@ -830,6 +864,36 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
830
864
|
out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
|
|
831
865
|
return 0
|
|
832
866
|
}
|
|
867
|
+
case 'connect': {
|
|
868
|
+
// Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
|
|
869
|
+
// `connect telegram <peer> [--token <t>]`. The human owes only the token;
|
|
870
|
+
// alias/bot-add/interface/router-restart are resolved by the system. The
|
|
871
|
+
// FIRST message from the human to the bot activates the chat (platform rule).
|
|
872
|
+
if (positionals[0] !== 'telegram' || !positionals[1]) return usage(errOut)
|
|
873
|
+
const { connectTelegram } = await import('../connect/index.ts')
|
|
874
|
+
const r = await connectTelegram({
|
|
875
|
+
peer: positionals[1],
|
|
876
|
+
token: typeof flags.token === 'string' ? flags.token : undefined,
|
|
877
|
+
env,
|
|
878
|
+
})
|
|
879
|
+
if (r.state === 'noop-same-token') {
|
|
880
|
+
out(`connect telegram ${r.peer}: ${r.detail}\n`)
|
|
881
|
+
return 0
|
|
882
|
+
}
|
|
883
|
+
if (r.state !== 'connected') {
|
|
884
|
+
errOut(`connect telegram ${r.peer}: ${r.state}${r.detail ? ` — ${r.detail}` : ''}\n`)
|
|
885
|
+
return 1
|
|
886
|
+
}
|
|
887
|
+
const rs = r.restart!
|
|
888
|
+
out(`bot ${r.username ?? `for "${r.peer}"`} added + interfaced to "${r.peer}"\n`)
|
|
889
|
+
out(
|
|
890
|
+
rs.state === 'restarted'
|
|
891
|
+
? `router restarted — credentials loaded\n`
|
|
892
|
+
: `router restart ${rs.state}${rs.detail ? ` — ${rs.detail}` : ''} (the channel stays dead until the router restarts)\n`,
|
|
893
|
+
)
|
|
894
|
+
out(`activation: send the bot ${r.username ?? '(see @BotFather)'} its FIRST message — Telegram does not let a bot start the chat\n`)
|
|
895
|
+
return rs.state === 'restarted' ? 0 : 1
|
|
896
|
+
}
|
|
833
897
|
case 'enable': {
|
|
834
898
|
// Per-peer capability install (contract Установка §3): install <plugin>@agfpd
|
|
835
899
|
// per-runtime (claude project-scope IN the peer cwd / codex global) + enable +
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// connect telegram — the one-flow channel attachment (design §(в)). Sandboxed:
|
|
2
|
+
// IAPEER_ROOT temp dirs, injected telegram-runtime runner + router restart. The
|
|
3
|
+
// runner stub mimics the package: `bot add` writes bots/<alias>/.env (stable ABI).
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
7
|
+
import { dirname } from 'path'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { botEnvPath, connectTelegram, type RestartOutcome, type TgRunner } from './index.ts'
|
|
11
|
+
import { writeRuntimeManifest } from '../runtime/index.ts'
|
|
12
|
+
import { upsertPeer } from '../registry/index.ts'
|
|
13
|
+
|
|
14
|
+
const roots: string[] = []
|
|
15
|
+
function mkTmp(): string {
|
|
16
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-connect-'))
|
|
17
|
+
roots.push(d)
|
|
18
|
+
return d
|
|
19
|
+
}
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function envFor(root: string): NodeJS.ProcessEnv {
|
|
25
|
+
return {
|
|
26
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
27
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
|
|
28
|
+
IAPEER_TEST_SANDBOX: '1',
|
|
29
|
+
HOME: root,
|
|
30
|
+
} as NodeJS.ProcessEnv
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function fixture(): Promise<{ env: NodeJS.ProcessEnv; calls: string[][]; runTg: TgRunner; restarts: string[] }> {
|
|
34
|
+
const env = envFor(mkTmp())
|
|
35
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: { command: '/stub/telegram-runtime', args: ['self-config'] } }, { env })
|
|
36
|
+
await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
|
|
37
|
+
await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
|
|
38
|
+
const calls: string[][] = []
|
|
39
|
+
const runTg: TgRunner = (args, e) => {
|
|
40
|
+
calls.push(args)
|
|
41
|
+
if (args[0] === 'bot' && args[1] === 'add') {
|
|
42
|
+
// the package's behavior: token → bots/<alias>/.env; prints the validated @username
|
|
43
|
+
const p = botEnvPath(args[2]!, e)
|
|
44
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
45
|
+
writeFileSync(p, `TELEGRAM_BOT_TOKEN=${args[4]}\n`)
|
|
46
|
+
return { status: 0, stdout: 'bot added: @leo_test_bot\n', stderr: '' }
|
|
47
|
+
}
|
|
48
|
+
return { status: 0, stdout: '', stderr: '' }
|
|
49
|
+
}
|
|
50
|
+
const restarts: string[] = []
|
|
51
|
+
return { env, calls, runTg, restarts }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const okRestart =
|
|
55
|
+
(restarts: string[]) =>
|
|
56
|
+
(human: string): RestartOutcome => {
|
|
57
|
+
restarts.push(human)
|
|
58
|
+
return { state: 'restarted' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('connectTelegram (one flow: bot add → interface → restart → activation)', () => {
|
|
62
|
+
test('happy path: adds the bot, interfaces the peer, restarts the HUMAN router, surfaces @username', async () => {
|
|
63
|
+
const { env, calls, runTg, restarts } = await fixture()
|
|
64
|
+
const r = await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
|
|
65
|
+
expect(r.state).toBe('connected')
|
|
66
|
+
expect(r.username).toBe('@leo_test_bot')
|
|
67
|
+
expect(r.restart?.state).toBe('restarted')
|
|
68
|
+
expect(restarts).toEqual(['arthur']) // the router = the natural telegram peer, not leo
|
|
69
|
+
expect(calls[0]).toEqual(['bot', 'add', 'leo', '--token', 'T1:abc'])
|
|
70
|
+
expect(calls[1]).toEqual(['interface', 'bot', 'leo', '--peer', 'leo'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('IDEMPOTENT: the same token is a byte-stable no-op — router NOT restarted', async () => {
|
|
74
|
+
const { env, runTg, restarts } = await fixture()
|
|
75
|
+
expect((await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })).state).toBe('connected')
|
|
76
|
+
const second = await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
|
|
77
|
+
expect(second.state).toBe('noop-same-token')
|
|
78
|
+
expect(restarts).toEqual(['arthur']) // exactly ONE restart — the first connect
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a NEW token replaces and RESTARTS again (credentials load at start)', async () => {
|
|
82
|
+
const { env, runTg, restarts } = await fixture()
|
|
83
|
+
await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
|
|
84
|
+
const r = await connectTelegram({ peer: 'leo', token: 'T2:new', env, runTg, restart: okRestart(restarts) })
|
|
85
|
+
expect(r.state).toBe('connected')
|
|
86
|
+
expect(restarts).toEqual(['arthur', 'arthur'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('unregistered peer → refused BEFORE any bot add (interface precondition)', async () => {
|
|
90
|
+
const { env, calls, runTg } = await fixture()
|
|
91
|
+
const r = await connectTelegram({ peer: 'ghost', token: 'T', env, runTg })
|
|
92
|
+
expect(r.state).toBe('unregistered-peer')
|
|
93
|
+
expect(calls.length).toBe(0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('no telegram manifest → runtime-missing with the install recipe', async () => {
|
|
97
|
+
const env = envFor(mkTmp())
|
|
98
|
+
await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
|
|
99
|
+
const r = await connectTelegram({ peer: 'leo', token: 'T', env })
|
|
100
|
+
expect(r.state).toBe('runtime-missing')
|
|
101
|
+
expect(r.detail).toContain('install-runtime telegram')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('non-tty without --token → explicit refusal with the BotFather recipe', async () => {
|
|
105
|
+
const { env, runTg } = await fixture()
|
|
106
|
+
const r = await connectTelegram({ peer: 'leo', env, runTg, isTty: false })
|
|
107
|
+
expect(r.state).toBe('refused-no-token')
|
|
108
|
+
expect(r.detail).toContain('@BotFather')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('tty prompt path: the asked token flows in; empty answer → refusal', async () => {
|
|
112
|
+
const { env, runTg, restarts } = await fixture()
|
|
113
|
+
const r = await connectTelegram({ peer: 'leo', env, runTg, isTty: true, ask: async () => 'T9:asked', restart: okRestart(restarts) })
|
|
114
|
+
expect(r.state).toBe('connected')
|
|
115
|
+
const r2 = await connectTelegram({ peer: 'leo', env, runTg, isTty: true, ask: async () => '' })
|
|
116
|
+
expect(r2.state).toBe('refused-no-token')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('bot add failure (getMe refusal on a bad token) → bot-add-failed with the package detail', async () => {
|
|
120
|
+
const { env } = await fixture()
|
|
121
|
+
const failTg: TgRunner = args =>
|
|
122
|
+
args[0] === 'bot' ? { status: 1, stdout: '', stderr: 'getMe failed: 401 Unauthorized' } : { status: 0, stdout: '', stderr: '' }
|
|
123
|
+
const r = await connectTelegram({ peer: 'leo', token: 'bad', env, runTg: failTg })
|
|
124
|
+
expect(r.state).toBe('bot-add-failed')
|
|
125
|
+
expect(r.detail).toContain('401')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('no natural telegram peer in the registry → connected but restart=no-router (loud)', async () => {
|
|
129
|
+
const env = envFor(mkTmp())
|
|
130
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: '/stub/telegram-runtime self-config' }, { env })
|
|
131
|
+
await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
|
|
132
|
+
const runTg: TgRunner = (args, e) => {
|
|
133
|
+
if (args[0] === 'bot') {
|
|
134
|
+
const p = botEnvPath('leo', e)
|
|
135
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
136
|
+
writeFileSync(p, 'TELEGRAM_BOT_TOKEN=T\n')
|
|
137
|
+
}
|
|
138
|
+
return { status: 0, stdout: '', stderr: '' }
|
|
139
|
+
}
|
|
140
|
+
const r = await connectTelegram({ peer: 'leo', token: 'T', env, runTg })
|
|
141
|
+
expect(r.state).toBe('connected')
|
|
142
|
+
expect(r.restart?.state).toBe('no-router')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// connect — per-peer channel attachment in ONE flow (design «Onboard костяка» §(в),
|
|
2
|
+
// FINAL 10.06; flow facts confirmed by the telegram-runtime owner against v0.10.3).
|
|
3
|
+
// Namespace: `iapeer connect <channel> <peer>` — extensible to future channels;
|
|
4
|
+
// v1 implements `connect telegram <peer> [--token <t>]`.
|
|
5
|
+
//
|
|
6
|
+
// The human owes EXACTLY ONE external fact: the bot token (prompt walks them
|
|
7
|
+
// through @BotFather → /newbot). Everything else the system resolves: the bot
|
|
8
|
+
// alias (= the peer's personality), `telegram-runtime bot add` (owner adds getMe
|
|
9
|
+
// validation — an invalid token fails THERE, early), `telegram-runtime interface
|
|
10
|
+
// bot` (profile merge; precondition: the peer is registered), then the MANDATORY
|
|
11
|
+
// router-session restart (the live poller reads bots/ ONCE at start, no fs-watch —
|
|
12
|
+
// without the restart the channel is dead both ways), and the activation hint:
|
|
13
|
+
// the FIRST message from the human to the bot opens the chat (a Telegram platform
|
|
14
|
+
// rule — a bot cannot initiate; outbound into an unopened chat is 403).
|
|
15
|
+
//
|
|
16
|
+
// IDEMPOTENT: the same token is a byte-stable no-op (bots/<alias>/.env unchanged →
|
|
17
|
+
// no restart needed); a NEW token replaces with an explicit message AND restarts
|
|
18
|
+
// (credentials load at start).
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'fs'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import { spawnSync } from 'child_process'
|
|
23
|
+
import { normalizeIntelligenceValue } from '../core/constants.ts'
|
|
24
|
+
import { runtimeRoot } from '../storage/index.ts'
|
|
25
|
+
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
26
|
+
import { readRuntimeManifest } from '../runtime/index.ts'
|
|
27
|
+
|
|
28
|
+
export interface TgRunResult {
|
|
29
|
+
status: number | null
|
|
30
|
+
stdout: string
|
|
31
|
+
stderr: string
|
|
32
|
+
}
|
|
33
|
+
export type TgRunner = (args: string[], env: NodeJS.ProcessEnv) => TgRunResult
|
|
34
|
+
|
|
35
|
+
export interface RestartOutcome {
|
|
36
|
+
state: 'restarted' | 'refused-foreign-launchd' | 'failed' | 'no-router'
|
|
37
|
+
detail?: string
|
|
38
|
+
}
|
|
39
|
+
export type RestartFn = (humanPeer: string, env: NodeJS.ProcessEnv) => RestartOutcome
|
|
40
|
+
|
|
41
|
+
export interface ConnectTelegramOptions {
|
|
42
|
+
peer: string
|
|
43
|
+
/** --token; absent → tty prompt (BotFather recipe) / non-tty refusal. */
|
|
44
|
+
token?: string
|
|
45
|
+
env?: NodeJS.ProcessEnv
|
|
46
|
+
/** Injectable prompt (tests). Default: readline on the live tty. */
|
|
47
|
+
ask?: (question: string) => Promise<string>
|
|
48
|
+
/** Override tty detection (tests). */
|
|
49
|
+
isTty?: boolean
|
|
50
|
+
/** Injectable telegram-runtime invoker (tests). Default: spawn the manifest's bin. */
|
|
51
|
+
runTg?: TgRunner
|
|
52
|
+
/** Injectable router restart (tests). Default: stopPeer→startPeer strictly in order. */
|
|
53
|
+
restart?: RestartFn
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ConnectTelegramResult {
|
|
57
|
+
state:
|
|
58
|
+
| 'connected' // bot added + interfaced + router restarted — channel live after activation
|
|
59
|
+
| 'noop-same-token' // byte-stable .env → nothing changed, no restart
|
|
60
|
+
| 'refused-no-token' // non-tty and no --token (or an empty tty answer)
|
|
61
|
+
| 'unregistered-peer' // precondition: the peer must be in the registry
|
|
62
|
+
| 'runtime-missing' // no telegram runtime manifest — install it first
|
|
63
|
+
| 'bot-add-failed' // telegram-runtime bot add exited non-zero (incl. getMe refusal)
|
|
64
|
+
| 'interface-failed' // telegram-runtime interface bot exited non-zero
|
|
65
|
+
peer: string
|
|
66
|
+
/** Bot @username when `bot add` surfaced one (owner obligation) — best-effort. */
|
|
67
|
+
username?: string
|
|
68
|
+
restart?: RestartOutcome
|
|
69
|
+
detail?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function ttyAsk(question: string): Promise<string> {
|
|
73
|
+
const { createInterface } = await import('node:readline/promises')
|
|
74
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
75
|
+
try {
|
|
76
|
+
return (await rl.question(question)).trim()
|
|
77
|
+
} finally {
|
|
78
|
+
rl.close()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** bots/<alias>/.env under the telegram runtime scope (stable ABI — owner's fact). */
|
|
83
|
+
export function botEnvPath(alias: string, env: NodeJS.ProcessEnv): string {
|
|
84
|
+
return join(runtimeRoot('telegram', { env }), 'bots', alias, '.env')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readBotEnv(alias: string, env: NodeJS.ProcessEnv): string | null {
|
|
88
|
+
try {
|
|
89
|
+
const p = botEnvPath(alias, env)
|
|
90
|
+
return existsSync(p) ? readFileSync(p, 'utf8') : null
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Resolve the telegram-runtime CLI bin from the manifest's selfConfig command (the
|
|
97
|
+
* package's own absolute bin — PATH-independent, launchd-safe). */
|
|
98
|
+
function resolveTgBin(env: NodeJS.ProcessEnv): string | null {
|
|
99
|
+
const manifest = readRuntimeManifest('telegram', { env })
|
|
100
|
+
if (!manifest) return null
|
|
101
|
+
const sc = manifest.selfConfig
|
|
102
|
+
if (typeof sc === 'string' && sc.trim()) return sc.trim().split(/\s+/)[0]!
|
|
103
|
+
if (sc && typeof sc === 'object' && sc.command) return sc.command
|
|
104
|
+
return 'telegram-runtime' // manifest present but no hook — fall back to PATH
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const defaultRunTg =
|
|
108
|
+
(bin: string): TgRunner =>
|
|
109
|
+
(args, env) => {
|
|
110
|
+
const r = spawnSync(bin, args, { encoding: 'utf8', env: env as Record<string, string> })
|
|
111
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? (r.error?.message ?? '') }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The default router restart: stop→start of the human peer's telegram runtime,
|
|
115
|
+
* STRICTLY sequential (the per-bot runtime.lock with live-pid detection refuses a
|
|
116
|
+
* parallel process — a feature). Deferred import: connect → cli would otherwise be
|
|
117
|
+
* a static cycle (cli imports connect). */
|
|
118
|
+
async function defaultRestart(humanPeer: string, env: NodeJS.ProcessEnv): Promise<RestartOutcome> {
|
|
119
|
+
const { stopPeer, startPeer } = await import('../cli/index.ts')
|
|
120
|
+
try {
|
|
121
|
+
const stops = stopPeer(humanPeer, 'telegram', { env })
|
|
122
|
+
if (stops.some(o => o.action === 'refused-foreign-launchd')) {
|
|
123
|
+
return {
|
|
124
|
+
state: 'refused-foreign-launchd',
|
|
125
|
+
detail: `router "${humanPeer}" is persistent-peer-managed (H4 read-only) — restart it yourself to load the new bot`,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const starts = startPeer(humanPeer, 'telegram', { env })
|
|
129
|
+
const bad = starts.find(o => o.reason && o.action === 'bootstrap')
|
|
130
|
+
return bad ? { state: 'failed', detail: bad.reason } : { state: 'restarted' }
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The registry's natural peer — the router-session owner (ONE poller serves all
|
|
137
|
+
* bots; it runs under the human peer's telegram runtime). */
|
|
138
|
+
function findRouterHuman(env: NodeJS.ProcessEnv): string | null {
|
|
139
|
+
try {
|
|
140
|
+
const naturals = readPeersIndex({ env }).peers.filter(
|
|
141
|
+
p => normalizeIntelligenceValue(p.intelligence) === 'natural' && (p.runtime === 'telegram' || p.runtimes.includes('telegram')),
|
|
142
|
+
)
|
|
143
|
+
return naturals[0]?.personality ?? null
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function connectTelegram(opts: ConnectTelegramOptions): Promise<ConnectTelegramResult> {
|
|
150
|
+
const env = opts.env ?? process.env
|
|
151
|
+
const peer = opts.peer.trim()
|
|
152
|
+
|
|
153
|
+
// Precondition (owner's fact): `interface bot` merges into the peer's profile —
|
|
154
|
+
// the peer must exist in the registry first.
|
|
155
|
+
if (!findPeer(readPeersIndex({ env }), peer)) {
|
|
156
|
+
return { state: 'unregistered-peer', peer, detail: `peer "${peer}" is not registered — create it first (iapeer create ${peer})` }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tgBin = resolveTgBin(env)
|
|
160
|
+
if (!tgBin) {
|
|
161
|
+
return { state: 'runtime-missing', peer, detail: 'telegram runtime is not installed — run: iapeer install-runtime telegram' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The ONE human-owed fact: the bot token.
|
|
165
|
+
let token = opts.token?.trim()
|
|
166
|
+
if (!token) {
|
|
167
|
+
const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
|
|
168
|
+
if (!tty) {
|
|
169
|
+
return { state: 'refused-no-token', peer, detail: 'no tty and no --token — re-run with --token <bot-token> (create one: @BotFather → /newbot)' }
|
|
170
|
+
}
|
|
171
|
+
const ask = opts.ask ?? ttyAsk
|
|
172
|
+
token = (await ask(`bot token for "${peer}" (create: message @BotFather → /newbot → copy the token): `)).trim()
|
|
173
|
+
if (!token) return { state: 'refused-no-token', peer, detail: 'no answer — nothing connected' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const runTg = opts.runTg ?? defaultRunTg(tgBin)
|
|
177
|
+
const alias = peer // bot alias = the peer's personality (design §(в); passes the owner's NAME_RE)
|
|
178
|
+
const before = readBotEnv(alias, env)
|
|
179
|
+
|
|
180
|
+
// (1) bot add — token → bots/<alias>/.env. Owner adds getMe validation here: an
|
|
181
|
+
// invalid token fails EARLY with the platform's reason; we surface it verbatim.
|
|
182
|
+
const add = runTg(['bot', 'add', alias, '--token', token], env)
|
|
183
|
+
if (add.status !== 0) {
|
|
184
|
+
return { state: 'bot-add-failed', peer, detail: (add.stderr || add.stdout || `exit ${add.status}`).trim() }
|
|
185
|
+
}
|
|
186
|
+
const username = add.stdout.match(/@[A-Za-z0-9_]{3,}/)?.[0]
|
|
187
|
+
|
|
188
|
+
// (2) interface bot — merge the channel binding into the peer's profile.
|
|
189
|
+
const iface = runTg(['interface', 'bot', alias, '--peer', peer], env)
|
|
190
|
+
if (iface.status !== 0) {
|
|
191
|
+
return { state: 'interface-failed', peer, username, detail: (iface.stderr || iface.stdout || `exit ${iface.status}`).trim() }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// (3) Idempotency gate: the SAME token leaves bots/<alias>/.env byte-stable →
|
|
195
|
+
// the live poller already loaded these credentials → NO restart, clean no-op.
|
|
196
|
+
const after = readBotEnv(alias, env)
|
|
197
|
+
if (before !== null && after === before) {
|
|
198
|
+
return { state: 'noop-same-token', peer, username, detail: 'same token — byte-stable no-op, router not restarted' }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// (4) MANDATORY router restart (new/changed credentials load only at start).
|
|
202
|
+
// Cost: a seconds-long delivery blip for the whole fleet; inbound is NOT lost
|
|
203
|
+
// (long-polling offset, Telegram holds up to 24 h) — known, accepted (design).
|
|
204
|
+
const human = findRouterHuman(env)
|
|
205
|
+
const restart: RestartOutcome = human
|
|
206
|
+
? opts.restart
|
|
207
|
+
? opts.restart(human, env)
|
|
208
|
+
: await defaultRestart(human, env)
|
|
209
|
+
: { state: 'no-router', detail: 'no natural telegram peer in the registry — start the router manually after onboarding the human' }
|
|
210
|
+
|
|
211
|
+
return { state: 'connected', peer, username, restart }
|
|
212
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -148,7 +148,10 @@ function writeSessionState(cfg: LifecycleConfig, state: SessionState): void {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
/** Drop the supervise session-state. Exported for the STOP verb: a deliberate stop
|
|
152
|
+
* removes the state with the session so superviseTick never tags the kill as a
|
|
153
|
+
* death (no crash-loop entry, no reaped-gone death class). */
|
|
154
|
+
export function removeSessionState(cfg: LifecycleConfig, identity: string): void {
|
|
152
155
|
try {
|
|
153
156
|
rmSync(sessionStatePath(cfg, identity), { force: true })
|
|
154
157
|
} catch {
|
|
@@ -198,10 +201,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
|
|
|
198
201
|
// Lifecycle markers — the DAEMON decides fresh-vs-resume by the DEATH CAUSE it
|
|
199
202
|
// tracks itself (TARGET redesign). Plain files in state/lifecycle/<identity>.* :
|
|
200
203
|
//
|
|
201
|
-
// .idle-reaped : written
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
204
|
+
// .idle-reaped : the CLEAN-PARK marker — written when the daemon idle-reaps the
|
|
205
|
+
// session AND by the deliberate `stop` verb (both are daemon/operator-initiated
|
|
206
|
+
// parks, not faults; boris 10.06: stop→start must survive ≥ idle-reap).
|
|
207
|
+
// Presence on the next wake = session was parked cleanly = RESUME-eligible.
|
|
208
|
+
// ABSENT on a dead session = it died on its own (crash / self-close) = FRESH.
|
|
209
|
+
// (resolver branch 3.)
|
|
205
210
|
// .new-eager : set when /new is invoked (owner reset, via `iapeer self-fresh`).
|
|
206
211
|
// Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
|
|
207
212
|
// wait for a message) and injects initial_prompt. Consumed on the relaunch.
|
|
@@ -230,7 +235,9 @@ export function hasIdleReaped(cfg: LifecycleConfig, identity: string): boolean {
|
|
|
230
235
|
return existsSync(idleReapedPath(cfg, identity))
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
/** Write the
|
|
238
|
+
/** Write the clean-park marker. Three writers, all deliberate parks: the idle-reap
|
|
239
|
+
* path in superviseTick, the `stop` verb (park before kill), and superviseTick's
|
|
240
|
+
* stopped-catch-up branch (a stop that raced the tick). Never a crash path. */
|
|
234
241
|
export function setIdleReaped(cfg: LifecycleConfig, identity: string): void {
|
|
235
242
|
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
236
243
|
writeFileSync(idleReapedPath(cfg, identity), `${new Date().toISOString()}\n`, { mode: 0o600 })
|
|
@@ -462,8 +469,15 @@ export function resolveWakeMode(
|
|
|
462
469
|
clearIdleReaped(cfg, identity)
|
|
463
470
|
return { resume: false, cause: 'ephemeral-policy' }
|
|
464
471
|
}
|
|
465
|
-
// 3a. NOT
|
|
466
|
-
|
|
472
|
+
// 3a. NOT parked-clean → FRESH either way, but tell the two cases apart in the
|
|
473
|
+
// cause (boris finding, 10.06): a peer with NO transcript at all never ran here —
|
|
474
|
+
// that is its FIRST-ever wake, not a crash. (A peer whose transcripts were rotated
|
|
475
|
+
// away also reads first-wake — equally honest: there is nothing it could resume.)
|
|
476
|
+
if (!hasIdleReaped(cfg, identity)) {
|
|
477
|
+
return resolveResume(cwd).ok
|
|
478
|
+
? { resume: false, cause: 'crash-or-self-close' }
|
|
479
|
+
: { resume: false, cause: 'first-wake' }
|
|
480
|
+
}
|
|
467
481
|
// 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
|
|
468
482
|
clearIdleReaped(cfg, identity)
|
|
469
483
|
// human-conversational dialogue never auto-freshes; only an explicit /new resets it.
|
|
@@ -967,7 +981,7 @@ export function killSession(sock: string, identity: string): void {
|
|
|
967
981
|
|
|
968
982
|
export interface SuperviseOutcome {
|
|
969
983
|
identity: string
|
|
970
|
-
action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'alive' | 'needs-eager-fresh'
|
|
984
|
+
action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh'
|
|
971
985
|
reason?: string
|
|
972
986
|
/** For 'needs-eager-fresh': the peer to EAGERLY re-launch fresh (its session died
|
|
973
987
|
* carrying a .new-eager mark). The daemon timer drives the async relaunch.
|
|
@@ -1002,6 +1016,19 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1002
1016
|
}
|
|
1003
1017
|
const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
|
|
1004
1018
|
if (!sessionAlive(sock, s.identity)) {
|
|
1019
|
+
// A DELIBERATE stop is not a death (boris 10.06): the stop verb parks clean
|
|
1020
|
+
// (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
|
|
1021
|
+
// a stop that raced this tick (or a pre-fix stop): drop the state quietly —
|
|
1022
|
+
// no crash-loop entry, no death class — and ensure the clean-park marker so
|
|
1023
|
+
// the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
|
|
1024
|
+
if (isStopped(cfg, s.identity)) {
|
|
1025
|
+
removeSessionState(cfg, s.identity)
|
|
1026
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1027
|
+
setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
|
|
1028
|
+
out.push({ identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' })
|
|
1029
|
+
trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
|
|
1030
|
+
continue
|
|
1031
|
+
}
|
|
1005
1032
|
// A dead session: record a death for crash-loop accounting, then branch on the
|
|
1006
1033
|
// .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
|
|
1007
1034
|
// the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
|
|
@@ -218,6 +218,24 @@ describe('superviseTick H4 guard', () => {
|
|
|
218
218
|
expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
test('a STOPPED peer with a dead session → skipped-stopped: no death record, park marker ensured', () => {
|
|
222
|
+
// boris repro (10.06): `iapeer stop` → next tick used to tag reaped-gone
|
|
223
|
+
// death=server-dead + recordDeath → post-start wake came up FRESH. A deliberate
|
|
224
|
+
// stop is a clean park the daemon knows 100% — never a death.
|
|
225
|
+
const c = cfg()
|
|
226
|
+
const id = writeState('iapeer-supstopped')
|
|
227
|
+
setStopped(c, id)
|
|
228
|
+
const out = superviseTick(c, { env: env(), nowMs: Date.now() })
|
|
229
|
+
const o = out.find(x => x.identity === id)
|
|
230
|
+
expect(o?.action).toBe('skipped-stopped')
|
|
231
|
+
expect(existsSync(join(stateDir, `${id}.session`))).toBe(false) // state dropped quietly
|
|
232
|
+
expect(readDeaths(c, id).length).toBe(0) // NOT a death — crash-loop ring untouched
|
|
233
|
+
expect(hasIdleReaped(c, id)).toBe(true) // clean park → post-start wake resumes
|
|
234
|
+
const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
235
|
+
expect(logged).toContain(`identity=${id} action=skipped-stopped`)
|
|
236
|
+
expect(logged).not.toContain('death=') // no death class for a deliberate stop
|
|
237
|
+
})
|
|
238
|
+
|
|
221
239
|
test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
|
|
222
240
|
const c = cfg()
|
|
223
241
|
const id = writeState('iapeer-supgone') // no plist, no live tmux session
|
|
@@ -433,6 +451,19 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
|
433
451
|
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
|
|
434
452
|
})
|
|
435
453
|
|
|
454
|
+
test('DEFAULT + NOT idle-reaped + NO transcript at all → FRESH with cause=first-wake (not a crash)', () => {
|
|
455
|
+
// boris finding (10.06): the first-ever wake of a freshly created peer used to
|
|
456
|
+
// read crash-or-self-close — a mis-classification. No transcript = never ran.
|
|
457
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, noTranscript)).toEqual({ resume: false, cause: 'first-wake' })
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ── stop→start is a CLEAN PARK (boris 10.06): the park marker → RESUME ────────
|
|
461
|
+
test('stop→start path: clean-park marker set by stop → the next default wake RESUMES', () => {
|
|
462
|
+
const c = cfg()
|
|
463
|
+
setIdleReaped(c, 'claude-p') // what the stop verb writes before killing
|
|
464
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
|
|
465
|
+
})
|
|
466
|
+
|
|
436
467
|
// ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
|
|
437
468
|
test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
|
|
438
469
|
const c = cfg()
|
package/src/onboard/index.ts
CHANGED
|
@@ -64,8 +64,12 @@ function isExecutable(binOrName: string): boolean {
|
|
|
64
64
|
return false
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
// bare name → resolved by spawnSync against PATH; probe with `which`-free spawn
|
|
68
|
-
|
|
67
|
+
// bare name → resolved by spawnSync against PATH; probe with `which`-free spawn.
|
|
68
|
+
// HARD TIMEOUT (live find 10.06): `codex --version` HANGS FOREVER in a non-tty
|
|
69
|
+
// environment (three stray probes sat 25+ min; an onboard --dry-run piped to a
|
|
70
|
+
// file never printed a byte). A hung probe must degrade to 'runtime-missing',
|
|
71
|
+
// not wedge the whole onboard.
|
|
72
|
+
const r = spawnSync(binOrName, ['--version'], { stdio: 'ignore', timeout: 10_000 })
|
|
69
73
|
return r.error === undefined && r.status !== null
|
|
70
74
|
}
|
|
71
75
|
|
package/src/runtime/index.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface RuntimePeerDecl {
|
|
|
51
51
|
export interface RuntimeManifest {
|
|
52
52
|
/** The runtime id this manifest describes (must match the folder it lives in). */
|
|
53
53
|
runtime: Runtime
|
|
54
|
+
/** OPTIONAL installed package version (the owners' self-install stamp — the
|
|
55
|
+
* telegram owner's 10.06 obligation). `update-runtime` version-gates on it;
|
|
56
|
+
* absent → no gate, the update re-installs idempotently. */
|
|
57
|
+
version?: string
|
|
54
58
|
/** OPTIONAL per-peer self-config hook (the shared contract both modes call). */
|
|
55
59
|
selfConfig?: SelfConfigDescriptor
|
|
56
60
|
/** OPTIONAL fixed peer-set (mode a). Omitted by an operator-add runtime (mode b). */
|
|
@@ -108,7 +112,8 @@ function normalizeManifest(raw: unknown, runtime: Runtime): RuntimeManifest {
|
|
|
108
112
|
]
|
|
109
113
|
})
|
|
110
114
|
}
|
|
111
|
-
|
|
115
|
+
const version = typeof obj.version === 'string' && obj.version.trim() ? obj.version.trim() : undefined
|
|
116
|
+
return { runtime: declaredRuntime, ...(version ? { version } : {}), ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
/** Read a runtime's manifest (~/.iapeer/runtimes/<runtime>/runtime.json), or null when
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// update-runtime (§(г)) — version-gate / forced re-install / idempotent re-provision /
|
|
2
|
+
// peer restart. Sandboxed (IAPEER_ROOT temp dirs, IAPEER_TEST_SANDBOX), injected
|
|
3
|
+
// npx + npm-version + restart.
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
7
|
+
import { tmpdir } from 'os'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import { updateAllRuntimes, updateRuntime, type RestartedPeer } from './update.ts'
|
|
10
|
+
import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest } from './index.ts'
|
|
11
|
+
import { upsertPeer } from '../registry/index.ts'
|
|
12
|
+
|
|
13
|
+
const roots: string[] = []
|
|
14
|
+
function mkTmp(): string {
|
|
15
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-rtup-'))
|
|
16
|
+
roots.push(d)
|
|
17
|
+
return d
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function envFor(root: string, path?: string): NodeJS.ProcessEnv {
|
|
24
|
+
return {
|
|
25
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
26
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
|
|
27
|
+
IAPEER_TEST_SANDBOX: '1',
|
|
28
|
+
HOME: root,
|
|
29
|
+
...(path ? { PATH: path } : {}),
|
|
30
|
+
} as NodeJS.ProcessEnv
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stubBins(): { dir: string; hook: string } {
|
|
34
|
+
const dir = mkTmp()
|
|
35
|
+
writeFileSync(join(dir, 'notifier-runtime'), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
|
|
36
|
+
const hook = join(dir, 'sc.sh')
|
|
37
|
+
writeFileSync(hook, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
|
38
|
+
return { dir, hook }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const restartOk =
|
|
42
|
+
(log: string[]) =>
|
|
43
|
+
(personality: string): RestartedPeer => {
|
|
44
|
+
log.push(personality)
|
|
45
|
+
return { personality, state: 'restarted' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('updateRuntime (§(г): gate → re-install → re-provision → restart)', () => {
|
|
49
|
+
test('full update: re-npx forced, declared set re-provisioned, peers restarted', async () => {
|
|
50
|
+
const root = mkTmp()
|
|
51
|
+
const { dir, hook } = stubBins()
|
|
52
|
+
const env = envFor(root, dir)
|
|
53
|
+
writeRuntimeManifest(
|
|
54
|
+
{ runtime: 'notifier', version: '0.1.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
|
|
55
|
+
{ env },
|
|
56
|
+
)
|
|
57
|
+
// the live-host layout: the declared peer already sits in its DEFAULT location
|
|
58
|
+
// (re-provision must be a no-clobber pass over it, not an identity conflict)
|
|
59
|
+
await upsertPeer(
|
|
60
|
+
{ personality: 'timer', runtime: 'notifier', cwd: join(root, 'iapeer', 'peers', 'timer'), intelligence: 'absent' },
|
|
61
|
+
{ env },
|
|
62
|
+
)
|
|
63
|
+
let npxRan = false
|
|
64
|
+
const restarts: string[] = []
|
|
65
|
+
const r = await updateRuntime({
|
|
66
|
+
runtime: 'notifier',
|
|
67
|
+
env,
|
|
68
|
+
runNpx: (_pkg, e) => {
|
|
69
|
+
npxRan = true
|
|
70
|
+
// the package self-deploys the NEW manifest with the new version stamp
|
|
71
|
+
const m: RuntimeManifest = { runtime: 'notifier', version: '0.2.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] }
|
|
72
|
+
writeRuntimeManifest(m, { env: e })
|
|
73
|
+
return { ok: true }
|
|
74
|
+
},
|
|
75
|
+
npmVersion: () => '0.2.0',
|
|
76
|
+
restartPeer: restartOk(restarts),
|
|
77
|
+
})
|
|
78
|
+
expect(r.state).toBe('updated')
|
|
79
|
+
expect(npxRan).toBe(true) // FORCED re-npx despite the present manifest
|
|
80
|
+
expect(r.from).toBe('0.1.0')
|
|
81
|
+
expect(r.to).toBe('0.2.0')
|
|
82
|
+
expect(r.peers.map(p => p.personality)).toEqual(['timer']) // re-provisioned
|
|
83
|
+
expect(restarts).toEqual(['timer']) // restarted via the injected stop/start
|
|
84
|
+
expect(readRuntimeManifest('notifier', { env })?.version).toBe('0.2.0')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('version-gate: manifest stamp equals npm latest → already-latest, NOTHING runs', async () => {
|
|
88
|
+
const env = envFor(mkTmp())
|
|
89
|
+
writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0' }, { env })
|
|
90
|
+
let npxRan = false
|
|
91
|
+
const r = await updateRuntime({ runtime: 'notifier', env, runNpx: () => ((npxRan = true), { ok: true }), npmVersion: () => '0.2.0' })
|
|
92
|
+
expect(r.state).toBe('already-latest')
|
|
93
|
+
expect(npxRan).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('--force overrides the gate', async () => {
|
|
97
|
+
const root = mkTmp()
|
|
98
|
+
const { dir, hook } = stubBins()
|
|
99
|
+
const env = envFor(root, dir)
|
|
100
|
+
writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0', selfConfig: hook }, { env })
|
|
101
|
+
let npxRan = false
|
|
102
|
+
const r = await updateRuntime({
|
|
103
|
+
runtime: 'notifier',
|
|
104
|
+
env,
|
|
105
|
+
force: true,
|
|
106
|
+
runNpx: () => ((npxRan = true), { ok: true }),
|
|
107
|
+
npmVersion: () => '0.2.0',
|
|
108
|
+
restartPeer: restartOk([]),
|
|
109
|
+
})
|
|
110
|
+
expect(r.state).toBe('updated')
|
|
111
|
+
expect(npxRan).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('no version stamp → gate skipped, idempotent re-install with an honest detail', async () => {
|
|
115
|
+
const root = mkTmp()
|
|
116
|
+
const { dir, hook } = stubBins()
|
|
117
|
+
const env = envFor(root, dir)
|
|
118
|
+
writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env }) // owners have not stamped yet
|
|
119
|
+
const r = await updateRuntime({
|
|
120
|
+
runtime: 'notifier',
|
|
121
|
+
env,
|
|
122
|
+
runNpx: () => ({ ok: true }),
|
|
123
|
+
npmVersion: () => '0.2.0',
|
|
124
|
+
restartPeer: restartOk([]),
|
|
125
|
+
})
|
|
126
|
+
expect(r.state).toBe('updated')
|
|
127
|
+
expect(r.detail).toContain('no version stamp')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('not installed → not-installed with the install-runtime recipe; npm unreachable → loud', async () => {
|
|
131
|
+
const env = envFor(mkTmp())
|
|
132
|
+
const r = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => '0.2.0' })
|
|
133
|
+
expect(r.state).toBe('not-installed')
|
|
134
|
+
expect(r.detail).toContain('install-runtime notifier')
|
|
135
|
+
writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
|
|
136
|
+
const r2 = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => null })
|
|
137
|
+
expect(r2.state).toBe('npm-unreachable')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('failed re-npx → install-failed, no deploy, no restarts', async () => {
|
|
141
|
+
const env = envFor(mkTmp())
|
|
142
|
+
writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
|
|
143
|
+
const restarts: string[] = []
|
|
144
|
+
const r = await updateRuntime({
|
|
145
|
+
runtime: 'notifier',
|
|
146
|
+
env,
|
|
147
|
+
runNpx: () => ({ ok: false, detail: 'network down' }),
|
|
148
|
+
npmVersion: () => '0.2.0',
|
|
149
|
+
restartPeer: restartOk(restarts),
|
|
150
|
+
})
|
|
151
|
+
expect(r.state).toBe('install-failed')
|
|
152
|
+
expect(restarts).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('mode-b runtime (telegram, no declared peers): empty re-provision, registered router restarted', async () => {
|
|
156
|
+
const root = mkTmp()
|
|
157
|
+
const env = envFor(root)
|
|
158
|
+
writeRuntimeManifest({ runtime: 'telegram', version: '0.10.0' }, { env })
|
|
159
|
+
await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
|
|
160
|
+
const restarts: string[] = []
|
|
161
|
+
const r = await updateRuntime({
|
|
162
|
+
runtime: 'telegram',
|
|
163
|
+
env,
|
|
164
|
+
runNpx: (_pkg, e) => {
|
|
165
|
+
writeRuntimeManifest({ runtime: 'telegram', version: '0.10.3' }, { env: e })
|
|
166
|
+
return { ok: true }
|
|
167
|
+
},
|
|
168
|
+
npmVersion: () => '0.10.3',
|
|
169
|
+
restartPeer: restartOk(restarts),
|
|
170
|
+
})
|
|
171
|
+
expect(r.state).toBe('updated')
|
|
172
|
+
expect(r.peers).toEqual([]) // operator-add runtime: nothing declared to re-provision
|
|
173
|
+
expect(restarts).toEqual(['arthur']) // the router still restarts onto the new code
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('updateAllRuntimes (--all)', () => {
|
|
178
|
+
test('updates installed runtimes, reports the rest not-installed (never an error)', async () => {
|
|
179
|
+
const root = mkTmp()
|
|
180
|
+
const env = envFor(root)
|
|
181
|
+
writeRuntimeManifest({ runtime: 'telegram', version: '1.0.0' }, { env })
|
|
182
|
+
const results = await updateAllRuntimes({
|
|
183
|
+
env,
|
|
184
|
+
runNpx: () => ({ ok: true }),
|
|
185
|
+
npmVersion: () => '1.0.0',
|
|
186
|
+
restartPeer: restartOk([]),
|
|
187
|
+
})
|
|
188
|
+
const byRt = Object.fromEntries(results.map(r => [r.runtime, r.state]))
|
|
189
|
+
expect(byRt.telegram).toBe('already-latest')
|
|
190
|
+
expect(byRt.notifier).toBe('not-installed')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// update-runtime — the runtime-package update story (design «Onboard костяка» §(г),
|
|
2
|
+
// FINAL 10.06; owner facts: NO state migrations on either package — notifier is
|
|
3
|
+
// stateless-by-design (registrations live durable in the OWNING peers' profiles),
|
|
4
|
+
// telegram's bots/.env is a stable ABI, its lock is transient self-healing).
|
|
5
|
+
//
|
|
6
|
+
// version-gate (npm) → re-install the package → IDEMPOTENT re-provision through
|
|
7
|
+
// the SAME path install-runtime uses (npx self-deploy + declared-set deploy:
|
|
8
|
+
// PEER_BLURB registry sync, re-self-config, manifest refresh; live peers are
|
|
9
|
+
// never clobbered — the notifier owner's point: without the re-provision a
|
|
10
|
+
// version with a new blurb/self-doc leaves stale descriptions) → restart the
|
|
11
|
+
// runtime's infra peers via the REGULAR stop/start verbs.
|
|
12
|
+
//
|
|
13
|
+
// The core's own `iapeer update` deliberately does NOT touch runtimes (foundation-
|
|
14
|
+
// only — the standing contract; the symmetry is conscious).
|
|
15
|
+
//
|
|
16
|
+
// Version-gate honesty: the installed version comes from the manifest's `version`
|
|
17
|
+
// stamp (the owners' self-install obligation, telegram 10.06). A manifest WITHOUT
|
|
18
|
+
// the stamp cannot be gated — the update proceeds idempotently and says so.
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'child_process'
|
|
21
|
+
import { type Runtime } from '../core/constants.ts'
|
|
22
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
23
|
+
import { readRuntimeManifest } from './index.ts'
|
|
24
|
+
import {
|
|
25
|
+
deployRuntime,
|
|
26
|
+
installRuntimePackage,
|
|
27
|
+
resolveRuntimePackage,
|
|
28
|
+
RUNTIME_PACKAGES,
|
|
29
|
+
type DeployedPeer,
|
|
30
|
+
type NpxRunner,
|
|
31
|
+
} from './deploy.ts'
|
|
32
|
+
|
|
33
|
+
/** Injectable npm-version resolver (tests). Default: `npm view <pkg> version`. */
|
|
34
|
+
export type NpmVersionFn = (pkg: string, env: NodeJS.ProcessEnv) => string | null
|
|
35
|
+
|
|
36
|
+
const defaultNpmVersion: NpmVersionFn = (pkg, env) => {
|
|
37
|
+
const r = spawnSync('npm', ['view', pkg, 'version'], { encoding: 'utf8', env: env as Record<string, string>, timeout: 60_000 })
|
|
38
|
+
const v = (r.stdout ?? '').trim()
|
|
39
|
+
return r.status === 0 && v ? v : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RestartedPeer {
|
|
43
|
+
personality: string
|
|
44
|
+
state: 'restarted' | 'refused-foreign-launchd' | 'failed'
|
|
45
|
+
detail?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UpdateRuntimeResult {
|
|
49
|
+
runtime: Runtime
|
|
50
|
+
package?: string
|
|
51
|
+
state:
|
|
52
|
+
| 'updated' // re-installed + re-provisioned + peers restarted
|
|
53
|
+
| 'already-latest' // version-gate: the manifest stamp equals npm latest
|
|
54
|
+
| 'not-installed' // no manifest — nothing to update (install-runtime first)
|
|
55
|
+
| 'npm-unreachable' // npm view failed — cannot resolve the target version
|
|
56
|
+
| 'install-failed' // the forced re-npx failed — nothing was touched further
|
|
57
|
+
| 'deploy-failed' // re-provision broke (per-peer detail in `peers`)
|
|
58
|
+
from?: string
|
|
59
|
+
to?: string
|
|
60
|
+
peers: DeployedPeer[]
|
|
61
|
+
restarted: RestartedPeer[]
|
|
62
|
+
detail?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UpdateRuntimeOptions {
|
|
66
|
+
runtime: Runtime
|
|
67
|
+
/** Re-install even when the version-gate says already-latest. */
|
|
68
|
+
force?: boolean
|
|
69
|
+
env?: NodeJS.ProcessEnv
|
|
70
|
+
runNpx?: NpxRunner
|
|
71
|
+
npmVersion?: NpmVersionFn
|
|
72
|
+
/** Injectable restart (tests). Default: the regular stop→start verbs, strictly
|
|
73
|
+
* sequential per peer. */
|
|
74
|
+
restartPeer?: (personality: string, runtime: Runtime, env: NodeJS.ProcessEnv) => RestartedPeer
|
|
75
|
+
warn?: (message: string) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Default per-peer restart: the REGULAR stop→start verbs (infra → launchctl
|
|
79
|
+
* bootout/bootstrap), strictly sequential. H4 stays intact: a PP-managed peer is
|
|
80
|
+
* refused by the verbs themselves and reported, never forced. */
|
|
81
|
+
async function defaultRestartPeer(personality: string, runtime: Runtime, env: NodeJS.ProcessEnv): Promise<RestartedPeer> {
|
|
82
|
+
const { stopPeer, startPeer } = await import('../cli/index.ts')
|
|
83
|
+
try {
|
|
84
|
+
const stops = stopPeer(personality, runtime, { env })
|
|
85
|
+
if (stops.some(o => o.action === 'refused-foreign-launchd')) {
|
|
86
|
+
return { personality, state: 'refused-foreign-launchd', detail: 'persistent-peer-managed (H4) — restart it yourself' }
|
|
87
|
+
}
|
|
88
|
+
const starts = startPeer(personality, runtime, { env })
|
|
89
|
+
const bad = starts.find(o => o.action === 'bootstrap' && o.reason)
|
|
90
|
+
return bad ? { personality, state: 'failed', detail: bad.reason } : { personality, state: 'restarted' }
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { personality, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateRuntimeResult> {
|
|
97
|
+
const env = opts.env ?? process.env
|
|
98
|
+
const runtime = opts.runtime
|
|
99
|
+
const manifest = readRuntimeManifest(runtime, { env })
|
|
100
|
+
if (!manifest) {
|
|
101
|
+
return {
|
|
102
|
+
runtime,
|
|
103
|
+
state: 'not-installed',
|
|
104
|
+
peers: [],
|
|
105
|
+
restarted: [],
|
|
106
|
+
detail: `no manifest for "${runtime}" — install first: iapeer install-runtime ${runtime}`,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const pkg = resolveRuntimePackage(runtime)
|
|
110
|
+
if (!pkg) {
|
|
111
|
+
return { runtime, state: 'not-installed', peers: [], restarted: [], detail: `no package mapping for "${runtime}"` }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Version-gate: npm latest vs the manifest stamp. No stamp → no gate (proceed
|
|
115
|
+
// idempotently — the owners' stamp obligation closes this once shipped).
|
|
116
|
+
const latest = (opts.npmVersion ?? defaultNpmVersion)(pkg, env)
|
|
117
|
+
if (!latest) {
|
|
118
|
+
return { runtime, package: pkg, state: 'npm-unreachable', peers: [], restarted: [], detail: `npm view ${pkg} version failed — cannot resolve the target` }
|
|
119
|
+
}
|
|
120
|
+
const installed = manifest.version
|
|
121
|
+
if (installed && installed === latest && !opts.force) {
|
|
122
|
+
return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Re-install (forced npx — the package self-deploys its new bin + manifest).
|
|
126
|
+
const install = installRuntimePackage({ runtime, force: true, env, runNpx: opts.runNpx })
|
|
127
|
+
if (install.state !== 'ran') {
|
|
128
|
+
return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// IDEMPOTENT re-provision via the SAME deploy path install-runtime uses: blurb
|
|
132
|
+
// sync, re-self-config, no-clobber on live peers. Mode-b (telegram) declares no
|
|
133
|
+
// peers — the deploy is an empty pass, by design.
|
|
134
|
+
let peers: DeployedPeer[]
|
|
135
|
+
try {
|
|
136
|
+
const d = await deployRuntime({ runtime, env, warn: opts.warn })
|
|
137
|
+
peers = d.peers
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
|
|
140
|
+
}
|
|
141
|
+
if (peers.some(p => p.selfConfig === 'failed' || p.bootstrap === 'failed')) {
|
|
142
|
+
return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers, restarted: [], detail: 'a declared peer failed re-provision — see the per-peer lines' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Restart THIS runtime's registered peers so the new baked code runs (notifier
|
|
146
|
+
// semantics per the owner: cron wall-clock unaffected, @every re-anchors,
|
|
147
|
+
// watcher children relaunch, heartbeat windows reset). Strictly sequential.
|
|
148
|
+
const registered = readPeersIndex({ env }).peers.filter(
|
|
149
|
+
p => p.runtime === runtime || p.runtimes.includes(runtime),
|
|
150
|
+
)
|
|
151
|
+
const restarted: RestartedPeer[] = []
|
|
152
|
+
for (const p of registered) {
|
|
153
|
+
restarted.push(
|
|
154
|
+
opts.restartPeer
|
|
155
|
+
? opts.restartPeer(p.personality, runtime, env)
|
|
156
|
+
: await defaultRestartPeer(p.personality, runtime, env),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
runtime,
|
|
162
|
+
package: pkg,
|
|
163
|
+
state: 'updated',
|
|
164
|
+
from: installed,
|
|
165
|
+
to: latest,
|
|
166
|
+
peers,
|
|
167
|
+
restarted,
|
|
168
|
+
detail: installed ? undefined : 'no version stamp in the old manifest — gate skipped, re-installed idempotently',
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `--all`: update every KNOWN runtime that is actually installed (manifest present);
|
|
173
|
+
* the rest are reported as not-installed, never an error. */
|
|
174
|
+
export async function updateAllRuntimes(opts: Omit<UpdateRuntimeOptions, 'runtime'> = {}): Promise<UpdateRuntimeResult[]> {
|
|
175
|
+
const env = opts.env ?? process.env
|
|
176
|
+
const out: UpdateRuntimeResult[] = []
|
|
177
|
+
for (const rt of Object.keys(RUNTIME_PACKAGES) as Runtime[]) {
|
|
178
|
+
if (!readRuntimeManifest(rt, { env })) {
|
|
179
|
+
out.push({ runtime: rt, state: 'not-installed', peers: [], restarted: [], detail: 'not installed — skipped' })
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
out.push(await updateRuntime({ ...opts, runtime: rt }))
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
}
|