@agfpd/iapeer-memory 0.1.12 → 0.2.0
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 +2 -2
- package/src/cli.ts +12 -0
- package/src/commands/init.ts +129 -38
- package/src/commands/memoryd.ts +18 -1
- package/src/commands/provision-peer.ts +172 -0
- package/src/commands/render.ts +8 -1
- package/src/commands/uninstall.ts +60 -23
- package/src/commands/update.ts +138 -32
- package/src/commands/verify.ts +147 -4
- package/src/fleet.ts +150 -0
- package/src/paths.ts +10 -0
- package/src/slot.ts +67 -22
- package/src/surfaces/claude.ts +494 -0
- package/src/surfaces/codex.ts +155 -0
- package/src/surfaces/lock.ts +72 -0
- package/src/surfaces/sweep.ts +170 -0
- package/src/templates/guide-en.ts +1 -1
- package/src/templates/guide-ru.ts +1 -1
- package/src/templates/index.ts +11 -2
- package/src/templates/skills.ts +196 -0
package/src/commands/update.ts
CHANGED
|
@@ -12,29 +12,42 @@
|
|
|
12
12
|
* 3. doctrines — re-render every role from the roles manifest with the
|
|
13
13
|
* fresh version marker; roles pick it up on their next
|
|
14
14
|
* cold wake (ADR-007), no restarts;
|
|
15
|
-
* 4.
|
|
16
|
-
* 5.
|
|
17
|
-
*
|
|
15
|
+
* 4. fleet — re-write the fleet map from `iapeer list --json`;
|
|
16
|
+
* 5. surfaces — direct per-peer session surfaces sweep over the map
|
|
17
|
+
* (ADR-009 v1.2: the «всё на местах у подключённых пиров»
|
|
18
|
+
* duty — both runtimes, idempotent, repairs drift);
|
|
19
|
+
* 6. plugin-off — v1.1→v1.2 migration: when the on-disk slot still
|
|
20
|
+
* carries a plugin block, sweep the legacy plugin off the
|
|
21
|
+
* fleet (while the old declaration is STILL readable by
|
|
22
|
+
* the core verb) — only after surfaces landed cleanly;
|
|
23
|
+
* 7. slot — re-declare in the v1.2 form (provision command blocks,
|
|
24
|
+
* new version — contract obligation);
|
|
25
|
+
* 8. launcher + triggers + guide — regenerate;
|
|
26
|
+
* 9. memoryd — MANAGED restart: verified SIGTERM via the pid file →
|
|
18
27
|
* the notifier watcher relaunches through the launcher
|
|
19
|
-
* with the NEW binary
|
|
20
|
-
* notifier fact). Not running → nothing to do;
|
|
21
|
-
* 7. plugin — the harness's native plugin update flow (printed hint;
|
|
22
|
-
* the package never reaches into harness internals).
|
|
28
|
+
* with the NEW binary. Not running → nothing to do.
|
|
23
29
|
*
|
|
24
30
|
* Idempotent: same version re-run → identical/no-op on every surface.
|
|
25
31
|
*/
|
|
26
32
|
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
27
35
|
import {
|
|
28
36
|
configFromEnv,
|
|
29
37
|
isLocaleId,
|
|
30
38
|
renderDoctrine,
|
|
39
|
+
writeHostWideGuideFragment,
|
|
31
40
|
type LocaleId,
|
|
32
41
|
} from "@agfpd/iapeer-memory-core";
|
|
33
42
|
import { installBinary } from "../binary.js";
|
|
43
|
+
import { readFleetMap, writeFleetMap } from "../fleet.js";
|
|
34
44
|
import { memoryPaths } from "../paths.js";
|
|
35
45
|
import { readRolesManifest } from "../roles.js";
|
|
36
|
-
import { writeSlot } from "../slot.js";
|
|
37
|
-
import {
|
|
46
|
+
import { applyMemoryPlugin, readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
|
|
47
|
+
import { withProvisionLock } from "../surfaces/lock.js";
|
|
48
|
+
import { sweepProvision } from "../surfaces/sweep.js";
|
|
49
|
+
import { mcpPort } from "./provision-peer.js";
|
|
50
|
+
import { guideText, materialiseTemplates } from "../templates/index.js";
|
|
38
51
|
import { packageVersion } from "../version.js";
|
|
39
52
|
import {
|
|
40
53
|
dreamTimerMessage,
|
|
@@ -48,8 +61,11 @@ import { stopMemorydByPidFile } from "./uninstall.js";
|
|
|
48
61
|
|
|
49
62
|
export function cmdUpdate(argv: string[]): number {
|
|
50
63
|
let skipBinary = false;
|
|
51
|
-
|
|
64
|
+
let iapeerBin: string | undefined;
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i];
|
|
52
67
|
if (a === "--skip-binary") skipBinary = true;
|
|
68
|
+
else if (a === "--iapeer-bin") iapeerBin = argv[++i];
|
|
53
69
|
else {
|
|
54
70
|
console.error(`iapeer-memory update: unknown flag: ${a}`);
|
|
55
71
|
return 2;
|
|
@@ -119,24 +135,103 @@ export function cmdUpdate(argv: string[]): number {
|
|
|
119
135
|
);
|
|
120
136
|
}
|
|
121
137
|
|
|
122
|
-
// 4.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
// 4. fleet map — personality → cwd × runtimes (the joint of the surfaces
|
|
139
|
+
// sweep below AND memoryd's fragment renderer, docs/05). BEFORE surfaces
|
|
140
|
+
// and BEFORE the memoryd restart: both consume the fresh map.
|
|
141
|
+
{
|
|
142
|
+
const fleet = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin });
|
|
143
|
+
step(
|
|
144
|
+
"fleet",
|
|
145
|
+
fleet.action === "written"
|
|
146
|
+
? fleet.detail
|
|
147
|
+
: `fleet map not written (${fleet.detail}) — surfaces sweep runs on the LAST map; fragments stay stale until verify --repair`,
|
|
148
|
+
fleet.action === "written",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 5. direct session surfaces sweep (ADR-009 v1.2) — the update duty:
|
|
153
|
+
// «всё на местах у подключённых пиров, что codex, что claude».
|
|
154
|
+
const existingSlot = readSlot(paths.slotPath);
|
|
155
|
+
const slotForeign = existingSlot !== null && existingSlot.provider !== SLOT_PROVIDER;
|
|
156
|
+
let surfacesOk = false;
|
|
157
|
+
if (slotForeign) {
|
|
158
|
+
step("surfaces", "skipped (foreign slot — not our host)");
|
|
159
|
+
} else {
|
|
160
|
+
const fleet = readFleetMap(paths.fleetMapPath) ?? [];
|
|
161
|
+
const locked = withProvisionLock({
|
|
162
|
+
stateDir: paths.stateDir,
|
|
163
|
+
fn: () => sweepProvision({ fleet, hooksDir: paths.hooksDir, port: mcpPort() }),
|
|
164
|
+
});
|
|
165
|
+
if (!locked.acquired) {
|
|
166
|
+
step("surfaces", locked.detail, false);
|
|
167
|
+
} else {
|
|
168
|
+
const { results, skipped } = locked.result;
|
|
169
|
+
const failed = results.filter((r) => !r.ok);
|
|
170
|
+
surfacesOk = failed.length === 0;
|
|
171
|
+
step(
|
|
172
|
+
"surfaces",
|
|
173
|
+
`${results.length - failed.length}/${results.length} peer-runtime(s) in place` +
|
|
174
|
+
(skipped.length ? `, ${skipped.length} skipped` : "") +
|
|
175
|
+
" — live sessions pick changes up on next restart",
|
|
176
|
+
surfacesOk,
|
|
177
|
+
);
|
|
178
|
+
for (const f of failed) {
|
|
179
|
+
console.log(
|
|
180
|
+
` surfaces FAIL ${f.personality}:${f.runtime} — ${f.outcomes
|
|
181
|
+
.filter((o) => o.action === "failed")
|
|
182
|
+
.map((o) => `${o.surface}: ${o.detail ?? "failed"}`)
|
|
183
|
+
.join("; ")}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 6. v1.1 → v1.2 migration (one-shot per host): the on-disk slot still
|
|
190
|
+
// carries a plugin block — sweep the legacy plugin off the fleet WHILE the
|
|
191
|
+
// old declaration is still readable (the core verb derives the identity
|
|
192
|
+
// from it), and ONLY after the direct surfaces landed cleanly.
|
|
193
|
+
let migrationBlocked = false;
|
|
194
|
+
if (!slotForeign && existingSlot?.plugin) {
|
|
195
|
+
if (!surfacesOk) {
|
|
196
|
+
migrationBlocked = true;
|
|
197
|
+
step(
|
|
198
|
+
"plugin-off",
|
|
199
|
+
"POSTPONED: direct surfaces did not land cleanly — legacy plugin and v1.1 slot kept (fix and re-run update)",
|
|
200
|
+
false,
|
|
201
|
+
);
|
|
202
|
+
} else {
|
|
203
|
+
const off = applyMemoryPlugin({ mode: "off" });
|
|
204
|
+
step(
|
|
205
|
+
"plugin-off",
|
|
206
|
+
off.suppressed
|
|
207
|
+
? "skipped (test sandbox — core calls suppressed)"
|
|
208
|
+
: off.ok
|
|
209
|
+
? "legacy v1.1 session plugin swept off the fleet (memory-plugin off --all)"
|
|
210
|
+
: `legacy plugin off failed (${off.detail.slice(0, 120)}) — manual: iapeer memory-plugin off --all`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 7. slot version + v1.2 form (contract obligation). Kept v1.1 while the
|
|
216
|
+
// migration is blocked — the legacy channel stays derivable.
|
|
217
|
+
if (slotForeign) {
|
|
218
|
+
step("slot", `slot held by foreign provider "${existingSlot?.provider}" — not ours to update`, false);
|
|
219
|
+
} else if (migrationBlocked) {
|
|
220
|
+
step("slot", "kept v1.1 declaration (migration postponed — see plugin-off)", false);
|
|
221
|
+
} else {
|
|
222
|
+
const slot = writeSlot({
|
|
223
|
+
slotPath: paths.slotPath,
|
|
224
|
+
version,
|
|
225
|
+
binaryPath: paths.binaryPath,
|
|
226
|
+
heartbeat: paths.heartbeatPath,
|
|
227
|
+
});
|
|
228
|
+
step("slot", `${slot.action} (v${version}, provision-command declared)`, slot.action !== "refused-foreign");
|
|
229
|
+
}
|
|
135
230
|
|
|
136
|
-
//
|
|
231
|
+
// 8. launcher
|
|
137
232
|
step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
|
|
138
233
|
|
|
139
|
-
//
|
|
234
|
+
// 8b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
|
|
140
235
|
// the idempotent re-target path (old hosts with target=index migrate to
|
|
141
236
|
// target=scriber by this very step).
|
|
142
237
|
{
|
|
@@ -169,14 +264,25 @@ export function cmdUpdate(argv: string[]): number {
|
|
|
169
264
|
);
|
|
170
265
|
}
|
|
171
266
|
|
|
172
|
-
//
|
|
173
|
-
|
|
267
|
+
// 8c. host-wide guide — update an ALREADY-ROLLED-OUT guide only
|
|
268
|
+
// (presence = the rollout sanction; init --skip-guide hosts stay
|
|
269
|
+
// untouched). Vault substituted into {{VAULT_PATH}} (дыра 10.06: the
|
|
270
|
+
// literal placeholder left peers without the write path).
|
|
271
|
+
{
|
|
272
|
+
const iapeerDir = path.dirname(paths.slotPath);
|
|
273
|
+
const guidePath = path.join(iapeerDir, "fragments", "iapeer-memory.md");
|
|
274
|
+
if (!fs.existsSync(guidePath)) {
|
|
275
|
+
step("guide", "not rolled out on this host — left untouched (roll out via init)");
|
|
276
|
+
} else if (!vaultPathForDoctrines) {
|
|
277
|
+
step("guide", "unprovisioned env — vault unknown, guide left as is", false);
|
|
278
|
+
} else {
|
|
279
|
+
writeHostWideGuideFragment(iapeerDir, guideText(locale, vaultPathForDoctrines));
|
|
280
|
+
step("guide", `${guidePath} re-written (v${version}, vault path substituted)`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
174
283
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
" plugin update via the harness's native plugin flow " +
|
|
178
|
-
"(claude/codex plugin manager); the package never reaches into harness internals",
|
|
179
|
-
);
|
|
284
|
+
// 9. memoryd managed restart (the watcher relaunches with the new binary)
|
|
285
|
+
step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
|
|
180
286
|
|
|
181
287
|
console.log(
|
|
182
288
|
failures
|
package/src/commands/verify.ts
CHANGED
|
@@ -25,9 +25,13 @@ import {
|
|
|
25
25
|
renderDoctrine,
|
|
26
26
|
renderedVersion,
|
|
27
27
|
} from "@agfpd/iapeer-memory-core";
|
|
28
|
+
import { readFleetMap, writeFleetMap } from "../fleet.js";
|
|
28
29
|
import { memoryPaths, type MemoryPaths } from "../paths.js";
|
|
29
30
|
import { readRolesManifest } from "../roles.js";
|
|
30
|
-
import { readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
|
|
31
|
+
import { readSlot, slotProvisionBlocks, writeSlot, SLOT_PROVIDER } from "../slot.js";
|
|
32
|
+
import { withProvisionLock } from "../surfaces/lock.js";
|
|
33
|
+
import { checkFleetSurfaces, sweepProvision } from "../surfaces/sweep.js";
|
|
34
|
+
import { mcpPort } from "./provision-peer.js";
|
|
31
35
|
import { packageVersion } from "../version.js";
|
|
32
36
|
import {
|
|
33
37
|
dreamTimerMessage,
|
|
@@ -94,6 +98,11 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
|
|
|
94
98
|
|
|
95
99
|
// 1b. memory-provider slot (iapeer memory-slot contract): a provisioned
|
|
96
100
|
// host must declare the slot; a FOREIGN slot is never repaired over.
|
|
101
|
+
// A v1.1 slot (plugin block) is NEVER migrated here — the migration is
|
|
102
|
+
// update's job (plugin off --all must run while the old declaration is
|
|
103
|
+
// readable; a SessionStart-kicked repair racing ahead of update would
|
|
104
|
+
// strand the legacy plugin on the whole fleet).
|
|
105
|
+
let slotIsLegacyV11 = false;
|
|
97
106
|
if (!configOk) {
|
|
98
107
|
results.push({
|
|
99
108
|
name: "memory-slot",
|
|
@@ -102,17 +111,37 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
|
|
|
102
111
|
});
|
|
103
112
|
} else {
|
|
104
113
|
const slot = readSlot(paths.slotPath);
|
|
114
|
+
const expectedBlocks = slotProvisionBlocks(paths.binaryPath);
|
|
115
|
+
const formOk =
|
|
116
|
+
slot !== null &&
|
|
117
|
+
slot.plugin === undefined &&
|
|
118
|
+
JSON.stringify(slot.provision) === JSON.stringify(expectedBlocks.provision) &&
|
|
119
|
+
JSON.stringify(slot.unprovision) === JSON.stringify(expectedBlocks.unprovision);
|
|
105
120
|
if (slot && slot.provider !== SLOT_PROVIDER) {
|
|
106
121
|
results.push({
|
|
107
122
|
name: "memory-slot",
|
|
108
123
|
status: "fail",
|
|
109
124
|
detail: `slot held by foreign provider "${slot.provider}" — refusing to touch (uninstall it first)`,
|
|
110
125
|
});
|
|
111
|
-
} else if (slot
|
|
112
|
-
|
|
126
|
+
} else if (slot?.plugin) {
|
|
127
|
+
slotIsLegacyV11 = true;
|
|
128
|
+
results.push({
|
|
129
|
+
name: "memory-slot",
|
|
130
|
+
status: "fail",
|
|
131
|
+
detail:
|
|
132
|
+
"slot is the legacy v1.1 form (plugin block) — migrate via `iapeer-memory update` (verify never migrates: the plugin-off order is update's duty)",
|
|
133
|
+
});
|
|
134
|
+
} else if (slot && slot.version === version && formOk) {
|
|
135
|
+
results.push({
|
|
136
|
+
name: "memory-slot",
|
|
137
|
+
status: "ok",
|
|
138
|
+
detail: `declared v${slot.version}, provision-command in place`,
|
|
139
|
+
});
|
|
113
140
|
} else {
|
|
114
141
|
const problem = slot
|
|
115
|
-
?
|
|
142
|
+
? slot.version !== version
|
|
143
|
+
? `slot declares v${slot.version}, package is v${version}`
|
|
144
|
+
: "provision-command block missing/drifted"
|
|
116
145
|
: `slot declaration missing at ${paths.slotPath}`;
|
|
117
146
|
if (!repair) {
|
|
118
147
|
results.push({ name: "memory-slot", status: "fail", detail: problem });
|
|
@@ -120,6 +149,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
|
|
|
120
149
|
const w = writeSlot({
|
|
121
150
|
slotPath: paths.slotPath,
|
|
122
151
|
version,
|
|
152
|
+
binaryPath: paths.binaryPath,
|
|
123
153
|
heartbeat: paths.heartbeatPath,
|
|
124
154
|
});
|
|
125
155
|
results.push(
|
|
@@ -131,6 +161,119 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
|
|
|
131
161
|
}
|
|
132
162
|
}
|
|
133
163
|
|
|
164
|
+
// 1c. fleet map — memoryd's fragment renderer reads it (docs/05; дыра
|
|
165
|
+
// 10.06: без карты пиры не получали paths-блок и индекс автора). Repair
|
|
166
|
+
// re-writes from `iapeer list --json` — the self-healing loop for new
|
|
167
|
+
// peers (SessionStart kick → repair → map fresh → memoryd renders the
|
|
168
|
+
// newcomer on the next heartbeat tick).
|
|
169
|
+
if (!configOk) {
|
|
170
|
+
results.push({ name: "fleet-map", status: "skip", detail: "not provisioned (config check failed)" });
|
|
171
|
+
} else {
|
|
172
|
+
let mapPeers = -1; // -1 = unreadable/missing
|
|
173
|
+
try {
|
|
174
|
+
const raw = JSON.parse(fs.readFileSync(paths.fleetMapPath, "utf-8")) as {
|
|
175
|
+
peers?: unknown[];
|
|
176
|
+
};
|
|
177
|
+
mapPeers = Array.isArray(raw?.peers) ? raw.peers.length : -1;
|
|
178
|
+
} catch {
|
|
179
|
+
mapPeers = -1;
|
|
180
|
+
}
|
|
181
|
+
if (mapPeers > 0) {
|
|
182
|
+
results.push({ name: "fleet-map", status: "ok", detail: `${mapPeers} peer(s) in ${paths.fleetMapPath}` });
|
|
183
|
+
} else {
|
|
184
|
+
const problem =
|
|
185
|
+
mapPeers === 0
|
|
186
|
+
? `fleet map is empty at ${paths.fleetMapPath}`
|
|
187
|
+
: `fleet map missing/unreadable at ${paths.fleetMapPath}`;
|
|
188
|
+
if (!repair) {
|
|
189
|
+
results.push({ name: "fleet-map", status: "fail", detail: problem });
|
|
190
|
+
} else {
|
|
191
|
+
const w = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin: opts.iapeerBin });
|
|
192
|
+
results.push(
|
|
193
|
+
w.action === "written"
|
|
194
|
+
? { name: "fleet-map", status: "repaired", detail: `${problem} — ${w.detail}` }
|
|
195
|
+
: { name: "fleet-map", status: "fail", detail: `${problem}; repair failed — ${w.detail}` },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 1d. direct per-peer session surfaces (ADR-009 v1.2) across the fleet
|
|
202
|
+
// map — the self-healing loop for newborns on hosts where the core's
|
|
203
|
+
// birth-hook lagged AND the drift-repair duty (требование №2). Skipped on
|
|
204
|
+
// a legacy v1.1 host: the plugin is still the live channel there, direct
|
|
205
|
+
// surfaces land via update's migration.
|
|
206
|
+
if (!configOk) {
|
|
207
|
+
results.push({ name: "peer-surfaces", status: "skip", detail: "not provisioned (config check failed)" });
|
|
208
|
+
} else if (slotIsLegacyV11) {
|
|
209
|
+
results.push({
|
|
210
|
+
name: "peer-surfaces",
|
|
211
|
+
status: "skip",
|
|
212
|
+
detail: "legacy v1.1 host (plugin channel) — direct surfaces land via `iapeer-memory update`",
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
const fleet = readFleetMap(paths.fleetMapPath);
|
|
216
|
+
if (!fleet) {
|
|
217
|
+
results.push({
|
|
218
|
+
name: "peer-surfaces",
|
|
219
|
+
status: "skip",
|
|
220
|
+
detail: "fleet map unreadable — see fleet-map check",
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
const { checks, skipped } = checkFleetSurfaces({
|
|
224
|
+
fleet,
|
|
225
|
+
hooksDir: paths.hooksDir,
|
|
226
|
+
port: mcpPort(),
|
|
227
|
+
});
|
|
228
|
+
const bad = checks.filter((c) => !c.ok);
|
|
229
|
+
if (bad.length === 0) {
|
|
230
|
+
results.push({
|
|
231
|
+
name: "peer-surfaces",
|
|
232
|
+
status: "ok",
|
|
233
|
+
detail:
|
|
234
|
+
`${checks.length} peer-runtime(s) in place` +
|
|
235
|
+
(skipped.length ? ` (${skipped.length} skipped: no session runtime / missing cwd)` : ""),
|
|
236
|
+
});
|
|
237
|
+
} else if (!repair) {
|
|
238
|
+
for (const b of bad) {
|
|
239
|
+
results.push({
|
|
240
|
+
name: `peer-surfaces[${b.personality}:${b.runtime}]`,
|
|
241
|
+
status: "fail",
|
|
242
|
+
detail: b.problems.join("; "),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
const badPeers = fleet.filter((p) => bad.some((b) => b.cwd === p.cwd));
|
|
247
|
+
const locked = withProvisionLock({
|
|
248
|
+
stateDir: paths.stateDir,
|
|
249
|
+
fn: () => sweepProvision({ fleet: badPeers, hooksDir: paths.hooksDir, port: mcpPort() }),
|
|
250
|
+
});
|
|
251
|
+
if (!locked.acquired) {
|
|
252
|
+
results.push({ name: "peer-surfaces", status: "fail", detail: locked.detail });
|
|
253
|
+
} else {
|
|
254
|
+
const stillBad = locked.result.results.filter((r) => !r.ok);
|
|
255
|
+
results.push(
|
|
256
|
+
stillBad.length === 0
|
|
257
|
+
? {
|
|
258
|
+
name: "peer-surfaces",
|
|
259
|
+
status: "repaired",
|
|
260
|
+
detail: `${bad.length} drifted peer-runtime(s) re-provisioned (${bad
|
|
261
|
+
.map((b) => `${b.personality}:${b.runtime}`)
|
|
262
|
+
.join(", ")})`,
|
|
263
|
+
}
|
|
264
|
+
: {
|
|
265
|
+
name: "peer-surfaces",
|
|
266
|
+
status: "fail",
|
|
267
|
+
detail: `repair failed for ${stillBad
|
|
268
|
+
.map((r) => `${r.personality}:${r.runtime}`)
|
|
269
|
+
.join(", ")}`,
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
134
277
|
// 2. memoryd heartbeat
|
|
135
278
|
try {
|
|
136
279
|
const stat = fs.statSync(paths.heartbeatPath);
|
package/src/fleet.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet map — the personality → cwd joint between the package (ecosystem
|
|
3
|
+
* knowledge, ADR-009) and core memoryd's per-peer fragment renderer
|
|
4
|
+
* (docs/05). Written from `iapeer list --json` (the registry cwd is the
|
|
5
|
+
* FACT — iapeer 0.2.14); memoryd reads it fail-open and re-checks the
|
|
6
|
+
* mtime on every heartbeat tick, so the self-healing loop is:
|
|
7
|
+
* new peer wakes → SessionStart kick → `verify --repair` re-writes the
|
|
8
|
+
* map → memoryd renders the newcomer's fragment within a tick.
|
|
9
|
+
*
|
|
10
|
+
* TEST FUSE (incident 11.06, the FOURTH of its class — first FILE-path one):
|
|
11
|
+
* `iapeer list` is read-only, but its RESULT is the target list of the
|
|
12
|
+
* surfaces sweep — a sandboxed `verify --repair` with no fleet map repaired
|
|
13
|
+
* the map from the LIVE registry and then swept the LIVE peers' cwds with
|
|
14
|
+
* direct surfaces (the send-fuse never saw it: no IAP send involved).
|
|
15
|
+
* Querying the live registry from a test IS the leak — so the default
|
|
16
|
+
* binary is refused under the sandbox fuse; tests that need a fleet pass a
|
|
17
|
+
* fake `iapeerBin` (as before) or write the map file directly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
export type FleetMapResult = {
|
|
24
|
+
action: "written" | "failed";
|
|
25
|
+
count: number;
|
|
26
|
+
detail: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ListedPeer = {
|
|
30
|
+
personality?: unknown;
|
|
31
|
+
cwd?: unknown;
|
|
32
|
+
/** iapeer registry: `[{runtime: "claude"|"codex"|…, status}]`. */
|
|
33
|
+
runtimes?: Array<{ runtime?: unknown }>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Fleet-map entry. `runtimes` (ADR-009 v1.2) names the peer's session
|
|
37
|
+
* runtimes from the registry — the surfaces sweep keys its per-runtime
|
|
38
|
+
* forms on it (claude: hooks+mcp+skills; codex: project-local MCP).
|
|
39
|
+
* Core's memoryd reader takes personality/cwd only — additive, fail-open. */
|
|
40
|
+
export type FleetPeer = { personality: string; cwd: string; runtimes: string[] };
|
|
41
|
+
|
|
42
|
+
/** Fail-open fleet-map reader (the package side: the surfaces sweep and
|
|
43
|
+
* verify's per-peer checks). Missing/unreadable map → null — callers report
|
|
44
|
+
* honestly instead of guessing the fleet. Entries without a runtimes array
|
|
45
|
+
* (pre-v1.2 maps) read as `runtimes: []` — the sweep skips them until the
|
|
46
|
+
* next map re-write (init/update/verify --repair). */
|
|
47
|
+
export function readFleetMap(fleetMapPath: string): FleetPeer[] | null {
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(fs.readFileSync(fleetMapPath, "utf-8")) as {
|
|
50
|
+
peers?: Array<{ personality?: unknown; cwd?: unknown; runtimes?: unknown }>;
|
|
51
|
+
};
|
|
52
|
+
if (!Array.isArray(raw?.peers)) return null;
|
|
53
|
+
return raw.peers
|
|
54
|
+
.filter(
|
|
55
|
+
(p): p is { personality: string; cwd: string; runtimes?: unknown } =>
|
|
56
|
+
typeof p?.personality === "string" && typeof p?.cwd === "string",
|
|
57
|
+
)
|
|
58
|
+
.map((p) => ({
|
|
59
|
+
personality: p.personality,
|
|
60
|
+
cwd: p.cwd,
|
|
61
|
+
runtimes: Array.isArray(p.runtimes)
|
|
62
|
+
? p.runtimes.filter((r): r is string => typeof r === "string")
|
|
63
|
+
: [],
|
|
64
|
+
}));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeFleetMap(opts: {
|
|
71
|
+
fleetMapPath: string;
|
|
72
|
+
iapeerBin?: string;
|
|
73
|
+
/** Injectable for tests. */
|
|
74
|
+
nowIso?: string;
|
|
75
|
+
}): FleetMapResult {
|
|
76
|
+
if (
|
|
77
|
+
opts.iapeerBin === undefined &&
|
|
78
|
+
(process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
|
|
79
|
+
process.env.IAPEER_TEST_SANDBOX === "1")
|
|
80
|
+
) {
|
|
81
|
+
return {
|
|
82
|
+
action: "failed",
|
|
83
|
+
count: 0,
|
|
84
|
+
detail: "live-registry query suppressed (test sandbox) — pass a fake iapeerBin",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const bin = opts.iapeerBin ?? "iapeer";
|
|
88
|
+
let stdout: string;
|
|
89
|
+
try {
|
|
90
|
+
const proc = Bun.spawnSync([bin, "list", "--json"], {
|
|
91
|
+
stdout: "pipe",
|
|
92
|
+
stderr: "pipe",
|
|
93
|
+
});
|
|
94
|
+
if (proc.exitCode !== 0) {
|
|
95
|
+
return {
|
|
96
|
+
action: "failed",
|
|
97
|
+
count: 0,
|
|
98
|
+
detail:
|
|
99
|
+
(proc.stderr.toString().trim() || `iapeer list exited ${proc.exitCode}`).slice(0, 160),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
stdout = proc.stdout.toString();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { action: "failed", count: 0, detail: `${bin} unavailable: ${String(err)}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let listed: ListedPeer[];
|
|
108
|
+
try {
|
|
109
|
+
const raw = JSON.parse(stdout) as unknown;
|
|
110
|
+
listed = Array.isArray(raw) ? (raw as ListedPeer[]) : [];
|
|
111
|
+
} catch {
|
|
112
|
+
return { action: "failed", count: 0, detail: "iapeer list --json: unparsable output" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const peers: FleetPeer[] = listed
|
|
116
|
+
.filter(
|
|
117
|
+
(p): p is ListedPeer & { personality: string; cwd: string } =>
|
|
118
|
+
typeof p.personality === "string" &&
|
|
119
|
+
p.personality.trim() !== "" &&
|
|
120
|
+
typeof p.cwd === "string" &&
|
|
121
|
+
p.cwd.trim() !== "",
|
|
122
|
+
)
|
|
123
|
+
.map((p) => ({
|
|
124
|
+
personality: p.personality.trim(),
|
|
125
|
+
cwd: p.cwd.trim(),
|
|
126
|
+
runtimes: [
|
|
127
|
+
...new Set(
|
|
128
|
+
(Array.isArray(p.runtimes) ? p.runtimes : [])
|
|
129
|
+
.map((r) => (typeof r?.runtime === "string" ? r.runtime.trim() : ""))
|
|
130
|
+
.filter(Boolean),
|
|
131
|
+
),
|
|
132
|
+
],
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const body =
|
|
136
|
+
JSON.stringify(
|
|
137
|
+
{ updatedAt: opts.nowIso ?? new Date().toISOString(), peers },
|
|
138
|
+
null,
|
|
139
|
+
2,
|
|
140
|
+
) + "\n";
|
|
141
|
+
fs.mkdirSync(path.dirname(opts.fleetMapPath), { recursive: true });
|
|
142
|
+
const tmp = `${opts.fleetMapPath}.tmp`;
|
|
143
|
+
fs.writeFileSync(tmp, body, "utf-8");
|
|
144
|
+
fs.renameSync(tmp, opts.fleetMapPath); // atomic — memoryd may race a read
|
|
145
|
+
return {
|
|
146
|
+
action: "written",
|
|
147
|
+
count: peers.length,
|
|
148
|
+
detail: `${peers.length} peer(s) → ${opts.fleetMapPath}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -48,10 +48,18 @@ export type MemoryPaths = {
|
|
|
48
48
|
binaryPath: string;
|
|
49
49
|
/** Materialised package-owned templates (roles, guide) — see templates/index.ts. */
|
|
50
50
|
templatesDir: string;
|
|
51
|
+
/** Materialised hook shims (fail-open bash, 3 lines) — the ABSOLUTE command
|
|
52
|
+
* paths merged into peers' `.claude/settings.json` (ownership lives IN THE
|
|
53
|
+
* DATA: the command path is the identity of our entries — ADR-009 v1.2). */
|
|
54
|
+
hooksDir: string;
|
|
51
55
|
/** memoryd launcher — the notifier watcher's script (wraps the stable binary). */
|
|
52
56
|
launcherPath: string;
|
|
53
57
|
/** Sweep check-script — gates the fail-open inbox sweep (ADR-015). */
|
|
54
58
|
checkScriptPath: string;
|
|
59
|
+
/** Fleet map (personality → cwd) — written by init/update/verify --repair
|
|
60
|
+
* from `iapeer list --json`, consumed by memoryd's fragment renderer
|
|
61
|
+
* (docs/05; дыра 10.06: без карты пиры не получали paths-блок и индекс). */
|
|
62
|
+
fleetMapPath: string;
|
|
55
63
|
};
|
|
56
64
|
|
|
57
65
|
export function memoryPaths(
|
|
@@ -88,8 +96,10 @@ export function memoryPaths(
|
|
|
88
96
|
binaryPath:
|
|
89
97
|
env.IAPEER_MEMORY_BINARY_PATH || path.join(home, ".local", "bin", "iapeer-memory"),
|
|
90
98
|
templatesDir: path.join(path.dirname(configFile), "templates"),
|
|
99
|
+
hooksDir: path.join(path.dirname(configFile), "hooks"),
|
|
91
100
|
launcherPath: path.join(path.dirname(configFile), "memoryd-launcher.sh"),
|
|
92
101
|
checkScriptPath: path.join(path.dirname(configFile), "inbox-stale-check.sh"),
|
|
102
|
+
fleetMapPath: path.join(stateDir, "fleet.json"),
|
|
93
103
|
};
|
|
94
104
|
}
|
|
95
105
|
|