@agfpd/iapeer 0.2.77 → 0.2.78
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/LICENSE +202 -0
- package/README.md +2 -2
- package/package.json +21 -2
- package/src/cli/index.ts +47 -48
- package/src/connect/index.ts +4 -4
- package/src/core/constants.ts +3 -3
- package/src/core/normalize.ts +2 -2
- package/src/create/index.ts +1 -1
- package/src/daemon/index.ts +5 -5
- package/src/daemon/main.ts +9 -9
- package/src/enable/index.ts +19 -18
- package/src/enable/provisionCommand.ts +10 -10
- package/src/identity/index.ts +2 -2
- package/src/identity/profileStandard.ts +8 -8
- package/src/init/index.ts +10 -10
- package/src/install/index.ts +2 -2
- package/src/install/signing.ts +3 -4
- package/src/launch/adapters/claude.ts +7 -7
- package/src/launch/adapters/codex.ts +5 -6
- package/src/launch/canary.ts +8 -8
- package/src/launch/cmdlog.ts +3 -3
- package/src/launch/composeSystemPrompt.ts +2 -1
- package/src/launch/index.ts +5 -6
- package/src/launch/invocation.ts +1 -1
- package/src/launch/launchd.ts +14 -15
- package/src/launch/launchdRun.ts +6 -6
- package/src/launch/nativeMemory.ts +9 -9
- package/src/launch/ptyHost.ts +1 -1
- package/src/launch/readyGateModel.ts +3 -3
- package/src/launch/types.ts +7 -7
- package/src/lifecycle/eventlog.ts +2 -2
- package/src/lifecycle/index.ts +21 -21
- package/src/lifecycle/queue.ts +1 -1
- package/src/onboard/index.ts +19 -13
- package/src/onboard/memory.ts +5 -5
- package/src/onboard/steps.ts +8 -9
- package/src/provision/index.ts +2 -3
- package/src/runtime/deploy.ts +2 -2
- package/src/runtime/index.ts +4 -4
- package/src/shadow/gate.ts +6 -6
- package/src/shadow/index.ts +2 -2
- package/src/status/index.ts +11 -11
- package/src/storage/index.ts +1 -1
- package/src/supervisor/daemon.ts +14 -8
- package/src/transport/index.ts +11 -11
- package/src/update/index.ts +4 -4
- package/src/cli/cli.test.ts +0 -603
- package/src/cli/listTui.test.ts +0 -71
- package/src/codec/codec.test.ts +0 -271
- package/src/connect/connect.test.ts +0 -163
- package/src/core/constants.test.ts +0 -32
- package/src/core/normalize.test.ts +0 -98
- package/src/create/create.test.ts +0 -143
- package/src/daemon/daemon-http.test.ts +0 -114
- package/src/daemon/daemon.test.ts +0 -267
- package/src/daemon/main.test.ts +0 -413
- package/src/enable/enable.test.ts +0 -95
- package/src/enable/provisionCommand.test.ts +0 -107
- package/src/identity/identity.test.ts +0 -279
- package/src/identity/profileStandard.test.ts +0 -204
- package/src/init/init.test.ts +0 -215
- package/src/init/runtime-resolve.test.ts +0 -49
- package/src/install/install.test.ts +0 -66
- package/src/install/signing.test.ts +0 -84
- package/src/launch/bootdeliver.test.ts +0 -108
- package/src/launch/bootstrap.test.ts +0 -56
- package/src/launch/canary.test.ts +0 -346
- package/src/launch/cmdlog.test.ts +0 -94
- package/src/launch/codexHooksTrust.test.ts +0 -248
- package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
- package/src/launch/composeSystemPrompt.test.ts +0 -145
- package/src/launch/hostComposerOccupied.test.ts +0 -63
- package/src/launch/invocation.test.ts +0 -174
- package/src/launch/launch.test.ts +0 -359
- package/src/launch/launchd.test.ts +0 -499
- package/src/launch/nativeMemory.test.ts +0 -193
- package/src/launch/ptyHost.test.ts +0 -38
- package/src/launch/readyGateModel.test.ts +0 -72
- package/src/launch/sockdir.test.ts +0 -74
- package/src/lifecycle/eventlog.test.ts +0 -114
- package/src/lifecycle/lifecycle.test.ts +0 -1056
- package/src/lifecycle/queue.test.ts +0 -185
- package/src/onboard/memory.test.ts +0 -157
- package/src/onboard/onboard.test.ts +0 -39
- package/src/onboard/steps.test.ts +0 -201
- package/src/provision/provision.test.ts +0 -288
- package/src/registry/registry.test.ts +0 -432
- package/src/runtime/runtime.test.ts +0 -226
- package/src/runtime/update.test.ts +0 -192
- package/src/shadow/enumerate-skip.test.ts +0 -67
- package/src/shadow/gate.test.ts +0 -286
- package/src/shadow/install.test.ts +0 -153
- package/src/shadow/shadow.test.ts +0 -42
- package/src/status/status.test.ts +0 -208
- package/src/storage/peers-home.test.ts +0 -34
- package/src/storage/rotatelog.test.ts +0 -44
- package/src/storage/storage.test.ts +0 -65
- package/src/supervisor/attached-marker.test.ts +0 -84
- package/src/supervisor/boot.test.ts +0 -66
- package/src/supervisor/client.attach.test.ts +0 -107
- package/src/supervisor/daemon.test.ts +0 -213
- package/src/supervisor/deliver.test.ts +0 -58
- package/src/supervisor/panelog.test.ts +0 -95
- package/src/supervisor/protocol.test.ts +0 -222
- package/src/supervisor/selfargv.test.ts +0 -22
- package/src/supervisor/spinner-strip.test.ts +0 -52
- package/src/transport/compactDone.test.ts +0 -144
- package/src/transport/composerQueue.test.ts +0 -204
- package/src/transport/hostControl.test.ts +0 -87
- package/src/transport/hostDeliver.test.ts +0 -137
- package/src/transport/hostLive.test.ts +0 -50
- package/src/transport/routesend-wake.test.ts +0 -357
- package/src/update/update.test.ts +0 -208
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { spawnSync } from 'node:child_process'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
import { isFoundationOwnedPlist } from '../launch/launchd.ts'
|
|
7
|
-
import {
|
|
8
|
-
SHADOW_PERSONALITY,
|
|
9
|
-
SHADOW_PLIST_LABEL,
|
|
10
|
-
buildShadowPlistSpec,
|
|
11
|
-
cycleShadowJob,
|
|
12
|
-
installShadowJob,
|
|
13
|
-
installShadowPlist,
|
|
14
|
-
shadowPlistPath,
|
|
15
|
-
uninstallShadowJob,
|
|
16
|
-
} from './install.ts'
|
|
17
|
-
|
|
18
|
-
// Hermetic: every plist write lands under IAPEER_LAUNCHAGENTS_DIR / IAPEER_ROOT overrides, and
|
|
19
|
-
// the suite runs with IAPEER_TEST_SANDBOX=1 (process.env) so the launchctl-touching paths
|
|
20
|
-
// (installShadowJob / cycleShadowJob / uninstallShadowJob) fail-CLOSED to skipped-sandbox — they
|
|
21
|
-
// NEVER load/unload a real launchd job. No tmux, no @xterm (install.ts is pure plumbing).
|
|
22
|
-
|
|
23
|
-
const tmpDirs: string[] = []
|
|
24
|
-
function mkEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
25
|
-
const root = mkdtempSync(join(tmpdir(), 'iapeer-shadow-install-'))
|
|
26
|
-
tmpDirs.push(root)
|
|
27
|
-
return {
|
|
28
|
-
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
|
|
29
|
-
IAPEER_ROOT: join(root, 'iapeer'),
|
|
30
|
-
HOME: root,
|
|
31
|
-
PATH: '/usr/bin:/bin',
|
|
32
|
-
...extra,
|
|
33
|
-
} as NodeJS.ProcessEnv
|
|
34
|
-
}
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true })
|
|
37
|
-
})
|
|
38
|
-
const plutilLint = (path: string): boolean => spawnSync('plutil', ['-lint', path], { encoding: 'utf8' }).status === 0
|
|
39
|
-
|
|
40
|
-
describe('buildShadowPlistSpec', () => {
|
|
41
|
-
test('label is com.iapeer.shadow-fidelity and runs the INSTALLED `iapeer shadow`', () => {
|
|
42
|
-
const spec = buildShadowPlistSpec({ HOME: '/Users/x' } as NodeJS.ProcessEnv)
|
|
43
|
-
expect(spec.label).toBe(SHADOW_PLIST_LABEL)
|
|
44
|
-
expect(SHADOW_PLIST_LABEL).toBe('com.iapeer.shadow-fidelity')
|
|
45
|
-
expect(spec.programArguments).toEqual(['/Users/x/.local/bin/iapeer', 'shadow'])
|
|
46
|
-
})
|
|
47
|
-
test('launchd-minimal PATH includes /opt/homebrew/bin (tmux lives there — the observer shells read-only tmux)', () => {
|
|
48
|
-
expect(buildShadowPlistSpec({ HOME: '/Users/x' } as NodeJS.ProcessEnv).environment.PATH).toContain('/opt/homebrew/bin')
|
|
49
|
-
})
|
|
50
|
-
test('stdout/stderr land under ~/.iapeer/logs/iapeer', () => {
|
|
51
|
-
const spec = buildShadowPlistSpec(mkEnv())
|
|
52
|
-
expect(spec.stdoutPath).toContain(join('logs', 'iapeer', 'shadow-fidelity.launchd-stdout.log'))
|
|
53
|
-
expect(spec.stderrPath).toContain(join('logs', 'iapeer', 'shadow-fidelity.launchd-stderr.log'))
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
describe('installShadowPlist', () => {
|
|
58
|
-
test('writes a valid, foundation-owned (sentinel) always-on plist under the override dir', () => {
|
|
59
|
-
const env = mkEnv()
|
|
60
|
-
const r = installShadowPlist(env)
|
|
61
|
-
expect(r.path).toBe(shadowPlistPath(env))
|
|
62
|
-
expect(r.changed).toBe(true)
|
|
63
|
-
expect(existsSync(r.path)).toBe(true)
|
|
64
|
-
expect(isFoundationOwnedPlist(r.path)).toBe(true) // carries the ownership sentinel
|
|
65
|
-
expect(plutilLint(r.path)).toBe(true)
|
|
66
|
-
const xml = readFileSync(r.path, 'utf8')
|
|
67
|
-
expect(xml).toContain(`<string>${SHADOW_PLIST_LABEL}</string>`)
|
|
68
|
-
expect(xml).toContain('<string>shadow</string>')
|
|
69
|
-
expect(xml).toContain('<key>RunAtLoad</key>')
|
|
70
|
-
expect(xml).toContain('<key>KeepAlive</key>')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
test('idempotent by content: an unchanged re-install does NOT rewrite (changed:false, mtime stable); a changed PATH DOES', () => {
|
|
74
|
-
const env = mkEnv()
|
|
75
|
-
const first = installShadowPlist(env)
|
|
76
|
-
expect(first.changed).toBe(true)
|
|
77
|
-
const mtime1 = statSync(first.path).mtimeMs
|
|
78
|
-
const second = installShadowPlist(env)
|
|
79
|
-
expect(second.changed).toBe(false)
|
|
80
|
-
expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched (no BTM spam)
|
|
81
|
-
// a different rendered plist (PATH override) rewrites
|
|
82
|
-
const third = installShadowPlist(mkEnv({ IAPEER_SHADOW_PATH: '/opt/homebrew/bin:/usr/bin:/bin', IAPEER_LAUNCHAGENTS_DIR: env.IAPEER_LAUNCHAGENTS_DIR!, IAPEER_ROOT: env.IAPEER_ROOT!, HOME: env.HOME! }))
|
|
83
|
-
expect(third.changed).toBe(true)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test('collision guard: REFUSES to overwrite a foreign (no-sentinel) plist at the label', () => {
|
|
87
|
-
const env = mkEnv()
|
|
88
|
-
const laDir = env.IAPEER_LAUNCHAGENTS_DIR!
|
|
89
|
-
mkdirSync(laDir, { recursive: true })
|
|
90
|
-
const path = shadowPlistPath(env)
|
|
91
|
-
const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.shadow-fidelity</string></dict></plist>\n'
|
|
92
|
-
writeFileSync(path, foreign)
|
|
93
|
-
expect(() => installShadowPlist(env)).toThrow(/foundation-managed|refus/i)
|
|
94
|
-
expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched — guard fires before any write
|
|
95
|
-
})
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
describe('installShadowJob (sandbox fail-closed)', () => {
|
|
99
|
-
test('writes the plist FILE but NEVER touches launchctl under IAPEER_TEST_SANDBOX → skipped-sandbox', () => {
|
|
100
|
-
const env = mkEnv()
|
|
101
|
-
const r = installShadowJob(env)
|
|
102
|
-
expect(r.action).toBe('skipped-sandbox')
|
|
103
|
-
expect(r.path).toBe(shadowPlistPath(env))
|
|
104
|
-
expect(r.changed).toBe(true)
|
|
105
|
-
expect(existsSync(r.path)).toBe(true) // file written to the override dir
|
|
106
|
-
expect(isFoundationOwnedPlist(r.path)).toBe(true)
|
|
107
|
-
})
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
describe('cycleShadowJob (deploy re-cycle hook)', () => {
|
|
111
|
-
test('returns null when no plist exists (nothing to recycle)', () => {
|
|
112
|
-
expect(cycleShadowJob(mkEnv())).toBeNull()
|
|
113
|
-
})
|
|
114
|
-
test('a written foundation plist recycles to skipped-sandbox under the test flag (never a real launchctl cycle)', () => {
|
|
115
|
-
const env = mkEnv()
|
|
116
|
-
installShadowPlist(env)
|
|
117
|
-
expect(cycleShadowJob(env)?.state).toBe('skipped-sandbox')
|
|
118
|
-
})
|
|
119
|
-
test('a foreign plist at the label is NOT recycled (ownership guard) → null', () => {
|
|
120
|
-
const env = mkEnv()
|
|
121
|
-
mkdirSync(env.IAPEER_LAUNCHAGENTS_DIR!, { recursive: true })
|
|
122
|
-
writeFileSync(shadowPlistPath(env), '<?xml version="1.0"?>\n<plist><dict/></plist>\n')
|
|
123
|
-
expect(cycleShadowJob(env)).toBeNull()
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
describe('uninstallShadowJob', () => {
|
|
128
|
-
test('absent plist → absent (no-op)', () => {
|
|
129
|
-
expect(uninstallShadowJob(mkEnv()).action).toBe('absent')
|
|
130
|
-
})
|
|
131
|
-
test('foundation plist under sandbox → skipped-sandbox, plist left in place (no real bootout)', () => {
|
|
132
|
-
const env = mkEnv()
|
|
133
|
-
installShadowPlist(env)
|
|
134
|
-
const r = uninstallShadowJob(env)
|
|
135
|
-
expect(r.action).toBe('skipped-sandbox')
|
|
136
|
-
expect(existsSync(shadowPlistPath(env))).toBe(true)
|
|
137
|
-
})
|
|
138
|
-
test('foreign plist → refused-foreign, untouched', () => {
|
|
139
|
-
const env = mkEnv()
|
|
140
|
-
mkdirSync(env.IAPEER_LAUNCHAGENTS_DIR!, { recursive: true })
|
|
141
|
-
const path = shadowPlistPath(env)
|
|
142
|
-
writeFileSync(path, '<?xml version="1.0"?>\n<plist><dict/></plist>\n')
|
|
143
|
-
expect(uninstallShadowJob(env).action).toBe('refused-foreign')
|
|
144
|
-
expect(existsSync(path)).toBe(true)
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
describe('exports wire to the running job', () => {
|
|
149
|
-
test('SHADOW_PERSONALITY/LABEL match the live burn-in job naming', () => {
|
|
150
|
-
expect(SHADOW_PERSONALITY).toBe('shadow-fidelity')
|
|
151
|
-
expect(SHADOW_PLIST_LABEL).toBe('com.iapeer.shadow-fidelity')
|
|
152
|
-
})
|
|
153
|
-
})
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { Terminal } from '@xterm/headless'
|
|
3
|
-
import { composerOccupancyFromModel, modelToPlainText } from './index.ts'
|
|
4
|
-
|
|
5
|
-
// The observer's pty-side verdicts are pure functions of the @xterm model — hermetic, no tmux.
|
|
6
|
-
// These mirror the verdicts validated 0-divergence vs the prod tmux verdicts in the migration
|
|
7
|
-
// fidelity shadows (occupancy: getCell dim/ghost; ready-gate: isInputReady on plain text).
|
|
8
|
-
const build = (frame: string, cols = 80, rows = 24): Promise<Terminal> =>
|
|
9
|
-
new Promise(res => { const t = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 200 }); t.write(frame, () => res(t)) })
|
|
10
|
-
|
|
11
|
-
describe('composerOccupancyFromModel (pty occupancy verdict)', () => {
|
|
12
|
-
test('human non-dim text after the prompt glyph → TRUE', async () => {
|
|
13
|
-
const t = await build(`\x1b[24;1H❯ the quick brown fox`)
|
|
14
|
-
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(true)
|
|
15
|
-
})
|
|
16
|
-
test('dim/ghost placeholder after the glyph → FALSE (the critical discriminator)', async () => {
|
|
17
|
-
const t = await build(`\x1b[24;1H❯ \x1b[2m\x1b[38;5;246mTry "fix typecheck errors"\x1b[0m`)
|
|
18
|
-
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(false)
|
|
19
|
-
})
|
|
20
|
-
test('empty composer (glyph alone) → FALSE', async () => {
|
|
21
|
-
const t = await build(`\x1b[24;1H❯ `)
|
|
22
|
-
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(false)
|
|
23
|
-
})
|
|
24
|
-
test('codex glyph › with human text → TRUE', async () => {
|
|
25
|
-
const t = await build(`\x1b[24;1H› reviewer note`)
|
|
26
|
-
expect(composerOccupancyFromModel(t, 80, 24, 'codex')).toBe(true)
|
|
27
|
-
})
|
|
28
|
-
test('no prompt glyph at all → FALSE', async () => {
|
|
29
|
-
const t = await build(`\x1b[24;1Hjust some output line`)
|
|
30
|
-
expect(composerOccupancyFromModel(t, 80, 24, 'claude')).toBe(false)
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe('modelToPlainText (feeds isInputReady)', () => {
|
|
35
|
-
test('renders the viewport as plain text (no SGR), trailing-trimmed', async () => {
|
|
36
|
-
const t = await build(`\x1b[1;1Hhello\x1b[2;1H\x1b[1mbold\x1b[0m world`)
|
|
37
|
-
const plain = modelToPlainText(t, 80, 24)
|
|
38
|
-
expect(plain.split('\n')[0]).toBe('hello')
|
|
39
|
-
expect(plain).toContain('bold world')
|
|
40
|
-
expect(plain).not.toContain('\x1b')
|
|
41
|
-
})
|
|
42
|
-
})
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
// Memory slot — the DECLARATIVE provider slot (docs/Слот памяти — контракт
|
|
2
|
-
// memory provider.md): the core only READS the root declaration; absent /
|
|
3
|
-
// unreadable / invalid → EMPTY slot (bare core, valid state, never an error).
|
|
4
|
-
// Plus the host-status assembly + rendering (memory: <provider> | none).
|
|
5
|
-
|
|
6
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
-
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
|
|
8
|
-
import { tmpdir } from 'os'
|
|
9
|
-
import { join } from 'path'
|
|
10
|
-
import {
|
|
11
|
-
formatHostStatus,
|
|
12
|
-
heartbeatAgeSecs,
|
|
13
|
-
hostStatus,
|
|
14
|
-
memoryProviderPath,
|
|
15
|
-
readMemoryProvider,
|
|
16
|
-
type HostStatus,
|
|
17
|
-
type MemoryProvider,
|
|
18
|
-
} from './index.ts'
|
|
19
|
-
|
|
20
|
-
const dirs: string[] = []
|
|
21
|
-
function mkTmp(): string {
|
|
22
|
-
const d = mkdtempSync(join(tmpdir(), 'iapeer-slot-'))
|
|
23
|
-
dirs.push(d)
|
|
24
|
-
return d
|
|
25
|
-
}
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
function envFor(root: string): NodeJS.ProcessEnv {
|
|
31
|
-
return { IAPEER_ROOT: root } as NodeJS.ProcessEnv
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const VALID = {
|
|
35
|
-
provider: 'iapeer-memory',
|
|
36
|
-
package: '@agfpd/iapeer-memory',
|
|
37
|
-
version: '0.1.0',
|
|
38
|
-
registeredAt: '2026-06-10T00:00:00Z',
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
42
|
-
test('absent file → null (EMPTY slot — valid bare state)', () => {
|
|
43
|
-
expect(readMemoryProvider(envFor(mkTmp()))).toBeNull()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('valid declaration → parsed provider; heartbeat optional', () => {
|
|
47
|
-
const root = mkTmp()
|
|
48
|
-
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify({ ...VALID, heartbeat: '/tmp/hb' }))
|
|
49
|
-
const p = readMemoryProvider(envFor(root))
|
|
50
|
-
expect(p).toMatchObject({ ...VALID, heartbeat: '/tmp/hb' })
|
|
51
|
-
// without heartbeat — field absent, not empty-string
|
|
52
|
-
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify(VALID))
|
|
53
|
-
expect(readMemoryProvider(envFor(root))?.heartbeat).toBeUndefined()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('garbage / wrong shape / missing required fields → null, NEVER throws', () => {
|
|
57
|
-
const root = mkTmp()
|
|
58
|
-
const env = envFor(root)
|
|
59
|
-
for (const bad of ['NOT JSON {{{', '[]', '{}', JSON.stringify({ provider: 'x' }), JSON.stringify({ ...VALID, version: 42 })]) {
|
|
60
|
-
writeFileSync(memoryProviderPath(env), bad)
|
|
61
|
-
expect(readMemoryProvider(env)).toBeNull()
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('LEGACY v1.1 plugin block (any shape) → ignored as unknown noise; the declaration itself stays valid (form removed 11.06, ADR-017)', () => {
|
|
66
|
-
const root = mkTmp()
|
|
67
|
-
const env = envFor(root)
|
|
68
|
-
for (const legacy of [
|
|
69
|
-
{ name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' }, // a complete v1.1 block
|
|
70
|
-
{ name: 'x', marketplace: 'agfpd' },
|
|
71
|
-
'iapeer-memory',
|
|
72
|
-
['x'],
|
|
73
|
-
]) {
|
|
74
|
-
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin: legacy }))
|
|
75
|
-
const p = readMemoryProvider(env)
|
|
76
|
-
expect(p).not.toBeNull()
|
|
77
|
-
expect((p as Record<string, unknown> | null)?.plugin).toBeUndefined() // not parsed at all
|
|
78
|
-
}
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('v1.2 provision/unprovision blocks: parsed when valid; relative command or bad args = absent (fail-open)', () => {
|
|
82
|
-
const root = mkTmp()
|
|
83
|
-
const env = envFor(root)
|
|
84
|
-
const provision = { command: '/usr/local/bin/iapeer-memory', args: ['provision-peer', '--cwd', '{cwd}'] }
|
|
85
|
-
const unprovision = { command: '/usr/local/bin/iapeer-memory', args: ['unprovision-peer'] }
|
|
86
|
-
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision, unprovision }))
|
|
87
|
-
const p = readMemoryProvider(env)
|
|
88
|
-
expect(p?.provision).toEqual(provision)
|
|
89
|
-
expect(p?.unprovision).toEqual(unprovision)
|
|
90
|
-
// empty args array is VALID (command alone)
|
|
91
|
-
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: { command: '/x/y', args: [] } }))
|
|
92
|
-
expect(readMemoryProvider(env)?.provision).toEqual({ command: '/x/y', args: [] })
|
|
93
|
-
// invalid shapes → block absent, declaration itself stays valid
|
|
94
|
-
for (const bad of [
|
|
95
|
-
{ command: 'iapeer-memory', args: [] }, // RELATIVE command (launchd minimal PATH) — contract-invalid
|
|
96
|
-
{ command: '/x/y' }, // args missing
|
|
97
|
-
{ command: '/x/y', args: ['ok', 42] }, // non-string arg
|
|
98
|
-
{ args: ['provision-peer'] }, // command missing
|
|
99
|
-
'provision-me', // not an object
|
|
100
|
-
]) {
|
|
101
|
-
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: bad }))
|
|
102
|
-
const r = readMemoryProvider(env)
|
|
103
|
-
expect(r).not.toBeNull()
|
|
104
|
-
expect(r?.provision).toBeUndefined()
|
|
105
|
-
}
|
|
106
|
-
})
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
describe('heartbeatAgeSecs', () => {
|
|
110
|
-
test('no heartbeat declared → null; declared but absent file → null; present → age', () => {
|
|
111
|
-
const root = mkTmp()
|
|
112
|
-
expect(heartbeatAgeSecs({ ...VALID } as MemoryProvider)).toBeNull()
|
|
113
|
-
const hb = join(root, 'memoryd.heartbeat')
|
|
114
|
-
expect(heartbeatAgeSecs({ ...VALID, heartbeat: hb } as MemoryProvider)).toBeNull() // not running
|
|
115
|
-
writeFileSync(hb, '')
|
|
116
|
-
const old = (Date.now() - 45_000) / 1000
|
|
117
|
-
utimesSync(hb, old, old)
|
|
118
|
-
const age = heartbeatAgeSecs({ ...VALID, heartbeat: hb } as MemoryProvider)
|
|
119
|
-
expect(age).toBeGreaterThanOrEqual(44)
|
|
120
|
-
expect(age).toBeLessThanOrEqual(60)
|
|
121
|
-
})
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
describe('hostStatus + formatHostStatus', () => {
|
|
125
|
-
test('empty slot → memory: none; daemon health from the injected probe', async () => {
|
|
126
|
-
const root = mkTmp()
|
|
127
|
-
const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => null })
|
|
128
|
-
expect(s.daemon.healthy).toBe(true)
|
|
129
|
-
expect(s.memory.provider).toBeNull()
|
|
130
|
-
expect(formatHostStatus(s)).toContain('memory: none')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
test('occupied slot + fresh heartbeat → provider line with age', async () => {
|
|
134
|
-
const root = mkTmp()
|
|
135
|
-
const env = envFor(root)
|
|
136
|
-
const hb = join(root, 'memoryd.heartbeat')
|
|
137
|
-
writeFileSync(hb, '')
|
|
138
|
-
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, heartbeat: hb }))
|
|
139
|
-
const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
|
|
140
|
-
expect(formatHostStatus(s)).toMatch(/memory: iapeer-memory 0\.1\.0 \(heartbeat \d+s ago\)/)
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
test('occupied slot, heartbeat declared but file ABSENT → "daemon not running" (graceful-shutdown semantics)', () => {
|
|
144
|
-
const s: HostStatus = {
|
|
145
|
-
version: '0.0.0',
|
|
146
|
-
daemon: { healthy: false, url: null, sock: null },
|
|
147
|
-
memory: {
|
|
148
|
-
provider: { ...VALID, heartbeat: '/nope/hb' } as MemoryProvider,
|
|
149
|
-
heartbeatAgeSecs: null,
|
|
150
|
-
},
|
|
151
|
-
fda: null,
|
|
152
|
-
}
|
|
153
|
-
const text = formatHostStatus(s)
|
|
154
|
-
expect(text).toContain('memory: iapeer-memory 0.1.0 (daemon not running — no heartbeat file)')
|
|
155
|
-
expect(text).toContain('daemon: NOT healthy')
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
test('discovery file addresses surface in the daemon line', async () => {
|
|
159
|
-
const root = mkTmp()
|
|
160
|
-
const env = envFor(root)
|
|
161
|
-
const stateDir = join(root, 'state', 'iapeer')
|
|
162
|
-
mkdirSync(stateDir, { recursive: true })
|
|
163
|
-
writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x/router.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
|
|
164
|
-
const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
|
|
165
|
-
const text = formatHostStatus(s)
|
|
166
|
-
expect(text).toContain('@ http://127.0.0.1:8765/mcp')
|
|
167
|
-
expect(text).toContain('+ /x/router.sock')
|
|
168
|
-
})
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
describe('FDA detection (iapeer status — fresh-host observability)', () => {
|
|
172
|
-
function base(fda: boolean | null): HostStatus {
|
|
173
|
-
return {
|
|
174
|
-
version: '0.0.0',
|
|
175
|
-
daemon: { healthy: true, url: null, sock: null },
|
|
176
|
-
memory: { provider: null, heartbeatAgeSecs: null },
|
|
177
|
-
fda,
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
test('granted → terse OK line, no instruction', () => {
|
|
182
|
-
const text = formatHostStatus(base(true))
|
|
183
|
-
expect(text).toContain('fda: granted')
|
|
184
|
-
expect(text).not.toContain('NOT granted')
|
|
185
|
-
expect(text).not.toContain('Full Disk Access')
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
test('NOT granted → actionable hint with the binary path', () => {
|
|
189
|
-
const text = formatHostStatus(base(false))
|
|
190
|
-
expect(text).toContain('fda: NOT granted')
|
|
191
|
-
expect(text).toContain('Full Disk Access')
|
|
192
|
-
expect(text).toMatch(/iapeer$|iapeer\n/m) // the grant path ends at the binary
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
test('undeterminable → explicit unknown, never a false claim', () => {
|
|
196
|
-
const text = formatHostStatus(base(null))
|
|
197
|
-
expect(text).toContain('fda: unknown')
|
|
198
|
-
expect(text).not.toContain('NOT granted')
|
|
199
|
-
expect(text).not.toContain('granted\n') // not the bare "granted" either
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
test('hostStatus threads the injected fdaProbe verbatim', async () => {
|
|
203
|
-
const root = mkTmp()
|
|
204
|
-
const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => false })
|
|
205
|
-
expect(s.fda).toBe(false)
|
|
206
|
-
expect(formatHostStatus(s)).toContain('fda: NOT granted')
|
|
207
|
-
})
|
|
208
|
-
})
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// peersHomeDir / defaultPeerCwd + ensureGlobalIapScaffold creating peers/ — the
|
|
2
|
-
// foundation-owned default home for `iapeer create`.
|
|
3
|
-
|
|
4
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
5
|
-
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
|
6
|
-
import { tmpdir } from 'os'
|
|
7
|
-
import { join } from 'path'
|
|
8
|
-
import { defaultPeerCwd, ensureGlobalIapScaffold, peersHomeDir } from './index.ts'
|
|
9
|
-
|
|
10
|
-
const dirs: string[] = []
|
|
11
|
-
function mkTmp(): string {
|
|
12
|
-
const d = mkdtempSync(join(tmpdir(), 'iapeer-ph-'))
|
|
13
|
-
dirs.push(d)
|
|
14
|
-
return d
|
|
15
|
-
}
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
describe('peers home', () => {
|
|
21
|
-
test('peersHomeDir / defaultPeerCwd resolve under IAPEER_ROOT', () => {
|
|
22
|
-
const root = mkTmp()
|
|
23
|
-
const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
|
|
24
|
-
expect(peersHomeDir({ env })).toBe(join(root, 'iapeer', 'peers'))
|
|
25
|
-
expect(defaultPeerCwd('worker', { env })).toBe(join(root, 'iapeer', 'peers', 'worker'))
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
test('ensureGlobalIapScaffold creates ~/.iapeer/peers/', () => {
|
|
29
|
-
const root = mkTmp()
|
|
30
|
-
const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
|
|
31
|
-
ensureGlobalIapScaffold({ env })
|
|
32
|
-
expect(existsSync(peersHomeDir({ env }))).toBe(true)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// rotatelog — the generic rotated-logfmt-append primitive (promoted from
|
|
2
|
-
// lifecycle/eventlog.ts when the daemon's delivery.log became the second
|
|
3
|
-
// producer). The logfmt formatting (fmtValue/formatEventLine) is pinned in
|
|
4
|
-
// lifecycle/eventlog.test.ts (its historical home, re-exported); these tests
|
|
5
|
-
// cover what is NEW at this layer: the path-parameterized append + rotation.
|
|
6
|
-
|
|
7
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
|
9
|
-
import { tmpdir } from 'os'
|
|
10
|
-
import { join } from 'path'
|
|
11
|
-
import { appendRotatedEvent } from './rotatelog.ts'
|
|
12
|
-
|
|
13
|
-
const dirs: string[] = []
|
|
14
|
-
function mkTmp(): string {
|
|
15
|
-
const d = mkdtempSync(join(tmpdir(), 'iapeer-rotatelog-'))
|
|
16
|
-
dirs.push(d)
|
|
17
|
-
return d
|
|
18
|
-
}
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
describe('appendRotatedEvent', () => {
|
|
24
|
-
test('creates the parent dir and appends one ts-stamped logfmt line', () => {
|
|
25
|
-
const path = join(mkTmp(), 'deep', 'nested', 'some.log')
|
|
26
|
-
appendRotatedEvent(path, { ev: 'delivery', ok: 'true', note: 'two words' }, { nowMs: 1750000000000 })
|
|
27
|
-
const text = readFileSync(path, 'utf8')
|
|
28
|
-
expect(text).toBe('ts=2025-06-15T15:06:40.000Z ev=delivery ok=true note="two words"\n')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test('rotates base → .1 at maxBytes and drops beyond keep', () => {
|
|
32
|
-
const path = join(mkTmp(), 'r.log')
|
|
33
|
-
const line = { ev: 'x', pad: 'a'.repeat(50) } // ~65 bytes/line
|
|
34
|
-
// maxBytes 100 → every second append rotates; keep 1 → no .2 ever exists.
|
|
35
|
-
for (let i = 0; i < 6; i++) appendRotatedEvent(path, line, { maxBytes: 100, keep: 1 })
|
|
36
|
-
expect(existsSync(path)).toBe(true)
|
|
37
|
-
expect(existsSync(`${path}.1`)).toBe(true)
|
|
38
|
-
expect(existsSync(`${path}.2`)).toBe(false)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('never throws on an unwritable path (best-effort observability)', () => {
|
|
42
|
-
expect(() => appendRotatedEvent('/dev/null/impossible/x.log', { ev: 'x' })).not.toThrow()
|
|
43
|
-
})
|
|
44
|
-
})
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// readLaunchEnv — the per-peer per-runtime launch.env parser (Ф-A #5, zone
|
|
2
|
-
// Хранение / Рантайм-адаптеры). Pure FS parser, no tmux: PEER_START_ARGS
|
|
3
|
-
// word-splits (faithful to the legacy unquoted ${PEER_START_ARGS} expansion),
|
|
4
|
-
// other KEY=VALUE lines become child env, quotes are stripped, comments/blanks
|
|
5
|
-
// are ignored, and a missing file → empty.
|
|
6
|
-
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
8
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
-
import { tmpdir } from 'os'
|
|
10
|
-
import { join } from 'path'
|
|
11
|
-
import { peerLaunchEnvPath, readLaunchEnv } from './index.ts'
|
|
12
|
-
|
|
13
|
-
let cwd: string
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
cwd = mkdtempSync(join(tmpdir(), 'iapeer-launchenv-'))
|
|
16
|
-
})
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
rmSync(cwd, { recursive: true, force: true })
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
function writeLaunchEnv(runtime: string, body: string): void {
|
|
22
|
-
const p = peerLaunchEnvPath(cwd, runtime)
|
|
23
|
-
mkdirSync(join(p, '..'), { recursive: true })
|
|
24
|
-
writeFileSync(p, body)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('readLaunchEnv', () => {
|
|
28
|
-
test('missing file → empty (no flags, no env)', () => {
|
|
29
|
-
expect(readLaunchEnv(cwd, 'claude')).toEqual({ startArgs: [], env: {} })
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('PEER_START_ARGS word-splits on whitespace; surrounding quotes stripped', () => {
|
|
33
|
-
writeLaunchEnv('claude', 'PEER_START_ARGS="--dangerously-load-development-channels --foo bar"\n')
|
|
34
|
-
expect(readLaunchEnv(cwd, 'claude').startArgs).toEqual([
|
|
35
|
-
'--dangerously-load-development-channels',
|
|
36
|
-
'--foo',
|
|
37
|
-
'bar',
|
|
38
|
-
])
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('single arg, no quotes', () => {
|
|
42
|
-
writeLaunchEnv('codex', 'PEER_START_ARGS=--no-alt-screen\n')
|
|
43
|
-
expect(readLaunchEnv(cwd, 'codex').startArgs).toEqual(['--no-alt-screen'])
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('other KEY=VALUE lines become child env; PEER_START_ARGS is consumed (not in env)', () => {
|
|
47
|
-
writeLaunchEnv('claude', '# a comment\nexport FOO=bar\nBAZ="q u x"\nPEER_START_ARGS="--x"\n\n')
|
|
48
|
-
const { startArgs, env } = readLaunchEnv(cwd, 'claude')
|
|
49
|
-
expect(startArgs).toEqual(['--x'])
|
|
50
|
-
expect(env).toEqual({ FOO: 'bar', BAZ: 'q u x' })
|
|
51
|
-
expect(env.PEER_START_ARGS).toBeUndefined()
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('blank lines, comments, and non-assignment lines are ignored', () => {
|
|
55
|
-
writeLaunchEnv('claude', '\n # comment\nnot an assignment line\nKEY=value\n')
|
|
56
|
-
expect(readLaunchEnv(cwd, 'claude').env).toEqual({ KEY: 'value' })
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('empty PEER_START_ARGS → no args', () => {
|
|
60
|
-
writeLaunchEnv('claude', 'PEER_START_ARGS=""\nFOO=bar\n')
|
|
61
|
-
const { startArgs, env } = readLaunchEnv(cwd, 'claude')
|
|
62
|
-
expect(startArgs).toEqual([])
|
|
63
|
-
expect(env).toEqual({ FOO: 'bar' })
|
|
64
|
-
})
|
|
65
|
-
})
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// Spawn-flip cutover Block 2, Ф0b-3 slice 3c-1 — the supervisor `.attached` marker (the gate signal
|
|
2
|
-
// `hasAttachedSupervisorClient` reads to hold hosted delivery ONLY for a real attached human).
|
|
3
|
-
// boris's three faces: (1) present ⟺ clients.size>0; (2) ALL detach paths incl. ABNORMAL (abrupt
|
|
4
|
-
// terminate → error/close), not just a clean Ctrl-]; (3) CRASH-ROBUSTNESS — a crashed predecessor
|
|
5
|
-
// skips shutdown-cleanup, so a stale marker must be cleared on daemon START. Real detached daemon over
|
|
6
|
-
// a real unix socket (tick runtime — no codex needed); the marker is read CROSS-PROCESS, as in prod.
|
|
7
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import { tmpdir } from 'node:os'
|
|
10
|
-
import { join } from 'node:path'
|
|
11
|
-
import { frame, FRAME_RESIZE, sizePayload } from './protocol.ts'
|
|
12
|
-
import { killSession, sessionAlive, sockPath, startSupervisorDaemon } from './index.ts'
|
|
13
|
-
import { attachedPath } from './paths.ts'
|
|
14
|
-
|
|
15
|
-
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
16
|
-
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
17
|
-
const d = bunPty ? describe : describe.skip
|
|
18
|
-
|
|
19
|
-
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
20
|
-
async function waitFor(pred: () => boolean, timeoutMs = 5000, step = 40): Promise<boolean> {
|
|
21
|
-
const end = Date.now() + timeoutMs
|
|
22
|
-
while (Date.now() < end) { if (pred()) return true; await sleep(step) }
|
|
23
|
-
return false
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface Client { end(): void; terminate(): void }
|
|
27
|
-
async function attach(runDir: string, session: string): Promise<Client> {
|
|
28
|
-
const sock = await Bun.connect({
|
|
29
|
-
unix: sockPath(runDir, session),
|
|
30
|
-
socket: { open(s) { s.write(frame(FRAME_RESIZE, sizePayload(80, 24))) }, data() { /* */ } },
|
|
31
|
-
})
|
|
32
|
-
return {
|
|
33
|
-
end: () => { try { sock.end() } catch { /* */ } },
|
|
34
|
-
terminate: () => { try { sock.terminate() } catch { /* */ } },
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const dirs: string[] = []
|
|
39
|
-
const mkRunDir = (): string => { const d2 = mkdtempSync(join(tmpdir(), 'iapeer-attmark-')); dirs.push(d2); return d2 }
|
|
40
|
-
afterEach(() => { for (const d2 of dirs.splice(0)) rmSync(d2, { recursive: true, force: true }) })
|
|
41
|
-
|
|
42
|
-
d('supervisor .attached marker (the hosted occupancy gate signal)', () => {
|
|
43
|
-
test('present ⟺ clients.size>0 across multiple clients (no stuck "attached" on partial detach)', async () => {
|
|
44
|
-
const runDir = mkRunDir(), session = 'mk'
|
|
45
|
-
startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
46
|
-
expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
|
|
47
|
-
const marker = attachedPath(runDir, session)
|
|
48
|
-
expect(existsSync(marker)).toBe(false) // no client yet → absent
|
|
49
|
-
const c1 = await attach(runDir, session)
|
|
50
|
-
expect(await waitFor(() => existsSync(marker))).toBe(true) // ≥1 attached → present
|
|
51
|
-
const c2 = await attach(runDir, session)
|
|
52
|
-
await sleep(150)
|
|
53
|
-
expect(existsSync(marker)).toBe(true) // 2 attached → still present
|
|
54
|
-
c1.end()
|
|
55
|
-
await sleep(250)
|
|
56
|
-
expect(existsSync(marker)).toBe(true) // 1 still attached → NOT stuck-absent, NOT removed early
|
|
57
|
-
c2.end()
|
|
58
|
-
expect(await waitFor(() => !existsSync(marker))).toBe(true) // 0 attached → absent
|
|
59
|
-
killSession(runDir, session)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
test('ABNORMAL detach (abrupt terminate, not a clean Ctrl-]) → marker still removed', async () => {
|
|
63
|
-
const runDir = mkRunDir(), session = 'ab'
|
|
64
|
-
startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
65
|
-
expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
|
|
66
|
-
const marker = attachedPath(runDir, session)
|
|
67
|
-
const c = await attach(runDir, session)
|
|
68
|
-
expect(await waitFor(() => existsSync(marker))).toBe(true)
|
|
69
|
-
c.terminate() // abrupt RST — daemon sees error/close, NOT a graceful protocol detach
|
|
70
|
-
expect(await waitFor(() => !existsSync(marker))).toBe(true) // the error/close path ALSO syncs the marker
|
|
71
|
-
killSession(runDir, session)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('CRASH-ROBUSTNESS: a stale marker is cleared on daemon START (crash skips shutdown-cleanup)', async () => {
|
|
75
|
-
const runDir = mkRunDir(), session = 'cr'
|
|
76
|
-
const marker = attachedPath(runDir, session)
|
|
77
|
-
writeFileSync(marker, '') // a crashed predecessor left this present, with no live client
|
|
78
|
-
expect(existsSync(marker)).toBe(true)
|
|
79
|
-
startSupervisorDaemon({ session, runtime: 'tick', runDir }) // fresh daemon = 0 clients
|
|
80
|
-
expect(await waitFor(() => sessionAlive(runDir, session))).toBe(true)
|
|
81
|
-
expect(await waitFor(() => !existsSync(marker))).toBe(true) // start-cleanup removed the stale lie
|
|
82
|
-
killSession(runDir, session)
|
|
83
|
-
})
|
|
84
|
-
})
|