@indigoai-us/hq-cli 5.108.24 → 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 +70 -0
- package/dist/commands/files.d.ts +11 -0
- package/dist/commands/files.js +206 -30
- 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
|
@@ -22,7 +22,65 @@
|
|
|
22
22
|
import * as fs from "node:fs";
|
|
23
23
|
import { readSessionState } from "../../work-context/state.js";
|
|
24
24
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
|
|
25
|
+
import { resolveCompany } from "../../work-context/company.js";
|
|
26
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
25
27
|
import { workMeshHeldPath } from "./paths.js";
|
|
28
|
+
/**
|
|
29
|
+
* Build the reconcile observation for one held session, forwarding the company
|
|
30
|
+
* evidence its events recorded (cwd / hqRoot / bound companySlug / project /
|
|
31
|
+
* task). The event's own companySlug is trusted explicit — it is the company
|
|
32
|
+
* the session was bound to when the events were emitted on this box.
|
|
33
|
+
*/
|
|
34
|
+
export function observationFromHeldRef(ref, contractVersion, clientOperationId) {
|
|
35
|
+
const obs = {
|
|
36
|
+
contractVersion,
|
|
37
|
+
identity: ref.harness
|
|
38
|
+
? { sessionId: ref.sessionId, harness: ref.harness }
|
|
39
|
+
: { sessionId: ref.sessionId },
|
|
40
|
+
clientOperationId,
|
|
41
|
+
};
|
|
42
|
+
if (ref.cwd)
|
|
43
|
+
obs.cwd = ref.cwd;
|
|
44
|
+
if (ref.hqRoot)
|
|
45
|
+
obs.hqRoot = ref.hqRoot;
|
|
46
|
+
if (ref.companySlug || ref.project || ref.task) {
|
|
47
|
+
obs.explicit = {
|
|
48
|
+
...(ref.companySlug ? { companySlug: ref.companySlug } : {}),
|
|
49
|
+
...(ref.project ? { projectId: ref.project } : {}),
|
|
50
|
+
...(ref.task ? { taskId: ref.task } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return obs;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Default dry-run predictor: run the shared `resolveCompany` over the same
|
|
57
|
+
* evidence the write path forwards. Returns true when a company WOULD resolve
|
|
58
|
+
* (so dry-run's `reconciled` count matches the write run's). Purely local:
|
|
59
|
+
* reads config / meta / git-remote, never the network. A slug-only resolution
|
|
60
|
+
* that later fails online membership validation is the one narrow case where
|
|
61
|
+
* the write run can still under-count; the gross all-or-nothing mismatch is
|
|
62
|
+
* gone because both paths key off this same resolver + evidence.
|
|
63
|
+
*/
|
|
64
|
+
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
|
|
65
|
+
const remoteOwnerSlug = remoteOwnerHint?.trim() ||
|
|
66
|
+
(ref.cwd
|
|
67
|
+
? deriveRemoteOwnerSlug({ cwd: ref.cwd, hqRoot: ref.hqRoot }) ?? undefined
|
|
68
|
+
: undefined);
|
|
69
|
+
const repoIdentityKey = ref.cwd
|
|
70
|
+
? deriveRepoIdentityKey({ cwd: ref.cwd })
|
|
71
|
+
: null;
|
|
72
|
+
const resolution = resolveCompany({
|
|
73
|
+
root: workContextRoot,
|
|
74
|
+
sessionId: ref.sessionId,
|
|
75
|
+
env,
|
|
76
|
+
trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
|
|
77
|
+
cwd: ref.cwd,
|
|
78
|
+
hqRoot: ref.hqRoot,
|
|
79
|
+
remoteOwnerSlug,
|
|
80
|
+
repoIdentityKey,
|
|
81
|
+
});
|
|
82
|
+
return resolution.status === "resolved";
|
|
83
|
+
}
|
|
26
84
|
/**
|
|
27
85
|
* Read held.jsonl and return distinct sessions (first occurrence wins),
|
|
28
86
|
* carrying the harness from whichever held event we saw first for that session.
|
|
@@ -65,10 +123,27 @@ export function readHeldSessions(workMeshRoot) {
|
|
|
65
123
|
: null;
|
|
66
124
|
if (!sessionId || seen.has(sessionId))
|
|
67
125
|
continue;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
126
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
127
|
+
const ref = { sessionId };
|
|
128
|
+
const harness = str(event.harness);
|
|
129
|
+
if (harness)
|
|
130
|
+
ref.harness = harness;
|
|
131
|
+
const cwd = str(event.cwd);
|
|
132
|
+
if (cwd)
|
|
133
|
+
ref.cwd = cwd;
|
|
134
|
+
const hqRoot = str(event.hqRoot);
|
|
135
|
+
if (hqRoot)
|
|
136
|
+
ref.hqRoot = hqRoot;
|
|
137
|
+
const companySlug = str(event.companySlug);
|
|
138
|
+
if (companySlug)
|
|
139
|
+
ref.companySlug = companySlug;
|
|
140
|
+
const project = str(event.project);
|
|
141
|
+
if (project)
|
|
142
|
+
ref.project = project;
|
|
143
|
+
const task = str(event.task);
|
|
144
|
+
if (task)
|
|
145
|
+
ref.task = task;
|
|
146
|
+
seen.set(sessionId, ref);
|
|
72
147
|
}
|
|
73
148
|
return [...seen.values()];
|
|
74
149
|
}
|
|
@@ -81,7 +156,10 @@ export async function backfillHeldSessions(deps) {
|
|
|
81
156
|
const limit = deps.limit && deps.limit > 0 ? deps.limit : 0;
|
|
82
157
|
const contractVersion = deps.contractVersion ?? WORK_CONTEXT_CONTRACT_VERSION;
|
|
83
158
|
const readState = deps.readState ?? readSessionState;
|
|
159
|
+
const env = deps.env ?? process.env;
|
|
84
160
|
const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
|
|
161
|
+
const predictCompany = deps.predictCompany ??
|
|
162
|
+
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
|
|
85
163
|
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
86
164
|
const considered = limit > 0 ? sessions.slice(0, limit) : sessions;
|
|
87
165
|
const result = {
|
|
@@ -93,23 +171,27 @@ export async function backfillHeldSessions(deps) {
|
|
|
93
171
|
errors: 0,
|
|
94
172
|
dryRun,
|
|
95
173
|
};
|
|
96
|
-
for (const
|
|
174
|
+
for (const ref of considered) {
|
|
175
|
+
const { sessionId, harness } = ref;
|
|
97
176
|
const state = readState(sessionId, deps.workContextRoot);
|
|
98
177
|
if (state?.companyUid) {
|
|
99
178
|
result.alreadyAttributed += 1;
|
|
100
179
|
continue;
|
|
101
180
|
}
|
|
102
181
|
if (dryRun) {
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
182
|
+
// Predict with the SAME evidence + resolver the write path uses, so the
|
|
183
|
+
// dry-run counts match a real run (no blanket would-reconcile).
|
|
184
|
+
if (predictCompany(ref)) {
|
|
185
|
+
result.reconciled += 1;
|
|
186
|
+
deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
result.unresolved += 1;
|
|
190
|
+
deps.log?.(`would stay unresolved ${sessionId} (no company evidence)`);
|
|
191
|
+
}
|
|
106
192
|
continue;
|
|
107
193
|
}
|
|
108
|
-
const obs =
|
|
109
|
-
contractVersion,
|
|
110
|
-
identity: harness ? { sessionId, harness } : { sessionId },
|
|
111
|
-
clientOperationId: newOperationId(),
|
|
112
|
-
};
|
|
194
|
+
const obs = observationFromHeldRef(ref, contractVersion, newOperationId());
|
|
113
195
|
try {
|
|
114
196
|
const outcome = await deps.reconcile(obs);
|
|
115
197
|
if (outcome.result.companyUid) {
|
|
@@ -10,6 +10,10 @@ export interface DaemonDoctorReport {
|
|
|
10
10
|
pid?: number;
|
|
11
11
|
mqttState: string;
|
|
12
12
|
companiesOnline: string[];
|
|
13
|
+
/** Daemon emit mode: "legacy" (spool/flush) or "direct" (receive-only). */
|
|
14
|
+
emitMode: "legacy" | "direct";
|
|
15
|
+
/** True when the receive subscriber is connected (mqtt connected). */
|
|
16
|
+
subscribed: boolean;
|
|
13
17
|
spoolDepth: number;
|
|
14
18
|
heldCount: number;
|
|
15
19
|
deadLetterCount: number;
|
|
@@ -25,6 +29,17 @@ export interface DaemonDoctorReport {
|
|
|
25
29
|
actorKind: CognitoActorKind;
|
|
26
30
|
/** Credential vend refused (disabled / unsupported / 403). */
|
|
27
31
|
presenceRefusal?: DaemonStateFile["presenceRefusal"];
|
|
32
|
+
/** Pending events awaiting a retry drain (POST /v1/mesh/events). */
|
|
33
|
+
emitRetryDepth: number;
|
|
34
|
+
/** Last successful direct-emit POST time. */
|
|
35
|
+
lastPostAt?: string;
|
|
36
|
+
/** Per-status counts from the last emit attempt. */
|
|
37
|
+
lastEmit?: {
|
|
38
|
+
accepted?: number;
|
|
39
|
+
unassigned?: number;
|
|
40
|
+
rejected?: number;
|
|
41
|
+
error?: string;
|
|
42
|
+
};
|
|
28
43
|
}
|
|
29
44
|
export interface DaemonDoctorDeps {
|
|
30
45
|
home?: string;
|
|
@@ -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
|