@indigoai-us/hq-cli 5.123.0 → 5.123.1
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 +21 -0
- package/dist/commands/mesh.js +7 -2
- package/dist/commands/update.js +6 -0
- package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
- package/dist/lib/mesh/live/backfill-held.js +323 -42
- package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
- package/dist/lib/mesh/live/daemon/config.js +45 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +17 -2
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
- package/dist/lib/mesh/live/daemon/index.js +3 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/install.js +54 -4
- package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/run.js +44 -2
- package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +4 -0
- package/dist/lib/work-context/reconcile.js +67 -6
- package/dist/lib/work-context/state.d.ts +5 -3
- package/dist/lib/work-context/state.js +28 -4
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.123.1] — 2026-09-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Work Mesh now preserves an acknowledged company binding through later
|
|
10
|
+
company-less hook writes, preventing repeated registrations and held events.
|
|
11
|
+
Held recovery accepts only durable registrations, canonical metadata under a
|
|
12
|
+
verified HQ root, or manifest-backed repository ownership; conflicting or
|
|
13
|
+
event-only hints stay unresolved. Its batch cursor rotates across restarts.
|
|
14
|
+
Both legacy and direct-mode daemons detect an upgraded CLI, and systemd units
|
|
15
|
+
use `Restart=always` so the installed service reloads the new code.
|
|
16
|
+
|
|
17
|
+
- Held-session recovery now accepts session metadata only from the daemon's
|
|
18
|
+
configured HQ root, requires current-session evidence before restoring an
|
|
19
|
+
acknowledged registration, and retains batch position when held records are
|
|
20
|
+
compacted. Repeated hook activity no longer re-registers an unchanged binding.
|
|
21
|
+
CLI updates report installed system units that need their host service manager
|
|
22
|
+
to restart them. Daemon installs persist verified HQ-root allowlists, inject
|
|
23
|
+
the primary root into service environments, and rewrite/reload existing user
|
|
24
|
+
units so upgraded daemons restart reliably.
|
|
25
|
+
|
|
5
26
|
## [5.123.0] — 2026-09-19
|
|
6
27
|
|
|
7
28
|
### Fixed
|
package/dist/commands/mesh.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Distinct from `hq doctor` (hook guardrails).
|
|
7
7
|
*/
|
|
8
8
|
import * as os from "node:os";
|
|
9
|
+
import { findHqRoot } from "../utils/manifest.js";
|
|
9
10
|
import chalk from "chalk";
|
|
10
11
|
import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
|
|
11
12
|
import * as readline from "node:readline/promises";
|
|
@@ -324,6 +325,7 @@ async function runContextBackfillHeld(opts) {
|
|
|
324
325
|
const result = await backfillHeldSessions({
|
|
325
326
|
workMeshRoot: meshRoot,
|
|
326
327
|
workContextRoot: root,
|
|
328
|
+
hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot(),
|
|
327
329
|
dryRun,
|
|
328
330
|
limit,
|
|
329
331
|
// Mirror the reconcile closure's non-forcing hint in the dry-run predictor
|
|
@@ -1181,7 +1183,10 @@ async function runDaemonRun() {
|
|
|
1181
1183
|
async function runDaemonInstall(opts) {
|
|
1182
1184
|
const result = installDaemonService({
|
|
1183
1185
|
platform: detectPlatform(),
|
|
1184
|
-
paths: buildInstallPaths({
|
|
1186
|
+
paths: buildInstallPaths({
|
|
1187
|
+
systemUser: opts.systemUser,
|
|
1188
|
+
hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot(),
|
|
1189
|
+
}),
|
|
1185
1190
|
systemUser: opts.systemUser,
|
|
1186
1191
|
});
|
|
1187
1192
|
if (opts.json) {
|
|
@@ -1242,7 +1247,7 @@ async function runDaemonStatus(opts) {
|
|
|
1242
1247
|
}
|
|
1243
1248
|
}
|
|
1244
1249
|
async function runDaemonDoctorCmd(opts) {
|
|
1245
|
-
const report = collectDaemonDoctor();
|
|
1250
|
+
const report = collectDaemonDoctor({ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot() });
|
|
1246
1251
|
if (opts.json) {
|
|
1247
1252
|
console.log(JSON.stringify({ ok: !report.unhealthy, ...report }, null, 2));
|
|
1248
1253
|
return;
|
package/dist/commands/update.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import { findHqRoot, readManifest, readLock, writeLock, getModulesDir } from '../utils/manifest.js';
|
|
7
7
|
import { fetchRepo, pullRepo, getCurrentCommit, isRepo } from '../utils/git.js';
|
|
8
|
+
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
8
9
|
export function registerUpdateCommand(program) {
|
|
9
10
|
program
|
|
10
11
|
.command('update [module-name]')
|
|
@@ -49,6 +50,11 @@ export function registerUpdateCommand(program) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
writeLock(hqRoot, lock);
|
|
53
|
+
// An installed daemon is a separate Node process. Refresh it
|
|
54
|
+
// best-effort after a successful update without making the update fail.
|
|
55
|
+
const restart = restartDaemonServiceIfInstalled();
|
|
56
|
+
if (restart.message)
|
|
57
|
+
console.log(restart.message);
|
|
52
58
|
console.log('\nLock file updated.');
|
|
53
59
|
}
|
|
54
60
|
catch (error) {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { ReconcileObservation, ReconcileOutcome } from "../../work-context/reconcile.js";
|
|
24
24
|
import { type SessionStateFile } from "../../work-context/state.js";
|
|
25
|
+
import { type OutboxOperation } from "../../work-context/outbox.js";
|
|
25
26
|
/**
|
|
26
27
|
* One distinct session discovered in held.jsonl, carrying the company evidence
|
|
27
28
|
* its held events recorded at enqueue time (cwd / hqRoot / companySlug /
|
|
@@ -38,12 +39,25 @@ export interface HeldSessionRef {
|
|
|
38
39
|
cwd?: string;
|
|
39
40
|
/** HQ root, so the resolver can read workspace/sessions/<sid>/meta.yaml. */
|
|
40
41
|
hqRoot?: string;
|
|
42
|
+
/** Every HQ root seen for this session, ordered by first observation. */
|
|
43
|
+
hqRoots?: string[];
|
|
41
44
|
/** Company the session was bound to at emit time (enqueue --company-slug). */
|
|
42
45
|
companySlug?: string;
|
|
43
46
|
/** Project id carried on the event, if any. */
|
|
44
47
|
project?: string;
|
|
45
48
|
/** Task id carried on the event, if any. */
|
|
46
49
|
task?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Per-event local evidence retained while coalescing a held session. Event
|
|
52
|
+
* timestamps are instance boundaries: a session id may be reused, so a root
|
|
53
|
+
* observed before the current state.startedAt must not attribute it.
|
|
54
|
+
*/
|
|
55
|
+
eventEvidence?: Array<{
|
|
56
|
+
hqRoot?: string;
|
|
57
|
+
cwd?: string;
|
|
58
|
+
harness?: string;
|
|
59
|
+
at?: string;
|
|
60
|
+
}>;
|
|
47
61
|
}
|
|
48
62
|
export interface BackfillHeldResult {
|
|
49
63
|
/** Distinct sessions found in held.jsonl (before --limit). */
|
|
@@ -52,10 +66,15 @@ export interface BackfillHeldResult {
|
|
|
52
66
|
considered: number;
|
|
53
67
|
/** Skipped because state already has a companyUid. */
|
|
54
68
|
alreadyAttributed: number;
|
|
55
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* A recovered company binding was persisted locally (would-recover in
|
|
71
|
+
* dry-run). A reconcile attempt that ends in needs_company is unresolved.
|
|
72
|
+
*/
|
|
56
73
|
reconciled: number;
|
|
57
74
|
/** Reconcile ran but resolver found no company. */
|
|
58
75
|
unresolved: number;
|
|
76
|
+
/** Unresolved because otherwise-trusted local evidence disagreed. */
|
|
77
|
+
conflictingEvidence: number;
|
|
59
78
|
/** Sessions that errored during reconcile. */
|
|
60
79
|
errors: number;
|
|
61
80
|
dryRun: boolean;
|
|
@@ -65,6 +84,10 @@ export interface BackfillHeldDeps {
|
|
|
65
84
|
workMeshRoot: string;
|
|
66
85
|
/** Work-context root that holds sessions/<sid>.json. */
|
|
67
86
|
workContextRoot: string;
|
|
87
|
+
/** HQ root configured for this daemon/CLI invocation (or device allowlist). */
|
|
88
|
+
hqRoot?: string;
|
|
89
|
+
/** Durable device allowlist of verified local HQ roots. */
|
|
90
|
+
hqRoots?: string[];
|
|
68
91
|
/** Report only; write nothing, call no network, invoke no reconcile. */
|
|
69
92
|
dryRun?: boolean;
|
|
70
93
|
/** Cap distinct sessions processed. 0 (default) = no cap. */
|
|
@@ -100,17 +123,29 @@ export interface BackfillHeldDeps {
|
|
|
100
123
|
predictCompany?: (ref: HeldSessionRef) => boolean;
|
|
101
124
|
/** Optional progress logger. */
|
|
102
125
|
log?: (message: string) => void;
|
|
126
|
+
/** Read/write seam for the bounded-startup rotation cursor. */
|
|
127
|
+
readCursor?: () => BackfillCursor | null;
|
|
128
|
+
writeCursor?: (cursor: BackfillCursor) => void;
|
|
129
|
+
}
|
|
130
|
+
interface BackfillCursor {
|
|
131
|
+
sessionId?: string;
|
|
132
|
+
/** Zero-based index of the last considered session in its file generation. */
|
|
133
|
+
ordinal?: number;
|
|
103
134
|
}
|
|
135
|
+
/** Read-only doctor helper: report only independently verified held evidence. */
|
|
136
|
+
export declare function refWithLocalCompanyEvidence(ref: HeldSessionRef, canonicalHqRoot?: string, canonicalHqRoots?: readonly string[], state?: SessionStateFile | null): HeldSessionRef | null;
|
|
104
137
|
/**
|
|
105
|
-
* Build the reconcile observation for
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* the session was bound to when the events were emitted on this box.
|
|
138
|
+
* Build the reconcile observation for a company that was independently proven
|
|
139
|
+
* from durable local evidence. Held-event fields are deliberately not forwarded
|
|
140
|
+
* as trusted explicit context.
|
|
109
141
|
*/
|
|
110
142
|
export declare function observationFromHeldRef(ref: HeldSessionRef, contractVersion: number, clientOperationId: string): ReconcileObservation;
|
|
143
|
+
/** Shared doctor/backfill gate for a receipt that is safe to treat as current evidence. */
|
|
144
|
+
export declare function hasCurrentAcknowledgedRegistration(ref: HeldSessionRef, state: SessionStateFile | null, operations: readonly OutboxOperation[], hqRoot?: string, hqRoots?: readonly string[]): boolean;
|
|
111
145
|
/**
|
|
112
|
-
* Read held.jsonl and return distinct sessions
|
|
113
|
-
*
|
|
146
|
+
* Read held.jsonl and return distinct sessions. Later events can add local
|
|
147
|
+
* evidence absent from the first event (the original first-occurrence-only
|
|
148
|
+
* implementation missed exactly that shape after a session became bound).
|
|
114
149
|
* Lines that fail to parse or carry no sessionId are ignored (the daemon owns
|
|
115
150
|
* their disposition; this backfill never mutates or deletes held lines).
|
|
116
151
|
*/
|
|
@@ -120,4 +155,5 @@ export declare function readHeldSessions(workMeshRoot: string): HeldSessionRef[]
|
|
|
120
155
|
* re-running skips sessions that already carry a companyUid.
|
|
121
156
|
*/
|
|
122
157
|
export declare function backfillHeldSessions(deps: BackfillHeldDeps): Promise<BackfillHeldResult>;
|
|
158
|
+
export {};
|
|
123
159
|
//# sourceMappingURL=backfill-held.d.ts.map
|
|
@@ -21,16 +21,189 @@
|
|
|
21
21
|
* - Purely local to the box it runs on; no fleet fan-out.
|
|
22
22
|
*/
|
|
23
23
|
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
24
25
|
import { readSessionState } from "../../work-context/state.js";
|
|
26
|
+
import { listOutboxOperations, writeAckedRegisterState, } from "../../work-context/outbox.js";
|
|
25
27
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
|
|
26
|
-
import { resolveCompany } from "../../work-context/company.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
28
|
+
import { resolveCompany, sameCompany } from "../../work-context/company.js";
|
|
29
|
+
import { readMetaCompanySlug } from "../../work-context/company.js";
|
|
30
|
+
import { deriveRemoteOwnerSlug, } from "../../work-context/repo-remote.js";
|
|
31
|
+
import { workMeshHeldBackfillCursorPath, workMeshHeldPath } from "./paths.js";
|
|
32
|
+
function readBackfillCursor(workMeshRoot) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(workMeshHeldBackfillCursorPath(workMeshRoot), "utf8"));
|
|
35
|
+
const sessionId = typeof parsed.sessionId === "string" && parsed.sessionId.trim()
|
|
36
|
+
? parsed.sessionId.trim()
|
|
37
|
+
: undefined;
|
|
38
|
+
const ordinal = typeof parsed.ordinal === "number" &&
|
|
39
|
+
Number.isInteger(parsed.ordinal) && parsed.ordinal >= 0
|
|
40
|
+
? parsed.ordinal
|
|
41
|
+
: undefined;
|
|
42
|
+
return sessionId || ordinal !== undefined ? { sessionId, ordinal } : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function writeBackfillCursor(workMeshRoot, cursor) {
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(workMeshHeldBackfillCursorPath(workMeshRoot), `${JSON.stringify(cursor)}\n`, { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A missing cursor only repeats a bounded batch; it must never stop flush.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function rotateSessions(sessions, limit, cursor) {
|
|
57
|
+
if (!limit || limit >= sessions.length)
|
|
58
|
+
return sessions;
|
|
59
|
+
const previous = cursor?.sessionId
|
|
60
|
+
? sessions.findIndex((ref) => ref.sessionId === cursor.sessionId)
|
|
61
|
+
: -1;
|
|
62
|
+
const start = previous >= 0
|
|
63
|
+
? (previous + 1) % sessions.length
|
|
64
|
+
: cursor?.ordinal !== undefined
|
|
65
|
+
? cursor.ordinal % sessions.length
|
|
66
|
+
: 0;
|
|
67
|
+
const rotated = [...sessions.slice(start), ...sessions.slice(0, start)];
|
|
68
|
+
return rotated.slice(0, limit);
|
|
69
|
+
}
|
|
70
|
+
function verifiedHqRoot(hqRoot) {
|
|
71
|
+
try {
|
|
72
|
+
const root = fs.realpathSync(hqRoot);
|
|
73
|
+
const manifest = path.join(root, "companies", "manifest.yaml");
|
|
74
|
+
return fs.statSync(manifest).isFile() ? root : null;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function verifiedMetaCompanySlug(sessionId, hqRoot) {
|
|
81
|
+
try {
|
|
82
|
+
const root = verifiedHqRoot(hqRoot);
|
|
83
|
+
if (!root)
|
|
84
|
+
return undefined;
|
|
85
|
+
const meta = path.join(root, "workspace", "sessions", sessionId, "meta.yaml");
|
|
86
|
+
if (!fs.statSync(meta).isFile())
|
|
87
|
+
return undefined;
|
|
88
|
+
// Do not follow a meta symlink outside the claimed HQ root.
|
|
89
|
+
const realMeta = fs.realpathSync(meta);
|
|
90
|
+
if (path.relative(root, realMeta).startsWith(".."))
|
|
91
|
+
return undefined;
|
|
92
|
+
return readMetaCompanySlug(sessionId, root);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function candidatesAgree(a, b) {
|
|
99
|
+
return sameCompany(a, b) ||
|
|
100
|
+
(!a.uid && !b.uid && Boolean(a.slug && b.slug) &&
|
|
101
|
+
a.slug.toLowerCase() === b.slug.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
function consistentCandidate(candidates) {
|
|
104
|
+
let chosen = null;
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (!candidate.slug && !candidate.uid)
|
|
107
|
+
continue;
|
|
108
|
+
if (!chosen) {
|
|
109
|
+
chosen = { ...candidate };
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!candidatesAgree(chosen, candidate))
|
|
113
|
+
return { candidate: null, conflict: true };
|
|
114
|
+
chosen = {
|
|
115
|
+
slug: chosen.slug ?? candidate.slug,
|
|
116
|
+
uid: chosen.uid ?? candidate.uid,
|
|
117
|
+
hqRoot: chosen.hqRoot ?? candidate.hqRoot,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { candidate: chosen, conflict: false };
|
|
121
|
+
}
|
|
122
|
+
function localCompanyCandidates(ref, state, canonicalHqRoot, canonicalHqRoots = []) {
|
|
123
|
+
const candidates = [];
|
|
124
|
+
// A slug without a UID can be the failed/stale projection this repair is
|
|
125
|
+
// investigating. Only a persisted UID is a durable registration signal.
|
|
126
|
+
if (state?.companyUid) {
|
|
127
|
+
candidates.push({ slug: state.companySlug, uid: state.companyUid });
|
|
128
|
+
}
|
|
129
|
+
const roots = [...new Set([canonicalHqRoot, ...canonicalHqRoots]
|
|
130
|
+
.filter((candidate) => Boolean(candidate)))]
|
|
131
|
+
.map(verifiedHqRoot).filter((root) => Boolean(root));
|
|
132
|
+
const eventEvidence = currentInstanceEventEvidence(ref, state);
|
|
133
|
+
const eventRoots = eventEvidence.map((evidence) => evidence.hqRoot).filter((candidate) => Boolean(candidate));
|
|
134
|
+
for (const root of roots)
|
|
135
|
+
if (eventRoots.some((candidate) => {
|
|
136
|
+
try {
|
|
137
|
+
return fs.realpathSync(candidate) === root;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
})) {
|
|
143
|
+
const slug = verifiedMetaCompanySlug(ref.sessionId, root);
|
|
144
|
+
if (slug)
|
|
145
|
+
candidates.push({ slug, hqRoot: root });
|
|
146
|
+
// The event cwd itself is not company evidence. The repository's origin is,
|
|
147
|
+
// but only when it is mapped by this verified HQ root's manifest.
|
|
148
|
+
for (const evidence of eventEvidence) {
|
|
149
|
+
if (!evidence.cwd || !evidence.hqRoot)
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
if (fs.realpathSync(evidence.hqRoot) !== root)
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const remoteSlug = deriveRemoteOwnerSlug({ cwd: evidence.cwd, hqRoot: root });
|
|
159
|
+
if (remoteSlug)
|
|
160
|
+
candidates.push({ slug: remoteSlug, hqRoot: root });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return candidates;
|
|
164
|
+
}
|
|
29
165
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
166
|
+
* Keep only event evidence that belongs to the current session instance. A
|
|
167
|
+
* current state is a durable instance marker; metadata from a held event that
|
|
168
|
+
* predates it could belong to a prior use of the same session id.
|
|
169
|
+
*/
|
|
170
|
+
function currentInstanceEventEvidence(ref, state) {
|
|
171
|
+
const evidence = ref.eventEvidence ?? [
|
|
172
|
+
{
|
|
173
|
+
hqRoot: ref.hqRoot,
|
|
174
|
+
cwd: ref.cwd,
|
|
175
|
+
harness: ref.harness,
|
|
176
|
+
},
|
|
177
|
+
...(ref.hqRoots ?? []).filter((hqRoot) => hqRoot !== ref.hqRoot).map((hqRoot) => ({ hqRoot })),
|
|
178
|
+
];
|
|
179
|
+
if (!state?.startedAt)
|
|
180
|
+
return evidence;
|
|
181
|
+
const startedAt = Date.parse(state.startedAt);
|
|
182
|
+
if (!Number.isFinite(startedAt))
|
|
183
|
+
return [];
|
|
184
|
+
return evidence.filter((entry) => {
|
|
185
|
+
if (!entry.hqRoot)
|
|
186
|
+
return false;
|
|
187
|
+
const eventAt = entry.at ? Date.parse(entry.at) : Number.NaN;
|
|
188
|
+
return Number.isFinite(eventAt) && eventAt >= startedAt;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function hasOnlyStaleEventEvidence(ref, state) {
|
|
192
|
+
return Boolean(state?.startedAt &&
|
|
193
|
+
ref.eventEvidence?.some((entry) => entry.hqRoot) &&
|
|
194
|
+
!currentInstanceEventEvidence(ref, state).some((entry) => entry.hqRoot));
|
|
195
|
+
}
|
|
196
|
+
/** Read-only doctor helper: report only independently verified held evidence. */
|
|
197
|
+
export function refWithLocalCompanyEvidence(ref, canonicalHqRoot, canonicalHqRoots = [], state = null) {
|
|
198
|
+
const evidence = consistentCandidate(localCompanyCandidates(ref, state, canonicalHqRoot, canonicalHqRoots));
|
|
199
|
+
return !evidence.conflict && evidence.candidate?.slug
|
|
200
|
+
? { ...ref, companySlug: evidence.candidate.slug }
|
|
201
|
+
: null;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Build the reconcile observation for a company that was independently proven
|
|
205
|
+
* from durable local evidence. Held-event fields are deliberately not forwarded
|
|
206
|
+
* as trusted explicit context.
|
|
34
207
|
*/
|
|
35
208
|
export function observationFromHeldRef(ref, contractVersion, clientOperationId) {
|
|
36
209
|
const obs = {
|
|
@@ -40,12 +213,8 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
|
|
|
40
213
|
: { sessionId: ref.sessionId },
|
|
41
214
|
clientOperationId,
|
|
42
215
|
};
|
|
43
|
-
if (ref.cwd)
|
|
44
|
-
obs.cwd = ref.cwd;
|
|
45
|
-
if (ref.hqRoot)
|
|
46
|
-
obs.hqRoot = ref.hqRoot;
|
|
47
216
|
if (ref.companySlug || ref.project || ref.task) {
|
|
48
|
-
obs.
|
|
217
|
+
obs.trustedContext = {
|
|
49
218
|
...(ref.companySlug ? { companySlug: ref.companySlug } : {}),
|
|
50
219
|
...(ref.project ? { projectId: ref.project } : {}),
|
|
51
220
|
...(ref.task ? { taskId: ref.task } : {}),
|
|
@@ -63,28 +232,74 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
|
|
|
63
232
|
* gone because both paths key off this same resolver + evidence.
|
|
64
233
|
*/
|
|
65
234
|
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
: undefined);
|
|
70
|
-
const repoIdentityKey = ref.cwd
|
|
71
|
-
? deriveRepoIdentityKey({ cwd: ref.cwd })
|
|
72
|
-
: null;
|
|
235
|
+
// This predictor is called only with a ref already rebuilt from trusted local
|
|
236
|
+
// evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
|
|
237
|
+
const remoteOwnerSlug = remoteOwnerHint?.trim();
|
|
73
238
|
const resolution = resolveCompany({
|
|
74
239
|
root: workContextRoot,
|
|
75
240
|
sessionId: ref.sessionId,
|
|
76
241
|
env,
|
|
77
242
|
trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
|
|
78
|
-
cwd: ref.cwd,
|
|
79
|
-
hqRoot: ref.hqRoot,
|
|
80
243
|
remoteOwnerSlug,
|
|
81
|
-
repoIdentityKey,
|
|
82
244
|
});
|
|
83
245
|
return resolution.status === "resolved";
|
|
84
246
|
}
|
|
85
247
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
248
|
+
* A backfill is only useful when the daemon can observe a durable company
|
|
249
|
+
* binding on its next held retry. Do not use the reconcile result alone here:
|
|
250
|
+
* a completed reconcile may legitimately return needs_company after a failed
|
|
251
|
+
* membership lookup, and that leaves the held events exactly where they were.
|
|
252
|
+
*/
|
|
253
|
+
function hasDurableCompanyRecovery(sessionId, workContextRoot, readState) {
|
|
254
|
+
return Boolean(readState(sessionId, workContextRoot)?.companyUid);
|
|
255
|
+
}
|
|
256
|
+
function latestAcknowledgedRegistration(sessionId, operations) {
|
|
257
|
+
for (let index = operations.length - 1; index >= 0; index -= 1) {
|
|
258
|
+
const operation = operations[index];
|
|
259
|
+
if (operation.kind === "register" &&
|
|
260
|
+
operation.delivery === "acked" &&
|
|
261
|
+
operation.sessionId === sessionId &&
|
|
262
|
+
operation.companyUid &&
|
|
263
|
+
operation.receiptId) {
|
|
264
|
+
return operation;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
function acknowledgedRegistrationMatches(operation, ref, evidence, state) {
|
|
270
|
+
if (!operation.companyUid || !operation.receiptId)
|
|
271
|
+
return false;
|
|
272
|
+
if (!state?.startedAt)
|
|
273
|
+
return false;
|
|
274
|
+
const startedAt = Date.parse(state.startedAt);
|
|
275
|
+
const registeredAt = Date.parse(operation.createdAt);
|
|
276
|
+
if (!Number.isFinite(startedAt) || !Number.isFinite(registeredAt) || registeredAt < startedAt) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (evidence &&
|
|
280
|
+
!candidatesAgree(evidence, {
|
|
281
|
+
slug: operation.companySlug,
|
|
282
|
+
uid: operation.companyUid,
|
|
283
|
+
}))
|
|
284
|
+
return false;
|
|
285
|
+
if (ref.harness && operation.harness && ref.harness !== operation.harness)
|
|
286
|
+
return false;
|
|
287
|
+
if (state?.bindingEpisodeId && state.bindingEpisodeId !== operation.receiptId)
|
|
288
|
+
return false;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
/** Shared doctor/backfill gate for a receipt that is safe to treat as current evidence. */
|
|
292
|
+
export function hasCurrentAcknowledgedRegistration(ref, state, operations, hqRoot, hqRoots = []) {
|
|
293
|
+
const evidence = consistentCandidate(localCompanyCandidates(ref, state, hqRoot, hqRoots));
|
|
294
|
+
if (evidence.conflict)
|
|
295
|
+
return false;
|
|
296
|
+
const operation = latestAcknowledgedRegistration(ref.sessionId, operations);
|
|
297
|
+
return Boolean(operation && acknowledgedRegistrationMatches(operation, ref, evidence.candidate, state));
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Read held.jsonl and return distinct sessions. Later events can add local
|
|
301
|
+
* evidence absent from the first event (the original first-occurrence-only
|
|
302
|
+
* implementation missed exactly that shape after a session became bound).
|
|
88
303
|
* Lines that fail to parse or carry no sessionId are ignored (the daemon owns
|
|
89
304
|
* their disposition; this backfill never mutates or deletes held lines).
|
|
90
305
|
*/
|
|
@@ -113,37 +328,47 @@ export function readHeldSessions(workMeshRoot) {
|
|
|
113
328
|
continue;
|
|
114
329
|
const obj = parsed;
|
|
115
330
|
// Peel the held envelope { event, heldReason, heldAt } if present.
|
|
116
|
-
const
|
|
331
|
+
const envelope = obj.event &&
|
|
117
332
|
typeof obj.event === "object" &&
|
|
118
333
|
!Array.isArray(obj.event) &&
|
|
119
334
|
typeof obj.heldReason === "string"
|
|
120
|
-
? obj
|
|
121
|
-
:
|
|
335
|
+
? obj
|
|
336
|
+
: undefined;
|
|
337
|
+
const event = envelope ? envelope.event : obj;
|
|
122
338
|
const sessionId = typeof event.sessionId === "string" && event.sessionId.trim()
|
|
123
339
|
? event.sessionId.trim()
|
|
124
340
|
: null;
|
|
125
|
-
if (!sessionId
|
|
341
|
+
if (!sessionId)
|
|
126
342
|
continue;
|
|
127
343
|
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
128
|
-
const ref = { sessionId };
|
|
344
|
+
const ref = seen.get(sessionId) ?? { sessionId };
|
|
129
345
|
const harness = str(event.harness);
|
|
130
|
-
if (harness)
|
|
346
|
+
if (harness && !ref.harness)
|
|
131
347
|
ref.harness = harness;
|
|
132
348
|
const cwd = str(event.cwd);
|
|
133
|
-
if (cwd)
|
|
349
|
+
if (cwd && !ref.cwd)
|
|
134
350
|
ref.cwd = cwd;
|
|
135
351
|
const hqRoot = str(event.hqRoot);
|
|
136
|
-
if (hqRoot)
|
|
137
|
-
ref.hqRoot
|
|
352
|
+
if (hqRoot) {
|
|
353
|
+
if (!ref.hqRoot)
|
|
354
|
+
ref.hqRoot = hqRoot;
|
|
355
|
+
if (!ref.hqRoots?.includes(hqRoot)) {
|
|
356
|
+
ref.hqRoots = [...(ref.hqRoots ?? []), hqRoot];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
138
359
|
const companySlug = str(event.companySlug);
|
|
139
|
-
if (companySlug)
|
|
360
|
+
if (companySlug && !ref.companySlug)
|
|
140
361
|
ref.companySlug = companySlug;
|
|
141
362
|
const project = str(event.project);
|
|
142
|
-
if (project)
|
|
363
|
+
if (project && !ref.project)
|
|
143
364
|
ref.project = project;
|
|
144
365
|
const task = str(event.task);
|
|
145
|
-
if (task)
|
|
366
|
+
if (task && !ref.task)
|
|
146
367
|
ref.task = task;
|
|
368
|
+
const at = str(event.at) ?? (envelope ? str(envelope.heldAt) : undefined);
|
|
369
|
+
if (hqRoot || cwd) {
|
|
370
|
+
ref.eventEvidence = [...(ref.eventEvidence ?? []), { hqRoot, cwd, harness, at }];
|
|
371
|
+
}
|
|
147
372
|
seen.set(sessionId, ref);
|
|
148
373
|
}
|
|
149
374
|
return [...seen.values()];
|
|
@@ -162,13 +387,18 @@ export async function backfillHeldSessions(deps) {
|
|
|
162
387
|
const predictCompany = deps.predictCompany ??
|
|
163
388
|
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
|
|
164
389
|
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
165
|
-
|
|
390
|
+
// An acked register is authoritative local evidence. Older clients could
|
|
391
|
+
// lose its state projection on a later hook write; recover that projection
|
|
392
|
+
// directly instead of re-registering the same session.
|
|
393
|
+
const outboxOperations = listOutboxOperations(deps.workContextRoot);
|
|
394
|
+
const considered = rotateSessions(sessions, limit, (deps.readCursor ?? (() => readBackfillCursor(deps.workMeshRoot)))());
|
|
166
395
|
const result = {
|
|
167
396
|
scanned: sessions.length,
|
|
168
397
|
considered: considered.length,
|
|
169
398
|
alreadyAttributed: 0,
|
|
170
399
|
reconciled: 0,
|
|
171
400
|
unresolved: 0,
|
|
401
|
+
conflictingEvidence: 0,
|
|
172
402
|
errors: 0,
|
|
173
403
|
dryRun,
|
|
174
404
|
};
|
|
@@ -179,10 +409,49 @@ export async function backfillHeldSessions(deps) {
|
|
|
179
409
|
result.alreadyAttributed += 1;
|
|
180
410
|
continue;
|
|
181
411
|
}
|
|
412
|
+
const evidence = consistentCandidate(localCompanyCandidates(ref, state, deps.hqRoot, deps.hqRoots));
|
|
413
|
+
if (evidence.conflict) {
|
|
414
|
+
result.unresolved += 1;
|
|
415
|
+
result.conflictingEvidence += 1;
|
|
416
|
+
deps.log?.(`unresolved ${sessionId} (CONFLICTING_EVIDENCE)`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const acknowledged = latestAcknowledgedRegistration(sessionId, outboxOperations);
|
|
420
|
+
const acknowledgedMatches = acknowledged && acknowledgedRegistrationMatches(acknowledged, ref, evidence.candidate, state);
|
|
421
|
+
if (acknowledged && !acknowledgedMatches) {
|
|
422
|
+
deps.log?.(`ignored stale acknowledged register for ${sessionId}`);
|
|
423
|
+
}
|
|
424
|
+
if (acknowledgedMatches && dryRun) {
|
|
425
|
+
result.reconciled += 1;
|
|
426
|
+
deps.log?.(`would reconcile ${sessionId} from acknowledged register`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (acknowledgedMatches) {
|
|
430
|
+
writeAckedRegisterState(acknowledged, deps.workContextRoot, acknowledged.receiptId);
|
|
431
|
+
if (readState(sessionId, deps.workContextRoot)?.companyUid) {
|
|
432
|
+
result.reconciled += 1;
|
|
433
|
+
deps.log?.(`reconciled ${sessionId} from acknowledged register`);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (!evidence.candidate?.slug) {
|
|
438
|
+
result.unresolved += 1;
|
|
439
|
+
deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const evidenceRef = {
|
|
443
|
+
sessionId,
|
|
444
|
+
harness,
|
|
445
|
+
companySlug: evidence.candidate.slug,
|
|
446
|
+
// Held-event fields are transport input, not trusted context. Preserve
|
|
447
|
+
// project/task only when the durable session state already owns them.
|
|
448
|
+
project: state?.projectId,
|
|
449
|
+
task: state?.taskId,
|
|
450
|
+
};
|
|
182
451
|
if (dryRun) {
|
|
183
452
|
// Predict with the SAME evidence + resolver the write path uses, so the
|
|
184
453
|
// dry-run counts match a real run (no blanket would-reconcile).
|
|
185
|
-
if (predictCompany(
|
|
454
|
+
if (predictCompany(evidenceRef)) {
|
|
186
455
|
result.reconciled += 1;
|
|
187
456
|
deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
|
|
188
457
|
}
|
|
@@ -192,16 +461,20 @@ export async function backfillHeldSessions(deps) {
|
|
|
192
461
|
}
|
|
193
462
|
continue;
|
|
194
463
|
}
|
|
195
|
-
const obs = observationFromHeldRef(
|
|
464
|
+
const obs = observationFromHeldRef(evidenceRef, contractVersion, newOperationId());
|
|
196
465
|
try {
|
|
197
466
|
const outcome = await deps.reconcile(obs);
|
|
198
|
-
if (
|
|
467
|
+
if (hasDurableCompanyRecovery(sessionId, deps.workContextRoot, readState)) {
|
|
199
468
|
result.reconciled += 1;
|
|
200
|
-
deps.log?.(`reconciled ${sessionId} →
|
|
469
|
+
deps.log?.(`reconciled ${sessionId} → durable company binding`);
|
|
470
|
+
}
|
|
471
|
+
else if (outcome.result.errorCode === "NOT_TRACKING") {
|
|
472
|
+
result.errors += 1;
|
|
473
|
+
deps.log?.(`error ${sessionId}: NOT_TRACKING (state was not durable)`);
|
|
201
474
|
}
|
|
202
475
|
else {
|
|
203
476
|
result.unresolved += 1;
|
|
204
|
-
deps.log?.(`unresolved ${sessionId} (no company)`);
|
|
477
|
+
deps.log?.(`unresolved ${sessionId} (${outcome.result.kind}: no durable company binding)`);
|
|
205
478
|
}
|
|
206
479
|
}
|
|
207
480
|
catch (err) {
|
|
@@ -209,6 +482,14 @@ export async function backfillHeldSessions(deps) {
|
|
|
209
482
|
deps.log?.(`error ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
210
483
|
}
|
|
211
484
|
}
|
|
485
|
+
if (!dryRun && limit > 0 && considered.length > 0) {
|
|
486
|
+
const last = considered[considered.length - 1];
|
|
487
|
+
const ordinal = sessions.findIndex((ref) => ref.sessionId === last.sessionId);
|
|
488
|
+
(deps.writeCursor ?? ((cursor) => writeBackfillCursor(deps.workMeshRoot, cursor)))({
|
|
489
|
+
sessionId: last.sessionId,
|
|
490
|
+
...(ordinal >= 0 ? { ordinal } : {}),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
212
493
|
return result;
|
|
213
494
|
}
|
|
214
495
|
//# sourceMappingURL=backfill-held.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface DaemonConfig {
|
|
2
|
+
trustedHqRoots: string[];
|
|
3
|
+
}
|
|
4
|
+
export declare function daemonConfigPath(daemonDirectory: string): string;
|
|
5
|
+
export declare function verifyHqRoot(candidate: string | undefined): string | null;
|
|
6
|
+
export declare function readDaemonConfig(daemonDirectory: string): DaemonConfig;
|
|
7
|
+
/** Record a verified local HQ checkout without trusting a caller-supplied path at runtime. */
|
|
8
|
+
export declare function registerTrustedHqRoot(daemonDirectory: string, candidate: string | undefined): DaemonConfig;
|
|
9
|
+
//# sourceMappingURL=config.d.ts.map
|