@indigoai-us/hq-cli 5.108.25 → 5.108.26
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/CHANGELOG.md +68 -0
- package/dist/commands/integrations-api.d.ts +15 -0
- package/dist/commands/integrations-connect.js +84 -3
- package/dist/commands/integrations-oauth.js +62 -3
- package/dist/commands/mcp-registration.d.ts +17 -7
- package/dist/commands/mcp-registration.js +16 -27
- package/dist/commands/mesh.js +174 -50
- package/dist/commands/pack-install.js +5 -5
- package/dist/commands/secrets.d.ts +7 -0
- package/dist/commands/secrets.js +26 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
- package/dist/lib/mesh/live/backfill-held.js +95 -13
- package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/doctor.js +41 -10
- package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/mode.js +88 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
- package/dist/lib/mesh/live/daemon/run.js +39 -28
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/emit-client.d.ts +99 -0
- package/dist/lib/mesh/live/emit-client.js +193 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
- package/dist/lib/mesh/live/emit-evidence.js +77 -0
- package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
- package/dist/lib/mesh/live/emit-replay.js +157 -0
- package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
- package/dist/lib/mesh/live/emit-retry.js +79 -0
- package/dist/lib/mesh/live/emit.d.ts +54 -0
- package/dist/lib/mesh/live/emit.js +153 -0
- package/dist/lib/narrow-hint-banner.d.ts +3 -7
- package/dist/lib/narrow-hint-banner.js +13 -34
- package/dist/lib/plan-limit-nag.d.ts +0 -3
- package/dist/lib/plan-limit-nag.js +10 -20
- package/package.json +1 -1
|
@@ -10,6 +10,8 @@ import { workContextRoot } from "../../../work-context/paths.js";
|
|
|
10
10
|
import { countJsonlLines, } from "../spool.js";
|
|
11
11
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
12
12
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
13
|
+
import { readEmitState } from "../emit.js";
|
|
14
|
+
import { emitRetryDepth } from "../emit-retry.js";
|
|
13
15
|
import { pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
14
16
|
import { readDaemonState } from "./state.js";
|
|
15
17
|
export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
|
|
@@ -79,10 +81,13 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
79
81
|
const outbox = outboxStats(ctxRoot);
|
|
80
82
|
const mqttState = state?.mqttState ?? (running ? "unknown" : "closed");
|
|
81
83
|
const companiesOnline = state?.companiesOnline ?? [];
|
|
84
|
+
const emitMode = state?.emitMode ?? "legacy";
|
|
85
|
+
const subscribed = mqttState === "connected";
|
|
82
86
|
const oldestSpoolAgeMs = oldestJsonlAgeMs(spoolPath, now());
|
|
83
87
|
const unhealthyReasons = [];
|
|
84
88
|
const online = mqttState === "connected" && companiesOnline.length > 0;
|
|
85
|
-
if (
|
|
89
|
+
if (emitMode === "legacy" &&
|
|
90
|
+
online &&
|
|
86
91
|
spoolDepth > 0 &&
|
|
87
92
|
oldestSpoolAgeMs !== undefined &&
|
|
88
93
|
oldestSpoolAgeMs > UNHEALTHY_SPOOL_AGE_MS) {
|
|
@@ -96,6 +101,11 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
96
101
|
unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
|
|
97
102
|
}
|
|
98
103
|
const auth = describeCognitoTokenSource({ home, env });
|
|
104
|
+
const emitState = readEmitState(meshRoot);
|
|
105
|
+
const emitRetry = emitRetryDepth(meshRoot);
|
|
106
|
+
if (emitRetry > 0) {
|
|
107
|
+
unhealthyReasons.push(`direct-emit retry depth=${emitRetry}`);
|
|
108
|
+
}
|
|
99
109
|
return {
|
|
100
110
|
running,
|
|
101
111
|
pid: running ? record?.pid : undefined,
|
|
@@ -113,23 +123,44 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
113
123
|
tokenSource: auth.tokenSource,
|
|
114
124
|
actorKind: auth.actorKind,
|
|
115
125
|
presenceRefusal,
|
|
126
|
+
emitMode,
|
|
127
|
+
subscribed,
|
|
128
|
+
emitRetryDepth: emitRetry,
|
|
129
|
+
lastPostAt: emitState?.lastPostAt,
|
|
130
|
+
lastEmit: emitState
|
|
131
|
+
? {
|
|
132
|
+
accepted: emitState.lastAccepted,
|
|
133
|
+
unassigned: emitState.lastUnassigned,
|
|
134
|
+
rejected: emitState.lastRejected,
|
|
135
|
+
error: emitState.lastError,
|
|
136
|
+
}
|
|
137
|
+
: undefined,
|
|
116
138
|
};
|
|
117
139
|
}
|
|
118
140
|
export function formatDaemonDoctor(report) {
|
|
119
141
|
const lines = [
|
|
120
142
|
`daemon: ${report.running ? `running pid=${report.pid}` : "not running"}`,
|
|
143
|
+
`mode: ${report.emitMode}`,
|
|
121
144
|
`mqtt: ${report.mqttState}`,
|
|
145
|
+
`subscription: ${report.subscribed ? "connected" : "disconnected"}`,
|
|
122
146
|
`companies online: ${report.companiesOnline.length ? report.companiesOnline.join(", ") : "(none)"}`,
|
|
123
|
-
`spool depth: ${report.spoolDepth}`,
|
|
124
|
-
`held: ${report.heldCount}`,
|
|
125
|
-
`dead-letter: ${report.deadLetterCount}`,
|
|
126
|
-
`outbox depth: ${report.outboxDepth}`,
|
|
127
|
-
`token source: ${report.tokenSource}`,
|
|
128
|
-
`actor kind: ${report.actorKind}`,
|
|
129
|
-
`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
|
|
130
|
-
? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
|
|
131
|
-
: ""}`,
|
|
132
147
|
];
|
|
148
|
+
if (report.emitMode === "legacy") {
|
|
149
|
+
// Legacy emit path only: the spool/held/outbox queues do not exist in
|
|
150
|
+
// receive-only (direct) mode.
|
|
151
|
+
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `outbox depth: ${report.outboxDepth}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push(`dead-letter: ${report.deadLetterCount}`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
|
|
154
|
+
if (report.emitMode === "legacy") {
|
|
155
|
+
lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
|
|
156
|
+
? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
|
|
157
|
+
: ""}`);
|
|
158
|
+
}
|
|
159
|
+
lines.push(`emit last post: ${report.lastPostAt ?? "(never)"}${report.lastEmit
|
|
160
|
+
? ` accepted=${report.lastEmit.accepted ?? 0}` +
|
|
161
|
+
` unassigned=${report.lastEmit.unassigned ?? 0}` +
|
|
162
|
+
` rejected=${report.lastEmit.rejected ?? 0}`
|
|
163
|
+
: ""}`, `emit retry depth: ${report.emitRetryDepth}`);
|
|
133
164
|
if (report.presenceRefusal) {
|
|
134
165
|
lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
|
|
135
166
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh daemon mode gate (owner decision 2026-09-08 "retire the client daemon";
|
|
3
|
+
* contract direct-emit-v1 §6.3 "deprecated, not deleted").
|
|
4
|
+
*
|
|
5
|
+
* Two modes coexist so this can merge before the server route ships and flip
|
|
6
|
+
* after:
|
|
7
|
+
* - "legacy" — the daemon runs the EMIT path (spool watch + flush + held +
|
|
8
|
+
* outbox replay + transcript-watch fallback) AND the receive
|
|
9
|
+
* subscriber. This is the default until the server route is live.
|
|
10
|
+
* - "direct" — sessions POST events directly (see `hq mesh session`/`emit`);
|
|
11
|
+
* the daemon runs RECEIVE-ONLY: the presence subscriber on
|
|
12
|
+
* hq/{personUid}/mesh keeps the cache the desktop app reads warm.
|
|
13
|
+
* No spool/held/outbox/flush/transcript-watch on this box.
|
|
14
|
+
*
|
|
15
|
+
* The flag is resolved from (highest first): env HQ_MESH_MODE, the work-mesh
|
|
16
|
+
* config file (~/.hq/work-mesh/config.json `emitMode`), else "legacy".
|
|
17
|
+
*/
|
|
18
|
+
export type MeshEmitMode = "legacy" | "direct";
|
|
19
|
+
export declare const MESH_MODE_ENV = "HQ_MESH_MODE";
|
|
20
|
+
export declare function meshConfigPath(meshRoot: string): string;
|
|
21
|
+
/** Read `emitMode` from ~/.hq/work-mesh/config.json (best-effort). */
|
|
22
|
+
export declare function readMeshConfigMode(meshRoot: string): MeshEmitMode | null;
|
|
23
|
+
export interface ResolveModeDeps {
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
meshRoot?: string;
|
|
26
|
+
home?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the daemon's emit mode. Env HQ_MESH_MODE wins, then the config file,
|
|
30
|
+
* else "legacy" (safe default until the server route returns 200).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveMeshEmitMode(deps?: ResolveModeDeps): MeshEmitMode;
|
|
33
|
+
/** Persist the emit mode to ~/.hq/work-mesh/config.json (`hq mesh mode set`). */
|
|
34
|
+
export declare function writeMeshConfigMode(meshRoot: string, mode: MeshEmitMode): void;
|
|
35
|
+
/** True when the daemon should run the legacy emit path (spool/flush/held). */
|
|
36
|
+
export declare function runsEmitPath(mode: MeshEmitMode): boolean;
|
|
37
|
+
//# sourceMappingURL=mode.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh daemon mode gate (owner decision 2026-09-08 "retire the client daemon";
|
|
3
|
+
* contract direct-emit-v1 §6.3 "deprecated, not deleted").
|
|
4
|
+
*
|
|
5
|
+
* Two modes coexist so this can merge before the server route ships and flip
|
|
6
|
+
* after:
|
|
7
|
+
* - "legacy" — the daemon runs the EMIT path (spool watch + flush + held +
|
|
8
|
+
* outbox replay + transcript-watch fallback) AND the receive
|
|
9
|
+
* subscriber. This is the default until the server route is live.
|
|
10
|
+
* - "direct" — sessions POST events directly (see `hq mesh session`/`emit`);
|
|
11
|
+
* the daemon runs RECEIVE-ONLY: the presence subscriber on
|
|
12
|
+
* hq/{personUid}/mesh keeps the cache the desktop app reads warm.
|
|
13
|
+
* No spool/held/outbox/flush/transcript-watch on this box.
|
|
14
|
+
*
|
|
15
|
+
* The flag is resolved from (highest first): env HQ_MESH_MODE, the work-mesh
|
|
16
|
+
* config file (~/.hq/work-mesh/config.json `emitMode`), else "legacy".
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { workMeshRoot } from "../paths.js";
|
|
21
|
+
export const MESH_MODE_ENV = "HQ_MESH_MODE";
|
|
22
|
+
export function meshConfigPath(meshRoot) {
|
|
23
|
+
return path.join(meshRoot, "config.json");
|
|
24
|
+
}
|
|
25
|
+
function normalizeMode(value) {
|
|
26
|
+
if (typeof value !== "string")
|
|
27
|
+
return null;
|
|
28
|
+
const v = value.trim().toLowerCase();
|
|
29
|
+
if (v === "direct" || v === "direct-emit" || v === "receive-only")
|
|
30
|
+
return "direct";
|
|
31
|
+
if (v === "legacy" || v === "daemon")
|
|
32
|
+
return "legacy";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/** Read `emitMode` from ~/.hq/work-mesh/config.json (best-effort). */
|
|
36
|
+
export function readMeshConfigMode(meshRoot) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(meshConfigPath(meshRoot), "utf8");
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
41
|
+
return normalizeMode(parsed.emitMode);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* absent / malformed → no override */
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the daemon's emit mode. Env HQ_MESH_MODE wins, then the config file,
|
|
51
|
+
* else "legacy" (safe default until the server route returns 200).
|
|
52
|
+
*/
|
|
53
|
+
export function resolveMeshEmitMode(deps = {}) {
|
|
54
|
+
const env = deps.env ?? process.env;
|
|
55
|
+
const fromEnv = normalizeMode(env[MESH_MODE_ENV]);
|
|
56
|
+
if (fromEnv)
|
|
57
|
+
return fromEnv;
|
|
58
|
+
const meshRoot = deps.meshRoot ?? workMeshRoot(deps.home, env);
|
|
59
|
+
const fromConfig = readMeshConfigMode(meshRoot);
|
|
60
|
+
if (fromConfig)
|
|
61
|
+
return fromConfig;
|
|
62
|
+
return "legacy";
|
|
63
|
+
}
|
|
64
|
+
/** Persist the emit mode to ~/.hq/work-mesh/config.json (`hq mesh mode set`). */
|
|
65
|
+
export function writeMeshConfigMode(meshRoot, mode) {
|
|
66
|
+
const p = meshConfigPath(meshRoot);
|
|
67
|
+
let existing = {};
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
72
|
+
existing = parsed;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* new file */
|
|
77
|
+
}
|
|
78
|
+
existing.emitMode = mode;
|
|
79
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
80
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
81
|
+
fs.writeFileSync(tmp, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
82
|
+
fs.renameSync(tmp, p);
|
|
83
|
+
}
|
|
84
|
+
/** True when the daemon should run the legacy emit path (spool/flush/held). */
|
|
85
|
+
export function runsEmitPath(mode) {
|
|
86
|
+
return mode === "legacy";
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=mode.js.map
|
|
@@ -13,6 +13,7 @@ import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
|
|
|
13
13
|
import { type PidLockDeps } from "./pid-lock.js";
|
|
14
14
|
import { PresenceClient, type MqttConnectFn } from "./presence.js";
|
|
15
15
|
import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
|
|
16
|
+
import { type MeshEmitMode } from "./mode.js";
|
|
16
17
|
export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
17
18
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
18
19
|
/**
|
|
@@ -53,6 +54,13 @@ export interface DaemonRunDeps {
|
|
|
53
54
|
enablePresence?: boolean;
|
|
54
55
|
/** When false, skip transcript-watch lane (tests). Default true. */
|
|
55
56
|
enableTranscriptWatch?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Emit mode. "legacy" runs the spool/flush/held/outbox/transcript-watch emit
|
|
59
|
+
* path; "direct" runs receive-only (subscriber + cache) because sessions POST
|
|
60
|
+
* events directly. Default resolves from HQ_MESH_MODE / work-mesh config, else
|
|
61
|
+
* "legacy" (contract direct-emit-v1 §6.3).
|
|
62
|
+
*/
|
|
63
|
+
mode?: MeshEmitMode;
|
|
56
64
|
/** Injectable transcript fs (tests). */
|
|
57
65
|
transcriptFs?: TranscriptFs;
|
|
58
66
|
/** HQ tree root for deterministic cwd→company/project mapping. */
|
|
@@ -28,6 +28,7 @@ import { PresenceClient, } from "./presence.js";
|
|
|
28
28
|
import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state.js";
|
|
29
29
|
import { workMeshHeldPath } from "../paths.js";
|
|
30
30
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
31
|
+
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
31
32
|
export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
32
33
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
33
34
|
/**
|
|
@@ -64,6 +65,8 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
64
65
|
const ctxRoot = deps.workContextRoot ?? workContextRoot(home, env);
|
|
65
66
|
const dir = daemonDir(meshRoot, home, env);
|
|
66
67
|
const timers = deps.timers ?? realTimerHost;
|
|
68
|
+
const mode = deps.mode ?? resolveMeshEmitMode({ env, meshRoot, home });
|
|
69
|
+
const emitPath = runsEmitPath(mode);
|
|
67
70
|
const now = deps.now ?? (() => new Date());
|
|
68
71
|
const log = deps.log ??
|
|
69
72
|
((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
|
|
@@ -77,7 +80,8 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
77
80
|
throw new Error(`daemon already running (pid ${lock.owner.pid}); refuse second instance`);
|
|
78
81
|
}
|
|
79
82
|
writeDaemonState(dir, defaultDaemonState(lockDeps.pid, now));
|
|
80
|
-
log(dir, `daemon started pid=${lockDeps.pid}`);
|
|
83
|
+
log(dir, `daemon started pid=${lockDeps.pid} mode=${mode}`);
|
|
84
|
+
patchDaemonState(dir, { emitMode: mode }, now);
|
|
81
85
|
// Opt into machine-credential minting when the box has creds (systemd exports
|
|
82
86
|
// HQ_MACHINE_CREDS_FILE). On a person laptop with no machine creds this falls
|
|
83
87
|
// through to the person login cache inside ensureCognitoToken.
|
|
@@ -199,7 +203,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
199
203
|
/** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
|
|
200
204
|
const lastHookEventAt = new Map();
|
|
201
205
|
let transcriptWatcher = null;
|
|
202
|
-
if (deps.enableTranscriptWatch !== false) {
|
|
206
|
+
if (emitPath && deps.enableTranscriptWatch !== false) {
|
|
203
207
|
const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
|
|
204
208
|
const getToken = deps.getToken ?? defaultGetToken;
|
|
205
209
|
transcriptWatcher = new TranscriptWatcher({
|
|
@@ -314,34 +318,36 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
314
318
|
void doFlushCycle("watch");
|
|
315
319
|
}, SPOOL_DEBOUNCE_MS);
|
|
316
320
|
};
|
|
317
|
-
// Watch spool directory (and file) for changes.
|
|
321
|
+
// Watch spool directory (and file) for changes — legacy emit path only.
|
|
318
322
|
const spoolPath = workMeshSpoolPath(meshRoot);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
fs.
|
|
323
|
+
if (emitPath) {
|
|
324
|
+
try {
|
|
325
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true, mode: 0o700 });
|
|
326
|
+
if (!fs.existsSync(spoolPath)) {
|
|
327
|
+
fs.writeFileSync(spoolPath, "", { mode: 0o600 });
|
|
328
|
+
}
|
|
329
|
+
watcher = fs.watch(path.dirname(spoolPath), { persistent: true }, (_event, filename) => {
|
|
330
|
+
if (stopped)
|
|
331
|
+
return;
|
|
332
|
+
if (shouldFlushOnSpoolDirEvent(filename)) {
|
|
333
|
+
scheduleDebouncedFlush();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
339
|
+
log(dir, `fs.watch failed (interval flush still active): ${msg.slice(0, 120)}`);
|
|
323
340
|
}
|
|
324
|
-
|
|
341
|
+
flushIntervalHandle = timers.setTimeout(function tick() {
|
|
325
342
|
if (stopped)
|
|
326
343
|
return;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
-
log(dir, `fs.watch failed (interval flush still active): ${msg.slice(0, 120)}`);
|
|
344
|
+
void doFlushCycle("interval").finally(() => {
|
|
345
|
+
if (stopped)
|
|
346
|
+
return;
|
|
347
|
+
flushIntervalHandle = timers.setTimeout(tick, FLUSH_INTERVAL_MS);
|
|
348
|
+
});
|
|
349
|
+
}, FLUSH_INTERVAL_MS);
|
|
335
350
|
}
|
|
336
|
-
flushIntervalHandle = timers.setTimeout(function tick() {
|
|
337
|
-
if (stopped)
|
|
338
|
-
return;
|
|
339
|
-
void doFlushCycle("interval").finally(() => {
|
|
340
|
-
if (stopped)
|
|
341
|
-
return;
|
|
342
|
-
flushIntervalHandle = timers.setTimeout(tick, FLUSH_INTERVAL_MS);
|
|
343
|
-
});
|
|
344
|
-
}, FLUSH_INTERVAL_MS);
|
|
345
351
|
boardIntervalHandle = timers.setTimeout(function boardTick() {
|
|
346
352
|
if (stopped)
|
|
347
353
|
return;
|
|
@@ -393,10 +399,15 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
393
399
|
});
|
|
394
400
|
}, TRANSCRIPT_WATCH_INTERVAL_MS);
|
|
395
401
|
}
|
|
396
|
-
// Initial
|
|
397
|
-
|
|
402
|
+
// Initial pass. Board refresh runs in both modes; the flush + transcript
|
|
403
|
+
// scan are legacy emit-path only.
|
|
404
|
+
if (emitPath) {
|
|
405
|
+
void doFlushCycle("start");
|
|
406
|
+
}
|
|
398
407
|
void refreshFn().catch(() => undefined);
|
|
399
|
-
|
|
408
|
+
if (emitPath) {
|
|
409
|
+
void runTranscriptTick();
|
|
410
|
+
}
|
|
400
411
|
const stop = async () => {
|
|
401
412
|
if (stopped)
|
|
402
413
|
return;
|
|
@@ -22,6 +22,8 @@ export interface DaemonStateFile {
|
|
|
22
22
|
lastErrorCode?: string;
|
|
23
23
|
/** Set while credential vend is refused (FEATURE_DISABLED / unsupported / 403). */
|
|
24
24
|
presenceRefusal?: PresenceRefusalState | null;
|
|
25
|
+
/** Daemon emit mode: "legacy" (spool/flush) or "direct" (receive-only). */
|
|
26
|
+
emitMode?: "legacy" | "direct";
|
|
25
27
|
updatedAt: string;
|
|
26
28
|
}
|
|
27
29
|
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-emit client: POST /v1/mesh/events (owner decision 2026-09-08).
|
|
3
|
+
*
|
|
4
|
+
* Hooks post each Work Mesh event straight to the server over HTTPS with the
|
|
5
|
+
* caller's normal HQ bearer token (person login or fleet agent token). The
|
|
6
|
+
* server persists, attributes (server-side), and fans out over MQTT. There is
|
|
7
|
+
* no local spool, held queue, or per-person STS vend on the emit path.
|
|
8
|
+
*
|
|
9
|
+
* Field names for the endpoint + per-event status vocabulary follow
|
|
10
|
+
* companies/indigo/projects/work-mesh-live/contracts/direct-emit-v1.md; this
|
|
11
|
+
* client tolerates minor shape drift (unknown keys ignored) and treats an
|
|
12
|
+
* unparseable 2xx as "retain and retry" rather than pretend-posted.
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionEventKind, SessionEventHarness, SessionEventSource } from "./format-spool-line.js";
|
|
15
|
+
import type { EmitEvidence } from "./emit-evidence.js";
|
|
16
|
+
export declare const MESH_EVENTS_PATH = "/v1/mesh/events";
|
|
17
|
+
/** Max events per POST (server batches; keep parity with legacy 100). */
|
|
18
|
+
export declare const MESH_EVENTS_BATCH_MAX = 100;
|
|
19
|
+
/** Per-event server disposition. */
|
|
20
|
+
export type EmitEventStatus = "accepted" | "unassigned" | "rejected";
|
|
21
|
+
export interface EmitEventResult {
|
|
22
|
+
eventId: string;
|
|
23
|
+
status: EmitEventStatus;
|
|
24
|
+
/** Present when status != accepted (no_company_evidence | not_a_member | …). */
|
|
25
|
+
reason?: string;
|
|
26
|
+
/** Resolved company (accepted, or unassigned-into-single-company). */
|
|
27
|
+
companyUid?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface EmitPostResult {
|
|
30
|
+
status: number;
|
|
31
|
+
body: unknown;
|
|
32
|
+
ok: boolean;
|
|
33
|
+
retryable: boolean;
|
|
34
|
+
networkError?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export type EmitPoster = (events: Record<string, unknown>[]) => Promise<EmitPostResult>;
|
|
37
|
+
export declare function classifyEmitResponse(status: number, networkError?: boolean): Pick<EmitPostResult, "ok" | "retryable" | "networkError">;
|
|
38
|
+
/**
|
|
39
|
+
* Parse the batch response into per-event statuses. Returns null when the body
|
|
40
|
+
* is not an object (caller should retain events rather than treat as posted).
|
|
41
|
+
* Accepts either { results: [{eventId,status,code}] } or
|
|
42
|
+
* { accepted:[ids], unassigned:[ids], rejected:[{eventId,code}] }.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseEmitResults(body: unknown): EmitEventResult[] | null;
|
|
45
|
+
/**
|
|
46
|
+
* Build a poster that uses vaultApiFetch + the caller's bearer token.
|
|
47
|
+
* `post` override is for tests (never hits the network).
|
|
48
|
+
*/
|
|
49
|
+
export declare function createEmitPoster(opts: {
|
|
50
|
+
token: string;
|
|
51
|
+
baseUrl?: string;
|
|
52
|
+
post?: (path: string, body: {
|
|
53
|
+
events: Record<string, unknown>[];
|
|
54
|
+
}) => Promise<{
|
|
55
|
+
status: number;
|
|
56
|
+
body: unknown;
|
|
57
|
+
}>;
|
|
58
|
+
}): EmitPoster;
|
|
59
|
+
/** One event posted to /v1/mesh/events: core session-event fields + evidence. */
|
|
60
|
+
export interface MeshEmitEvent {
|
|
61
|
+
v: 1;
|
|
62
|
+
eventId: string;
|
|
63
|
+
kind: SessionEventKind;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
harness: SessionEventHarness;
|
|
66
|
+
adapterVersion: string;
|
|
67
|
+
at: string;
|
|
68
|
+
seq: number;
|
|
69
|
+
runtimeVersion?: string;
|
|
70
|
+
source?: SessionEventSource;
|
|
71
|
+
taskId?: string;
|
|
72
|
+
status?: "queued" | "in_progress" | "review" | "done";
|
|
73
|
+
reason?: string;
|
|
74
|
+
summary?: string;
|
|
75
|
+
evidence?: EmitEvidence;
|
|
76
|
+
}
|
|
77
|
+
export interface BuildMeshEmitEventInput {
|
|
78
|
+
eventId: string;
|
|
79
|
+
kind: SessionEventKind;
|
|
80
|
+
sessionId: string;
|
|
81
|
+
harness: SessionEventHarness;
|
|
82
|
+
adapterVersion: string;
|
|
83
|
+
at: string;
|
|
84
|
+
seq: number;
|
|
85
|
+
runtimeVersion?: string;
|
|
86
|
+
source?: SessionEventSource;
|
|
87
|
+
taskId?: string;
|
|
88
|
+
status?: "queued" | "in_progress" | "review" | "done";
|
|
89
|
+
reason?: string;
|
|
90
|
+
summary?: string;
|
|
91
|
+
evidence?: EmitEvidence;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build a MeshEmitEvent, omitting empty optionals and dropping the evidence
|
|
95
|
+
* object entirely when it carries nothing. Never includes server-owned fields
|
|
96
|
+
* (companyUid/actorUid/contextStatus) — those would draw a 403 (contract §2.1).
|
|
97
|
+
*/
|
|
98
|
+
export declare function buildMeshEmitEvent(input: BuildMeshEmitEventInput): MeshEmitEvent;
|
|
99
|
+
//# sourceMappingURL=emit-client.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-emit client: POST /v1/mesh/events (owner decision 2026-09-08).
|
|
3
|
+
*
|
|
4
|
+
* Hooks post each Work Mesh event straight to the server over HTTPS with the
|
|
5
|
+
* caller's normal HQ bearer token (person login or fleet agent token). The
|
|
6
|
+
* server persists, attributes (server-side), and fans out over MQTT. There is
|
|
7
|
+
* no local spool, held queue, or per-person STS vend on the emit path.
|
|
8
|
+
*
|
|
9
|
+
* Field names for the endpoint + per-event status vocabulary follow
|
|
10
|
+
* companies/indigo/projects/work-mesh-live/contracts/direct-emit-v1.md; this
|
|
11
|
+
* client tolerates minor shape drift (unknown keys ignored) and treats an
|
|
12
|
+
* unparseable 2xx as "retain and retry" rather than pretend-posted.
|
|
13
|
+
*/
|
|
14
|
+
import { vaultApiFetch } from "../../../utils/vault-api.js";
|
|
15
|
+
import { DEFAULT_VAULT_API_URL } from "../../../utils/cognito-session.js";
|
|
16
|
+
export const MESH_EVENTS_PATH = "/v1/mesh/events";
|
|
17
|
+
/** Max events per POST (server batches; keep parity with legacy 100). */
|
|
18
|
+
export const MESH_EVENTS_BATCH_MAX = 100;
|
|
19
|
+
export function classifyEmitResponse(status, networkError = false) {
|
|
20
|
+
if (networkError || status === 0) {
|
|
21
|
+
return { ok: false, retryable: true, networkError: true };
|
|
22
|
+
}
|
|
23
|
+
if (status >= 200 && status < 300)
|
|
24
|
+
return { ok: true, retryable: false };
|
|
25
|
+
// 404/408/425/429 + 5xx are transient (route not yet deployed, throttle,
|
|
26
|
+
// server fault) — RETAIN and retry, never drop. A 404 here matters: replaying
|
|
27
|
+
// the legacy backlog before the server route ships must not discard events.
|
|
28
|
+
if (status === 404 ||
|
|
29
|
+
status === 408 ||
|
|
30
|
+
status === 425 ||
|
|
31
|
+
status === 429 ||
|
|
32
|
+
status >= 500) {
|
|
33
|
+
return { ok: false, retryable: true };
|
|
34
|
+
}
|
|
35
|
+
// Real client/auth errors (400/401/403/413) → terminal (never retried).
|
|
36
|
+
return { ok: false, retryable: false };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse the batch response into per-event statuses. Returns null when the body
|
|
40
|
+
* is not an object (caller should retain events rather than treat as posted).
|
|
41
|
+
* Accepts either { results: [{eventId,status,code}] } or
|
|
42
|
+
* { accepted:[ids], unassigned:[ids], rejected:[{eventId,code}] }.
|
|
43
|
+
*/
|
|
44
|
+
export function parseEmitResults(body) {
|
|
45
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
46
|
+
return null;
|
|
47
|
+
const rec = body;
|
|
48
|
+
if (Array.isArray(rec.results)) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const row of rec.results) {
|
|
51
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
52
|
+
continue;
|
|
53
|
+
const r = row;
|
|
54
|
+
const eventId = typeof r.eventId === "string" ? r.eventId.trim() : "";
|
|
55
|
+
if (!eventId)
|
|
56
|
+
continue;
|
|
57
|
+
const status = normalizeStatus(r.status);
|
|
58
|
+
if (!status)
|
|
59
|
+
continue;
|
|
60
|
+
const res = { eventId, status };
|
|
61
|
+
if (typeof r.reason === "string" && r.reason.trim())
|
|
62
|
+
res.reason = r.reason.trim();
|
|
63
|
+
else if (typeof r.code === "string" && r.code.trim())
|
|
64
|
+
res.reason = r.code.trim();
|
|
65
|
+
if (typeof r.companyUid === "string" && r.companyUid.trim()) {
|
|
66
|
+
res.companyUid = r.companyUid.trim();
|
|
67
|
+
}
|
|
68
|
+
out.push(res);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
// Fallback shape: parallel arrays.
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const id of asStringArray(rec.accepted))
|
|
75
|
+
out.push({ eventId: id, status: "accepted" });
|
|
76
|
+
for (const id of asStringArray(rec.unassigned))
|
|
77
|
+
out.push({ eventId: id, status: "unassigned" });
|
|
78
|
+
if (Array.isArray(rec.rejected)) {
|
|
79
|
+
for (const row of rec.rejected) {
|
|
80
|
+
if (typeof row === "string" && row.trim()) {
|
|
81
|
+
out.push({ eventId: row.trim(), status: "rejected" });
|
|
82
|
+
}
|
|
83
|
+
else if (row && typeof row === "object" && !Array.isArray(row)) {
|
|
84
|
+
const r = row;
|
|
85
|
+
const eventId = typeof r.eventId === "string" ? r.eventId.trim() : "";
|
|
86
|
+
if (!eventId)
|
|
87
|
+
continue;
|
|
88
|
+
const res = { eventId, status: "rejected" };
|
|
89
|
+
if (typeof r.reason === "string" && r.reason.trim())
|
|
90
|
+
res.reason = r.reason.trim();
|
|
91
|
+
else if (typeof r.code === "string" && r.code.trim())
|
|
92
|
+
res.reason = r.code.trim();
|
|
93
|
+
out.push(res);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out.length > 0 ? out : null;
|
|
98
|
+
}
|
|
99
|
+
function normalizeStatus(v) {
|
|
100
|
+
if (typeof v !== "string")
|
|
101
|
+
return null;
|
|
102
|
+
const s = v.trim().toLowerCase();
|
|
103
|
+
if (s === "accepted" || s === "unassigned" || s === "rejected")
|
|
104
|
+
return s;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function asStringArray(v) {
|
|
108
|
+
if (!Array.isArray(v))
|
|
109
|
+
return [];
|
|
110
|
+
return v.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build a poster that uses vaultApiFetch + the caller's bearer token.
|
|
114
|
+
* `post` override is for tests (never hits the network).
|
|
115
|
+
*/
|
|
116
|
+
export function createEmitPoster(opts) {
|
|
117
|
+
return async (events) => {
|
|
118
|
+
const body = { events };
|
|
119
|
+
try {
|
|
120
|
+
if (opts.post) {
|
|
121
|
+
const res = await opts.post(MESH_EVENTS_PATH, body);
|
|
122
|
+
return { status: res.status, body: res.body, ...classifyEmitResponse(res.status) };
|
|
123
|
+
}
|
|
124
|
+
const res = await vaultApiFetch({
|
|
125
|
+
method: "POST",
|
|
126
|
+
path: MESH_EVENTS_PATH,
|
|
127
|
+
token: opts.token,
|
|
128
|
+
baseUrl: opts.baseUrl ?? DEFAULT_VAULT_API_URL,
|
|
129
|
+
body: body,
|
|
130
|
+
});
|
|
131
|
+
const text = await res.text();
|
|
132
|
+
let parsed = {};
|
|
133
|
+
if (text) {
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(text);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
parsed = { raw: text };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { status: res.status, body: parsed, ...classifyEmitResponse(res.status) };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
// Auth hard-fail: 401/403 is not retryable (bad token, not a blip).
|
|
146
|
+
if (/^401\b|^403\b|unauthorized|forbidden/i.test(message)) {
|
|
147
|
+
return { status: 401, body: { error: message }, ok: false, retryable: false };
|
|
148
|
+
}
|
|
149
|
+
return { status: 0, body: { error: message }, ...classifyEmitResponse(0, true) };
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function keep(v) {
|
|
154
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Build a MeshEmitEvent, omitting empty optionals and dropping the evidence
|
|
158
|
+
* object entirely when it carries nothing. Never includes server-owned fields
|
|
159
|
+
* (companyUid/actorUid/contextStatus) — those would draw a 403 (contract §2.1).
|
|
160
|
+
*/
|
|
161
|
+
export function buildMeshEmitEvent(input) {
|
|
162
|
+
const event = {
|
|
163
|
+
v: 1,
|
|
164
|
+
eventId: input.eventId,
|
|
165
|
+
kind: input.kind,
|
|
166
|
+
sessionId: input.sessionId,
|
|
167
|
+
harness: input.harness,
|
|
168
|
+
adapterVersion: input.adapterVersion,
|
|
169
|
+
at: input.at,
|
|
170
|
+
seq: input.seq,
|
|
171
|
+
};
|
|
172
|
+
const runtimeVersion = keep(input.runtimeVersion);
|
|
173
|
+
if (runtimeVersion)
|
|
174
|
+
event.runtimeVersion = runtimeVersion;
|
|
175
|
+
if (input.source)
|
|
176
|
+
event.source = input.source;
|
|
177
|
+
const taskId = keep(input.taskId);
|
|
178
|
+
if (taskId)
|
|
179
|
+
event.taskId = taskId;
|
|
180
|
+
if (input.status)
|
|
181
|
+
event.status = input.status;
|
|
182
|
+
const reason = keep(input.reason);
|
|
183
|
+
if (reason)
|
|
184
|
+
event.reason = reason;
|
|
185
|
+
const summary = keep(input.summary);
|
|
186
|
+
if (summary)
|
|
187
|
+
event.summary = summary;
|
|
188
|
+
if (input.evidence && Object.keys(input.evidence).length > 0) {
|
|
189
|
+
event.evidence = input.evidence;
|
|
190
|
+
}
|
|
191
|
+
return event;
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=emit-client.js.map
|