@murmurv2/cli 2.11.0 → 2.12.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/README.md +37 -12
- package/package.json +2 -1
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +97 -61
- package/runtime/package.json +1 -1
- package/runtime/packages/setup/bin/murmur.mjs +5 -3
- package/runtime/packages/setup/dist/src/cli.d.ts +1 -1
- package/runtime/packages/setup/dist/src/cli.js +49 -9
- package/runtime/packages/setup/dist/src/clients.js +8 -2
- package/runtime/packages/setup/dist/src/commands.d.ts +3 -1
- package/runtime/packages/setup/dist/src/commands.js +17 -6
- package/runtime/packages/setup/dist/src/config.d.ts +2 -0
- package/runtime/packages/setup/dist/src/config.js +61 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +12 -0
- package/runtime/packages/setup/dist/src/doctor.js +19 -2
- package/runtime/packages/setup/dist/src/invitation-server.d.ts +2 -0
- package/runtime/packages/setup/dist/src/invitation-server.js +45 -0
- package/runtime/packages/setup/dist/src/message-status.d.ts +4 -0
- package/runtime/packages/setup/dist/src/message-status.js +10 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +19 -6
- package/runtime/packages/setup/dist/src/onboarding.js +46 -22
- package/runtime/packages/setup/dist/src/outbox-attention.js +3 -21
- package/runtime/packages/setup/dist/src/platform/darwin.js +7 -1
- package/runtime/packages/setup/dist/src/platform/linux.js +21 -16
- package/runtime/packages/setup/dist/src/platform/windows.js +67 -3
- package/runtime/packages/setup/dist/src/status.d.ts +17 -2
- package/runtime/packages/setup/dist/src/status.js +78 -18
- package/runtime/packages/setup/dist/src/types.d.ts +11 -1
- package/runtime/packages/setup/dist/src/verdict.js +4 -2
- package/runtime/packages/setup/dist/src/wake-attention.d.ts +11 -0
- package/runtime/packages/setup/dist/src/wake-attention.js +44 -0
- package/runtime/packages/setup/package.json +1 -1
- package/runtime/scripts/codex-app-server-wake.mjs +169 -32
- package/runtime/scripts/daemon-contacts.mjs +56 -0
- package/runtime/scripts/daemon-flush-tick.mjs +25 -0
- package/runtime/scripts/daemon-link-watch.mjs +60 -0
- package/runtime/scripts/daemon-observation.mjs +74 -18
- package/runtime/scripts/murmur-daemon.mjs +45 -31
- package/runtime/scripts/wake-drain-claude.mjs +69 -32
- package/runtime/scripts/wake-monitor.mjs +46 -18
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { access, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
const paths = path.win32;
|
|
@@ -102,7 +103,10 @@ export function createWindowsAdapter(options = {}) {
|
|
|
102
103
|
|| !(v.lastExitCode === null || Number.isSafeInteger(v.lastExitCode))
|
|
103
104
|
|| !['since', 'lastFailureAt', 'observedStorePath', 'observedStoreUnknownReason', 'restartsUnknownReason'].every(k => textOrNull(v[k]))
|
|
104
105
|
|| !['since', 'lastFailureAt'].every(k => v[k] === null || Number.isFinite(Date.parse(v[k])))
|
|
105
|
-
|| v.restartsPerHourLimit !== 5
|
|
106
|
+
|| v.restartsPerHourLimit !== 5
|
|
107
|
+
// Older helpers omit foreignKind; it describes only a Service that is not this installation's.
|
|
108
|
+
|| !(v.foreignKind === undefined || v.foreignKind === null
|
|
109
|
+
|| v.manager === 'foreign' && ['previous-installation', 'not-murmur'].includes(v.foreignKind)))
|
|
106
110
|
fail('native-response-invalid');
|
|
107
111
|
if (!(v.restartCount === null && v.restartWindowMs === null || natural(v.restartCount) && natural(v.restartWindowMs) && v.restartWindowMs <= 3600000)
|
|
108
112
|
|| (v.restartWindowMs === 3600000 ? v.restartsLastHour !== v.restartCount : v.restartsLastHour !== null))
|
|
@@ -112,7 +116,12 @@ export function createWindowsAdapter(options = {}) {
|
|
|
112
116
|
fail('native-response-invalid');
|
|
113
117
|
return { snapshot: { ...empty('service.not-installed'), state: 'stopped' }, installed: false };
|
|
114
118
|
}
|
|
115
|
-
|
|
119
|
+
// A Service left by another Murmur installation (a pilot or an earlier version) can be replaced
|
|
120
|
+
// on request; a name taken by another program never is. Anything else stays unverified.
|
|
121
|
+
if (v.manager === 'foreign')
|
|
122
|
+
fail(v.foreignKind === 'previous-installation' ? 'previous-installation'
|
|
123
|
+
: v.foreignKind === 'not-murmur' ? 'foreign-image' : 'profile-unverified');
|
|
124
|
+
if (!object(v.profile))
|
|
116
125
|
fail('profile-unverified');
|
|
117
126
|
const expected = { dataDir: c.dataDir, node: c.nodePath, workDir: c.repoRoot, entry: paths.join(c.repoRoot, 'scripts', 'murmur-daemon.mjs') };
|
|
118
127
|
for (const [key, value] of Object.entries(expected)) {
|
|
@@ -151,8 +160,63 @@ export function createWindowsAdapter(options = {}) {
|
|
|
151
160
|
|| (action === 'stop' ? after.snapshot.state !== 'stopped' : after.snapshot.state !== 'running' || after.snapshot.pid === null))
|
|
152
161
|
fail('action-unconfirmed');
|
|
153
162
|
}
|
|
163
|
+
/** Removes only a Service that another Murmur installation registered under this name. */
|
|
164
|
+
async function removePrevious(c) {
|
|
165
|
+
validate(c);
|
|
166
|
+
const found = await inspect(c).then(() => null, (error) => error);
|
|
167
|
+
if (found === null)
|
|
168
|
+
return; // Absent or already this installation's: nothing to replace.
|
|
169
|
+
if (!(found instanceof Error && found.message === 'service.previous-installation'))
|
|
170
|
+
throw found;
|
|
171
|
+
if (await elevated() === false)
|
|
172
|
+
fail('elevation-required');
|
|
173
|
+
await invoke(c, 'remove-previous');
|
|
174
|
+
const after = await inspect(c).catch(() => null);
|
|
175
|
+
if (!after || after.installed)
|
|
176
|
+
fail('action-unconfirmed');
|
|
177
|
+
}
|
|
154
178
|
return {
|
|
155
179
|
manager: 'windows-service',
|
|
180
|
+
removePrevious,
|
|
181
|
+
async logDirectory(c) {
|
|
182
|
+
validate(c);
|
|
183
|
+
if (!(await inspect(c)).installed)
|
|
184
|
+
throw new Error('logs.service-not-installed');
|
|
185
|
+
const programData = Object.entries(env).find(([key]) => key.toLowerCase() === 'programdata')?.[1];
|
|
186
|
+
if (!programData || !paths.isAbsolute(programData))
|
|
187
|
+
throw new Error('logs.windows-native-location-unavailable');
|
|
188
|
+
// This is the native helper's ProgramData/Murmur/logs/<service> layout.
|
|
189
|
+
// Verify ownership above and containment after resolving junctions below.
|
|
190
|
+
try {
|
|
191
|
+
const root = await canonicalize(paths.join(programData, 'Murmur', 'logs'));
|
|
192
|
+
const logDir = await canonicalize(paths.join(programData, 'Murmur', 'logs', c.serviceName));
|
|
193
|
+
if (paths.relative(root, logDir) !== c.serviceName)
|
|
194
|
+
throw new Error('logs.path-outside-service');
|
|
195
|
+
if (!(await stat(logDir)).isDirectory())
|
|
196
|
+
throw new Error('logs.not-directory');
|
|
197
|
+
await access(logDir, constants.R_OK);
|
|
198
|
+
return logDir;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (error instanceof Error && error.message.startsWith('logs.'))
|
|
202
|
+
throw error;
|
|
203
|
+
const code = error.code;
|
|
204
|
+
throw new Error(code === 'ENOENT' ? 'logs.directory-missing' : code === 'EACCES' || code === 'EPERM' ? 'logs.directory-unreadable' : 'logs.path-unavailable');
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
async observeStore(c, pid) {
|
|
208
|
+
validate(c);
|
|
209
|
+
if (!natural(pid) || pid === 0)
|
|
210
|
+
return null;
|
|
211
|
+
const result = await run(await helper(c), ['observe-store', String(pid)], { env: selectedEnvironment(c), timeout: 8000 });
|
|
212
|
+
if (result.code !== 0)
|
|
213
|
+
return null; // Older helpers cannot provide this proof.
|
|
214
|
+
const v = JSON.parse(result.stdout);
|
|
215
|
+
if (!object(v) || v.schema !== 'murmur.store-proof/1' || v.pid !== pid || typeof v.observedStorePath !== 'string')
|
|
216
|
+
return null;
|
|
217
|
+
absolute(v.observedStorePath);
|
|
218
|
+
return await same(v.observedStorePath, c.storePath) ? canonicalize(v.observedStorePath) : null;
|
|
219
|
+
},
|
|
156
220
|
async status(c) {
|
|
157
221
|
try {
|
|
158
222
|
return (await inspect(c)).snapshot;
|
|
@@ -6,6 +6,14 @@ export type Measurement = {
|
|
|
6
6
|
};
|
|
7
7
|
export declare const pairFingerprint: (config: AgentConfig, peerId: string) => string;
|
|
8
8
|
export declare function validPairProof(proof: any, config: AgentConfig, peerId: string, now: number): boolean;
|
|
9
|
+
interface ContactReload {
|
|
10
|
+
state: "current" | "partial" | "retained";
|
|
11
|
+
count: number;
|
|
12
|
+
invalidCount: number;
|
|
13
|
+
lastSuccessAt: string | null;
|
|
14
|
+
lastError: string | null;
|
|
15
|
+
lastErrorAt: string | null;
|
|
16
|
+
}
|
|
9
17
|
export interface StatusOptions {
|
|
10
18
|
context: ServiceContext;
|
|
11
19
|
adapter: PlatformAdapter;
|
|
@@ -14,9 +22,10 @@ export interface StatusOptions {
|
|
|
14
22
|
export declare function readStatus({ context: c, adapter, now }: StatusOptions): Promise<{
|
|
15
23
|
schema: "murmur.status/1";
|
|
16
24
|
generatedAt: string;
|
|
17
|
-
agentId:
|
|
25
|
+
agentId: any;
|
|
18
26
|
serviceName: string;
|
|
19
27
|
service: {
|
|
28
|
+
managed: boolean | null;
|
|
20
29
|
lastFailureAt: null;
|
|
21
30
|
restartsLastHour: number | null;
|
|
22
31
|
restartFailureThreshold: number;
|
|
@@ -25,7 +34,7 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
|
|
|
25
34
|
manager: Measurement;
|
|
26
35
|
history: Measurement;
|
|
27
36
|
};
|
|
28
|
-
state: "running" | "stopped" | "failed" | "unknown";
|
|
37
|
+
state: "running" | "running-unmanaged" | "stopped" | "failed" | "unknown";
|
|
29
38
|
manager: import("./types.js").ServiceManager;
|
|
30
39
|
since: string | null;
|
|
31
40
|
pid: number | null;
|
|
@@ -41,6 +50,8 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
|
|
|
41
50
|
connectedAt: any;
|
|
42
51
|
lastError: any;
|
|
43
52
|
lastErrorAt: any;
|
|
53
|
+
disconnectedAt: any;
|
|
54
|
+
reconnectAttempts: any;
|
|
44
55
|
unknownReason: string | null;
|
|
45
56
|
measurements: {
|
|
46
57
|
runtime: Measurement;
|
|
@@ -52,7 +63,10 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
|
|
|
52
63
|
paired: boolean | null;
|
|
53
64
|
lastInboundAt: string | null;
|
|
54
65
|
lastOutboundAt: string | null;
|
|
66
|
+
lastExchangeAt: string | null;
|
|
67
|
+
connection: "connected" | "stale" | "unverified";
|
|
55
68
|
}> | null;
|
|
69
|
+
reload: ContactReload | null;
|
|
56
70
|
unknownReason: string | null;
|
|
57
71
|
measurements: Record<string, Measurement>;
|
|
58
72
|
};
|
|
@@ -135,3 +149,4 @@ export declare function readStatus({ context: c, adapter, now }: StatusOptions):
|
|
|
135
149
|
};
|
|
136
150
|
}>;
|
|
137
151
|
export declare function configuredWakeMode(config: AgentConfig): "hook" | "monitor" | "none";
|
|
152
|
+
export {};
|
|
@@ -2,8 +2,9 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { realpath } from "node:fs/promises";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { loadConfig, readJson, safeError } from "./config.js";
|
|
5
|
+
import { loadConfig, readJson, safeError, validAgentId } from "./config.js";
|
|
6
6
|
import { readOutboxAttention } from "./outbox-attention.js";
|
|
7
|
+
import { assistantRead, wakeColumns } from "./message-status.js";
|
|
7
8
|
const measured = (at) => ({ measuredAt: at, unknownReason: null });
|
|
8
9
|
const unknown = (reason) => ({ measuredAt: null, unknownReason: reason });
|
|
9
10
|
export const pairFingerprint = (config, peerId) => createHash("sha256").update(JSON.stringify([
|
|
@@ -11,10 +12,27 @@ export const pairFingerprint = (config, peerId) => createHash("sha256").update(J
|
|
|
11
12
|
config.peers[peerId]?.signing.publicKey, config.peers[peerId]?.encryption.publicKey,
|
|
12
13
|
])).digest("hex");
|
|
13
14
|
export function validPairProof(proof, config, peerId, now) {
|
|
14
|
-
const at =
|
|
15
|
+
const at = pairProofTime(proof, config, peerId, now);
|
|
16
|
+
return at !== null && now - Date.parse(at) <= 86400000;
|
|
17
|
+
}
|
|
18
|
+
function pairProofTime(proof, config, peerId, now) {
|
|
15
19
|
return proof?.peerId === peerId && proof?.localAgentId === config.agentId
|
|
16
20
|
&& proof?.keyFingerprint === pairFingerprint(config, peerId)
|
|
17
|
-
|
|
21
|
+
? exchangeTime(proof.verifiedAt, now) : null;
|
|
22
|
+
}
|
|
23
|
+
function exchangeTime(value, now) {
|
|
24
|
+
const timestamp = typeof value === "string" ? Date.parse(value) : NaN;
|
|
25
|
+
return Number.isFinite(timestamp) && timestamp <= now + 5000 ? new Date(timestamp).toISOString() : null;
|
|
26
|
+
}
|
|
27
|
+
function contactReload(raw, now) {
|
|
28
|
+
if (!raw || !["current", "partial", "retained"].includes(raw.state)
|
|
29
|
+
|| !Number.isSafeInteger(raw.count) || raw.count < 0
|
|
30
|
+
|| !Number.isSafeInteger(raw.invalidCount) || raw.invalidCount < 0
|
|
31
|
+
|| (raw.lastError !== null && (typeof raw.lastError !== "string" || !/^agent-config-[a-z-]+$/.test(raw.lastError))))
|
|
32
|
+
return null;
|
|
33
|
+
return { state: raw.state, count: raw.count, invalidCount: raw.invalidCount,
|
|
34
|
+
lastSuccessAt: exchangeTime(raw.lastSuccessAt, now), lastError: raw.lastError,
|
|
35
|
+
lastErrorAt: exchangeTime(raw.lastErrorAt, now) };
|
|
18
36
|
}
|
|
19
37
|
export async function readStatus({ context: c, adapter, now = Date.now }) {
|
|
20
38
|
const started = now();
|
|
@@ -46,13 +64,33 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
|
|
|
46
64
|
const raw = await readJson(path.join(c.dataDir, "daemon-observation.json"));
|
|
47
65
|
const timestamp = Date.parse(raw.measuredAt);
|
|
48
66
|
const storePath = await realpath(c.storePath);
|
|
49
|
-
if (raw.schema === "murmur.runtime/1" &&
|
|
50
|
-
|
|
67
|
+
if (raw.schema === "murmur.runtime/1" && Number.isSafeInteger(raw.pid) && raw.pid > 0
|
|
68
|
+
// A rejected on-disk config must not hide an already running process's
|
|
69
|
+
// reload diagnostic. Fresh PID/open-store evidence still gates acceptance.
|
|
70
|
+
&& validAgentId(raw.agentId) && (!config || raw.agentId === config.agentId) && raw.storePath === storePath
|
|
51
71
|
&& Number.isFinite(timestamp) && started - timestamp >= -5000 && started - timestamp <= 15000
|
|
52
|
-
&& ["connected", "disconnected", "unauthorized", "unknown"].includes(raw.broker?.state)
|
|
72
|
+
&& ["connected", "disconnected", "unauthorized", "closed", "unknown"].includes(raw.broker?.state)
|
|
53
73
|
&& typeof raw.wake?.enabled === "boolean" && ["hook", "monitor", "none"].includes(raw.wake?.mode)) {
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
const managed = service.state === "running" && raw.pid === service.pid && service.observedStorePath === storePath;
|
|
75
|
+
// A fresh file alone cannot prove liveness: the OS must observe this PID's
|
|
76
|
+
// open database. Never use process arguments or the configured service name.
|
|
77
|
+
const independent = !managed && service.state !== "failed"
|
|
78
|
+
? await adapter.observeStore?.(c, raw.pid).catch(() => null) : null;
|
|
79
|
+
if (managed || independent === storePath) {
|
|
80
|
+
// A Service of a previous Murmur installation keeps its own label: the live process is
|
|
81
|
+
// most likely its child, and "running outside this app" would hide the replacement.
|
|
82
|
+
const previous = service.detail === "service.previous-installation";
|
|
83
|
+
if (!managed && raw.pid !== service.pid && !previous)
|
|
84
|
+
service = { state: "running-unmanaged", manager: "none", pid: raw.pid,
|
|
85
|
+
observedStorePath: storePath, since: exchangeTime(raw.startedAt, started), lastExitCode: null,
|
|
86
|
+
restartCount: null, restartWindowMs: null, detail: "service.running-unmanaged" };
|
|
87
|
+
else if (independent === storePath)
|
|
88
|
+
service = { ...service, observedStorePath: storePath };
|
|
89
|
+
observation = raw;
|
|
90
|
+
runtimeMeasurement = measured(raw.measuredAt);
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
runtimeMeasurement = unknown("runtime.process-store-unverified");
|
|
56
94
|
}
|
|
57
95
|
else
|
|
58
96
|
runtimeMeasurement = unknown("runtime.observation-unverified-or-stale");
|
|
@@ -70,7 +108,9 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
|
|
|
70
108
|
lastFault: null, lastFaultAt: null, unknownReason: null,
|
|
71
109
|
measurements: { store: unknown("store.unavailable"), runtime: runtimeMeasurement } };
|
|
72
110
|
const peers = {
|
|
73
|
-
list: config ? Object.keys(config.peers).map((agentId) => ({ agentId, paired: null, lastInboundAt: null, lastOutboundAt: null
|
|
111
|
+
list: config ? Object.keys(config.peers).map((agentId) => ({ agentId, paired: null, lastInboundAt: null, lastOutboundAt: null,
|
|
112
|
+
lastExchangeAt: null, connection: "unverified" })) : null,
|
|
113
|
+
reload: contactReload(observation?.contacts, started),
|
|
74
114
|
unknownReason: configError, measurements: { config: config ? measured(at) : unknown(configError ?? "config.unavailable"), store: unknown("store.unavailable"), proof: unknown("peers.proof-unavailable") },
|
|
75
115
|
};
|
|
76
116
|
let deliveries = null;
|
|
@@ -116,7 +156,9 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
|
|
|
116
156
|
wake.storedOnly = rows["stored-only"] ?? 0;
|
|
117
157
|
wake.pendingUndelivered = (rows.pending ?? 0) + (rows.inflight ?? 0) + (rows.failed ?? 0);
|
|
118
158
|
wake.lastDeliveredAt = db.prepare("SELECT MAX(wake_updated_at) AS at FROM local_messages WHERE direction='inbound' AND wake_status='handled'").get().at;
|
|
119
|
-
const fault = db.prepare(
|
|
159
|
+
const fault = db.prepare(`SELECT wake_error,wake_updated_at FROM local_messages WHERE direction='inbound'
|
|
160
|
+
AND (wake_status IN ('failed','dlq') OR (wake_status='inflight' AND wake_error='accepted-turn-unobservable'))
|
|
161
|
+
AND wake_error IS NOT NULL ORDER BY wake_updated_at DESC LIMIT 1`).get();
|
|
120
162
|
wake.lastFault = fault ? safeError(fault.wake_error) : null;
|
|
121
163
|
wake.lastFaultAt = fault?.wake_updated_at ?? null;
|
|
122
164
|
wake.measurements.store = measured(at);
|
|
@@ -124,14 +166,20 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
|
|
|
124
166
|
catch {
|
|
125
167
|
wake.measurements.store = unknown("wake.store-schema-unavailable");
|
|
126
168
|
}
|
|
169
|
+
const exchangeCutoff = new Date(started + 5000).toISOString();
|
|
127
170
|
for (const peer of peers.list ?? []) {
|
|
128
|
-
peer.lastInboundAt = db.prepare("SELECT
|
|
129
|
-
peer.lastOutboundAt = db.prepare("SELECT
|
|
171
|
+
peer.lastInboundAt = db.prepare("SELECT created_at AS at FROM local_messages WHERE direction='inbound' AND sender=? AND julianday(created_at)<=julianday(?) ORDER BY julianday(created_at) DESC LIMIT 1").get(peer.agentId, exchangeCutoff)?.at ?? null;
|
|
172
|
+
peer.lastOutboundAt = db.prepare("SELECT created_at AS at FROM outbox WHERE json_extract(envelope_json,'$.recipients[0]')=? AND julianday(created_at)<=julianday(?) ORDER BY julianday(created_at) DESC LIMIT 1").get(peer.agentId, exchangeCutoff)?.at ?? null;
|
|
173
|
+
const ackedAt = db.prepare("SELECT updated_at AS at FROM outbox WHERE status='acked' AND json_extract(envelope_json,'$.recipients[0]')=? AND julianday(updated_at)<=julianday(?) ORDER BY julianday(updated_at) DESC LIMIT 1").get(peer.agentId, exchangeCutoff)?.at ?? null;
|
|
174
|
+
peer.lastExchangeAt = [peer.lastInboundAt, ackedAt].map(value => exchangeTime(value, started)).filter((value) => value !== null).sort().at(-1) ?? null;
|
|
130
175
|
}
|
|
131
176
|
peers.measurements.store = measured(at);
|
|
132
177
|
const outbound = db.prepare("SELECT msg_id AS msgId,json_extract(envelope_json,'$.recipients[0]') AS peer,'outbound' AS direction,CASE status WHEN 'acked' THEN 'delivered' WHEN 'sent' THEN 'inflight' ELSE status END AS state,updated_at AS at,attempts,last_error AS error FROM outbox ORDER BY updated_at DESC LIMIT 20").all();
|
|
133
|
-
const inbound = db.prepare(
|
|
134
|
-
deliveries = [...outbound, ...inbound].sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 20).map((r) => ({ ...r,
|
|
178
|
+
const inbound = db.prepare(`SELECT msg_id AS msgId,sender AS peer,'inbound' AS direction,'delivered' AS state,created_at AS at,0 AS attempts,NULL AS error,${wakeColumns(db)} FROM local_messages WHERE direction='inbound' ORDER BY created_at DESC LIMIT 20`).all();
|
|
179
|
+
deliveries = [...outbound, ...inbound].sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 20).map((r) => ({ ...r,
|
|
180
|
+
error: r.error ? safeError(r.error) : null, wake_status: r.wake_status ?? null,
|
|
181
|
+
wake_error: r.wake_error ? safeError(r.wake_error) : null,
|
|
182
|
+
wake_updated_at: r.wake_updated_at ?? null, assistantRead: assistantRead(r.wake_status) }));
|
|
135
183
|
db.exec("COMMIT");
|
|
136
184
|
}
|
|
137
185
|
catch {
|
|
@@ -143,25 +191,37 @@ export async function readStatus({ context: c, adapter, now = Date.now }) {
|
|
|
143
191
|
if (config) {
|
|
144
192
|
try {
|
|
145
193
|
const proofs = await readJson(path.join(c.dataDir, "pair-proofs.json"));
|
|
146
|
-
for (const peer of peers.list ?? [])
|
|
194
|
+
for (const peer of peers.list ?? []) {
|
|
147
195
|
peer.paired = validPairProof(proofs[peer.agentId], config, peer.agentId, started) ? true : null;
|
|
196
|
+
const proofAt = pairProofTime(proofs[peer.agentId], config, peer.agentId, started);
|
|
197
|
+
peer.lastExchangeAt = [peer.lastExchangeAt, proofAt].filter((value) => value !== null).sort().at(-1) ?? null;
|
|
198
|
+
}
|
|
148
199
|
peers.measurements.proof = measured(at);
|
|
149
200
|
}
|
|
150
201
|
catch { /* No roundtrip evidence: every pairing stays unknown. */ }
|
|
151
202
|
}
|
|
203
|
+
for (const peer of peers.list ?? [])
|
|
204
|
+
peer.connection = peer.lastExchangeAt === null ? "unverified"
|
|
205
|
+
: started - Date.parse(peer.lastExchangeAt) <= 86400000 ? "connected" : "stale";
|
|
152
206
|
const reason = (sources) => Object.entries(sources).filter(([, v]) => v.unknownReason).map(([name, v]) => `${name}:${v.unknownReason}`).join("; ") || null;
|
|
153
207
|
inbox.unknownReason = reason(inbox.measurements);
|
|
154
208
|
outbox.unknownReason = reason(outbox.measurements);
|
|
155
209
|
wake.unknownReason = reason(wake.measurements);
|
|
156
210
|
peers.unknownReason = reason(peers.measurements);
|
|
157
211
|
return {
|
|
158
|
-
schema: "murmur.status/1", generatedAt: at, agentId: config?.agentId ?? null, serviceName: c.serviceName,
|
|
159
|
-
service: { ...service,
|
|
212
|
+
schema: "murmur.status/1", generatedAt: at, agentId: config?.agentId ?? observation?.agentId ?? null, serviceName: c.serviceName,
|
|
213
|
+
service: { ...service, managed: service.state === "running" ? true : service.state === "running-unmanaged" ? false : null, lastFailureAt: null,
|
|
214
|
+
restartsLastHour: service.state === "running-unmanaged" ? null : restartsLastHour, restartFailureThreshold: 5,
|
|
160
215
|
unknownReason: service.state === "unknown" ? service.detail ?? "service.unavailable" : null,
|
|
161
216
|
measurements: { manager: service.state === "unknown" ? unknown(service.detail ?? "service.unavailable") : measured(at), history: unknown("service.history-unavailable") } },
|
|
162
217
|
broker: { url: config?.natsUrl ?? null, state: observation?.broker?.state ?? "unknown",
|
|
163
218
|
connectedAt: observation?.broker?.connectedAt ?? null, lastError: observation?.broker?.lastError ?? null,
|
|
164
|
-
lastErrorAt: observation?.broker?.lastErrorAt ?? null,
|
|
219
|
+
lastErrorAt: observation?.broker?.lastErrorAt ?? null,
|
|
220
|
+
// #276 — since when the link is lost and how many reconnect attempts failed meanwhile.
|
|
221
|
+
disconnectedAt: typeof observation?.broker?.disconnectedAt === "string" ? observation.broker.disconnectedAt : null,
|
|
222
|
+
reconnectAttempts: Number.isSafeInteger(observation?.broker?.reconnectAttempts) && observation.broker.reconnectAttempts >= 0
|
|
223
|
+
? observation.broker.reconnectAttempts : null,
|
|
224
|
+
unknownReason: runtimeMeasurement.unknownReason,
|
|
165
225
|
measurements: { runtime: runtimeMeasurement } },
|
|
166
226
|
peers, inbox, deliveries,
|
|
167
227
|
outbox: {
|
|
@@ -10,7 +10,7 @@ export interface ServiceContext {
|
|
|
10
10
|
}
|
|
11
11
|
export type ServiceManager = "windows-service" | "scheduled-task" | "launchd" | "systemd" | "none";
|
|
12
12
|
export interface ServiceSnapshot {
|
|
13
|
-
state: "running" | "stopped" | "failed" | "unknown";
|
|
13
|
+
state: "running" | "running-unmanaged" | "stopped" | "failed" | "unknown";
|
|
14
14
|
manager: ServiceManager;
|
|
15
15
|
since: string | null;
|
|
16
16
|
pid: number | null;
|
|
@@ -32,9 +32,19 @@ export interface ClientDetection {
|
|
|
32
32
|
export interface PlatformAdapter {
|
|
33
33
|
manager: ServiceManager;
|
|
34
34
|
status(context: ServiceContext): Promise<ServiceSnapshot>;
|
|
35
|
+
/** Read-only proof that this PID currently holds the selected store open. */
|
|
36
|
+
observeStore?(context: ServiceContext, pid: number): Promise<string | null>;
|
|
35
37
|
install(context: ServiceContext): Promise<void>;
|
|
36
38
|
start(context: ServiceContext): Promise<void>;
|
|
37
39
|
stop(context: ServiceContext): Promise<void>;
|
|
38
40
|
uninstall?(context: ServiceContext): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Removes a Service registered under this name by another installation of Murmur (a pilot or
|
|
43
|
+
* an earlier version), so that install can follow in the same elevated process. Resolves
|
|
44
|
+
* without change when there is none; refuses a Service that does not run Murmur.
|
|
45
|
+
*/
|
|
46
|
+
removePrevious?(context: ServiceContext): Promise<void>;
|
|
39
47
|
detectClients(context: ServiceContext): Promise<ClientDetection[]>;
|
|
48
|
+
/** Native log location, after verifying the selected service owns this profile. */
|
|
49
|
+
logDirectory?(context: ServiceContext): Promise<string>;
|
|
40
50
|
}
|
|
@@ -59,7 +59,7 @@ export function statusVerdict(input, now = Date.now()) {
|
|
|
59
59
|
return out("grey", invalid);
|
|
60
60
|
if (typeof s.schema !== "string" || !/^murmur\.status\/1(?:\.\d+)?$/.test(s.schema))
|
|
61
61
|
return out("grey", "schema.unknown");
|
|
62
|
-
unread = count(s.
|
|
62
|
+
unread = count(s.wake?.delivery?.pendingUndelivered) && s.wake.delivery.pendingUndelivered > 0;
|
|
63
63
|
const stamp = typeof s.generatedAt === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(s.generatedAt) ? Date.parse(s.generatedAt) : NaN;
|
|
64
64
|
if (!Number.isFinite(stamp) || !Number.isFinite(now))
|
|
65
65
|
return out("grey", "snapshot.unparsable");
|
|
@@ -69,7 +69,7 @@ export function statusVerdict(input, now = Date.now()) {
|
|
|
69
69
|
return out("grey", "snapshot.future");
|
|
70
70
|
if (s.service?.state === "stopped")
|
|
71
71
|
return out("grey", "service.stopped");
|
|
72
|
-
if (!["running", "failed"].includes(s.service?.state))
|
|
72
|
+
if (!["running", "running-unmanaged", "failed"].includes(s.service?.state))
|
|
73
73
|
return out("grey", "service.unknown");
|
|
74
74
|
if (s.service.state === "failed")
|
|
75
75
|
return out("red", "service.failed");
|
|
@@ -101,6 +101,8 @@ export function statusVerdict(input, now = Date.now()) {
|
|
|
101
101
|
note(name, "source-unreadable");
|
|
102
102
|
switch (s.broker?.state) {
|
|
103
103
|
case "unauthorized": return out("yellow", "broker.unauthorized");
|
|
104
|
+
// #276 — the client gave up for good; only a restart brings the link back.
|
|
105
|
+
case "closed": return out("red", "broker.closed");
|
|
104
106
|
case "connected": break;
|
|
105
107
|
case null:
|
|
106
108
|
case undefined:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServiceContext } from './types.js';
|
|
2
|
+
/** Acknowledge an unknown accepted outcome; never replay it or claim it was read. */
|
|
3
|
+
export declare function dismissWake(c: ServiceContext, msgId: string, expectedAgent: string): Promise<{
|
|
4
|
+
schema: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
msgIds: import("node:sqlite").SQLOutputValue[];
|
|
7
|
+
status: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
executed: boolean;
|
|
10
|
+
historyPreserved: boolean;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { lstat, mkdir, rmdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfig, validAgentId } from './config.js';
|
|
5
|
+
/** Acknowledge an unknown accepted outcome; never replay it or claim it was read. */
|
|
6
|
+
export async function dismissWake(c, msgId, expectedAgent) {
|
|
7
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,150}$/.test(msgId) || !validAgentId(expectedAgent))
|
|
8
|
+
throw new Error('wake.selection-invalid');
|
|
9
|
+
const lock = path.join(c.dataDir, '.setup-write.lock');
|
|
10
|
+
await mkdir(lock, { mode: 0o700 });
|
|
11
|
+
try {
|
|
12
|
+
const config = await loadConfig(c);
|
|
13
|
+
if (config.agentId !== expectedAgent)
|
|
14
|
+
throw new Error('wake.identity-changed');
|
|
15
|
+
const file = await lstat(c.storePath);
|
|
16
|
+
if (!file.isFile() || file.isSymbolicLink() || (typeof process.getuid === 'function' && file.uid !== process.getuid())) {
|
|
17
|
+
throw new Error('wake.store-invalid');
|
|
18
|
+
}
|
|
19
|
+
const db = new DatabaseSync(c.storePath);
|
|
20
|
+
try {
|
|
21
|
+
db.exec('PRAGMA busy_timeout=1000; BEGIN IMMEDIATE');
|
|
22
|
+
const row = db.prepare("SELECT wake_batch_id FROM local_messages WHERE direction='inbound' AND msg_id=?").get(msgId);
|
|
23
|
+
if (!row)
|
|
24
|
+
throw new Error('wake.dismiss-not-eligible');
|
|
25
|
+
const rows = db.prepare(`SELECT msg_id,wake_status,wake_error FROM local_messages WHERE direction='inbound'
|
|
26
|
+
AND (msg_id=? OR (wake_batch_id IS NOT NULL AND wake_batch_id=?))`).all(msgId, row.wake_batch_id);
|
|
27
|
+
if (rows.some(r => !['dlq', 'muted'].includes(String(r.wake_status)) || r.wake_error !== 'accepted-turn-unobservable')) {
|
|
28
|
+
throw new Error('wake.dismiss-not-eligible');
|
|
29
|
+
}
|
|
30
|
+
const update = db.prepare("UPDATE local_messages SET wake_status='muted',wake_next_at=NULL,wake_updated_at=? WHERE direction='inbound' AND msg_id=?");
|
|
31
|
+
for (const r of rows)
|
|
32
|
+
update.run(new Date().toISOString(), r.msg_id);
|
|
33
|
+
db.exec('COMMIT');
|
|
34
|
+
return { schema: 'murmur.wake-dismiss/1', agentId: config.agentId, msgIds: rows.map(r => r.msg_id),
|
|
35
|
+
status: 'muted', reason: 'accepted-turn-unobservable', executed: false, historyPreserved: true };
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
db.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await rmdir(lock);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"@murmurv2/cli","private":true,"version":"2.
|
|
1
|
+
{"name":"@murmurv2/cli","private":true,"version":"2.12.0","type":"module","main":"dist/src/index.js","types":"dist/src/index.d.ts"}
|