@indigoai-us/hq-cloud 6.11.14 → 6.11.16
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/.github/workflows/ci.yml +1 -0
- package/.github/workflows/publish.yml +20 -64
- package/dist/agent-codex-instructions.d.ts +84 -0
- package/dist/agent-codex-instructions.d.ts.map +1 -0
- package/dist/agent-codex-instructions.js +275 -0
- package/dist/agent-codex-instructions.js.map +1 -0
- package/dist/agent-codex-instructions.test.d.ts +2 -0
- package/dist/agent-codex-instructions.test.d.ts.map +1 -0
- package/dist/agent-codex-instructions.test.js +271 -0
- package/dist/agent-codex-instructions.test.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +11 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +19 -2
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-telemetry.d.ts +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -1
- package/dist/bin/sync-runner-telemetry.js +8 -1
- package/dist/bin/sync-runner-telemetry.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +20 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +154 -9
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +328 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.js +13 -9
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +24 -12
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +16 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/context.d.ts +8 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +16 -6
- package/dist/context.js.map +1 -1
- package/dist/qmd-reindex.d.ts +7 -0
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +12 -1
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/skill-telemetry.d.ts +15 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +34 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +75 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +16 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +21 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +3 -1
- package/src/agent-codex-instructions.test.ts +332 -0
- package/src/agent-codex-instructions.ts +309 -0
- package/src/bin/sync-runner-planning.ts +34 -2
- package/src/bin/sync-runner-telemetry.ts +8 -1
- package/src/bin/sync-runner.test.ts +413 -0
- package/src/bin/sync-runner.ts +184 -7
- package/src/cli/share.ts +15 -9
- package/src/cli/sync.test.ts +17 -0
- package/src/cli/sync.ts +33 -12
- package/src/context.ts +17 -6
- package/src/qmd-reindex.ts +18 -1
- package/src/skill-telemetry.test.ts +94 -0
- package/src/skill-telemetry.ts +51 -3
- package/src/vault-client.ts +24 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -106,7 +106,9 @@ import { collectAndSendTelemetry } from "../telemetry.js";
|
|
|
106
106
|
import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
|
|
107
107
|
import { reindexAfterSync } from "../qmd-reindex.js";
|
|
108
108
|
import { pruneConflictIndex } from "../lib/conflict-index.js";
|
|
109
|
+
import { materializeCodexAgents } from "../agent-codex-instructions.js";
|
|
109
110
|
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
111
|
+
import { describeError } from "../lib/describe-error.js";
|
|
110
112
|
import type { Clock, TreeChangeBatch } from "../watcher.js";
|
|
111
113
|
import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
|
|
112
114
|
import {
|
|
@@ -404,6 +406,53 @@ export interface VaultClientSurface {
|
|
|
404
406
|
listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
|
|
405
407
|
}
|
|
406
408
|
|
|
409
|
+
interface RunnerDiagnostic {
|
|
410
|
+
component: string;
|
|
411
|
+
event: string;
|
|
412
|
+
message: string;
|
|
413
|
+
err?: unknown;
|
|
414
|
+
context?: Record<string, unknown>;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
type RunnerDiagnosticReporter = (diagnostic: RunnerDiagnostic) => void;
|
|
418
|
+
|
|
419
|
+
function errorDetails(err: unknown): Record<string, unknown> {
|
|
420
|
+
const details: Record<string, unknown> = { message: describeError(err) };
|
|
421
|
+
if (err instanceof Error) {
|
|
422
|
+
if (err.name) details.name = err.name;
|
|
423
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
424
|
+
if (code) details.code = code;
|
|
425
|
+
}
|
|
426
|
+
return details;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function createRunnerDiagnosticReporter(
|
|
430
|
+
stderr: { write: (chunk: string) => boolean | void },
|
|
431
|
+
): RunnerDiagnosticReporter {
|
|
432
|
+
return (diagnostic) => {
|
|
433
|
+
try {
|
|
434
|
+
const payload: Record<string, unknown> = {
|
|
435
|
+
type: "error",
|
|
436
|
+
diagnostic: true,
|
|
437
|
+
component: diagnostic.component,
|
|
438
|
+
event: diagnostic.event,
|
|
439
|
+
path: `(${diagnostic.component})`,
|
|
440
|
+
message:
|
|
441
|
+
diagnostic.err === undefined
|
|
442
|
+
? diagnostic.message
|
|
443
|
+
: `${diagnostic.message}: ${describeError(diagnostic.err)}`,
|
|
444
|
+
...(diagnostic.context ?? {}),
|
|
445
|
+
};
|
|
446
|
+
if (diagnostic.err !== undefined) {
|
|
447
|
+
payload.err = errorDetails(diagnostic.err);
|
|
448
|
+
}
|
|
449
|
+
stderr.write(`${JSON.stringify(payload)}\n`);
|
|
450
|
+
} catch {
|
|
451
|
+
/* diagnostics must never affect runner control flow */
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
407
456
|
// `resolvePullScope`, `readPinnedPrefixes`, and the `PullScope` type now live
|
|
408
457
|
// in `../sync/pull-scope.ts` so the menubar runner and `hq sync pull|now`
|
|
409
458
|
// (hq-cli) share ONE scope resolver — the drift between them was the root
|
|
@@ -459,6 +508,17 @@ interface IdTokenClaims {
|
|
|
459
508
|
name?: string;
|
|
460
509
|
given_name?: string;
|
|
461
510
|
family_name?: string;
|
|
511
|
+
/**
|
|
512
|
+
* Entity-bound machine-identity claims. A headless agent box authenticates
|
|
513
|
+
* with machine creds (username `machine-agt_*`); its idToken carries the
|
|
514
|
+
* agent's own entity binding here. The vault authorizer reads these to
|
|
515
|
+
* resolve the caller AS the agent. The runner reads them to drive
|
|
516
|
+
* agent-aware `--personal` target resolution: when `custom:entityType ===
|
|
517
|
+
* "agent"`, the personal slot resolves to the agent's OWN entity
|
|
518
|
+
* (`custom:entityUid`, `agt_*`) instead of the person-only canonical pick.
|
|
519
|
+
*/
|
|
520
|
+
"custom:entityType"?: string;
|
|
521
|
+
"custom:entityUid"?: string;
|
|
462
522
|
}
|
|
463
523
|
|
|
464
524
|
export interface RunnerDeps {
|
|
@@ -769,6 +829,7 @@ export async function defaultCollectTelemetry(
|
|
|
769
829
|
client: VaultClientSurface,
|
|
770
830
|
clientIsStub: boolean,
|
|
771
831
|
hqRoot: string,
|
|
832
|
+
reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
|
|
772
833
|
): Promise<void> {
|
|
773
834
|
if (clientIsStub) return;
|
|
774
835
|
|
|
@@ -797,7 +858,14 @@ export async function defaultCollectTelemetry(
|
|
|
797
858
|
try {
|
|
798
859
|
const persons = await client.entity.listByType("person");
|
|
799
860
|
if (pickCanonicalPersonEntity(persons) === null) return;
|
|
800
|
-
} catch {
|
|
861
|
+
} catch (err) {
|
|
862
|
+
reportDiagnostic({
|
|
863
|
+
component: "telemetry",
|
|
864
|
+
event: "runner.telemetry.person_probe_failed",
|
|
865
|
+
message: "telemetry person probe failed; continuing with telemetry",
|
|
866
|
+
err,
|
|
867
|
+
context: { hqRoot },
|
|
868
|
+
});
|
|
801
869
|
// Probe failed — preserve prior behavior and let telemetry run.
|
|
802
870
|
}
|
|
803
871
|
|
|
@@ -820,7 +888,14 @@ export async function defaultCollectTelemetry(
|
|
|
820
888
|
try {
|
|
821
889
|
machineId = getOrCreateMachineId(hqRoot);
|
|
822
890
|
installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
|
|
823
|
-
} catch {
|
|
891
|
+
} catch (err) {
|
|
892
|
+
reportDiagnostic({
|
|
893
|
+
component: "telemetry",
|
|
894
|
+
event: "runner.telemetry.identity_failed",
|
|
895
|
+
message: "telemetry identity resolution failed; skipping telemetry",
|
|
896
|
+
err,
|
|
897
|
+
context: { hqRoot },
|
|
898
|
+
});
|
|
824
899
|
return;
|
|
825
900
|
}
|
|
826
901
|
|
|
@@ -836,8 +911,22 @@ export async function defaultCollectTelemetry(
|
|
|
836
911
|
// manifest.yaml and stamp companyUid (US-002). Unmatched cwds stay
|
|
837
912
|
// unattributed (field omitted).
|
|
838
913
|
hqRoot,
|
|
914
|
+
log: (message) =>
|
|
915
|
+
reportDiagnostic({
|
|
916
|
+
component: "telemetry",
|
|
917
|
+
event: "runner.telemetry.usage_nonfatal",
|
|
918
|
+
message,
|
|
919
|
+
context: { hqRoot },
|
|
920
|
+
}),
|
|
921
|
+
});
|
|
922
|
+
} catch (err) {
|
|
923
|
+
reportDiagnostic({
|
|
924
|
+
component: "telemetry",
|
|
925
|
+
event: "runner.telemetry.usage_failed",
|
|
926
|
+
message: "usage telemetry collector threw",
|
|
927
|
+
err,
|
|
928
|
+
context: { hqRoot },
|
|
839
929
|
});
|
|
840
|
-
} catch {
|
|
841
930
|
// Fire-and-forget; nothing escapes the boundary.
|
|
842
931
|
}
|
|
843
932
|
|
|
@@ -851,8 +940,22 @@ export async function defaultCollectTelemetry(
|
|
|
851
940
|
// Scope skill capture to the HQ project — only invocations whose cwd is
|
|
852
941
|
// the HQ root are emitted; skill usage in unrelated repos is excluded.
|
|
853
942
|
hqRoot,
|
|
943
|
+
log: (message) =>
|
|
944
|
+
reportDiagnostic({
|
|
945
|
+
component: "telemetry",
|
|
946
|
+
event: "runner.telemetry.skill_nonfatal",
|
|
947
|
+
message,
|
|
948
|
+
context: { hqRoot },
|
|
949
|
+
}),
|
|
950
|
+
});
|
|
951
|
+
} catch (err) {
|
|
952
|
+
reportDiagnostic({
|
|
953
|
+
component: "telemetry",
|
|
954
|
+
event: "runner.telemetry.skill_failed",
|
|
955
|
+
message: "skill telemetry collector threw",
|
|
956
|
+
err,
|
|
957
|
+
context: { hqRoot },
|
|
854
958
|
});
|
|
855
|
-
} catch {
|
|
856
959
|
// Fire-and-forget; nothing escapes the boundary.
|
|
857
960
|
}
|
|
858
961
|
}
|
|
@@ -867,6 +970,7 @@ export async function runRunner(
|
|
|
867
970
|
): Promise<number> {
|
|
868
971
|
const stdout = deps.stdout ?? process.stdout;
|
|
869
972
|
const stderr = deps.stderr ?? process.stderr;
|
|
973
|
+
const reportDiagnostic = createRunnerDiagnosticReporter(stderr);
|
|
870
974
|
|
|
871
975
|
// ---- emit ---------------------------------------------------------------
|
|
872
976
|
// Error-class events go to stderr; everything else to stdout.
|
|
@@ -1022,6 +1126,7 @@ export async function runRunner(
|
|
|
1022
1126
|
personal: parsed.personal,
|
|
1023
1127
|
skipPersonal: parsed.skipPersonal,
|
|
1024
1128
|
client,
|
|
1129
|
+
claims,
|
|
1025
1130
|
resolveSkipPersonal,
|
|
1026
1131
|
});
|
|
1027
1132
|
if (targetPlan.status === "setup-needed") {
|
|
@@ -1068,7 +1173,16 @@ export async function runRunner(
|
|
|
1068
1173
|
client,
|
|
1069
1174
|
deps.createVaultClient !== undefined,
|
|
1070
1175
|
parsed.hqRoot,
|
|
1176
|
+
reportDiagnostic,
|
|
1071
1177
|
),
|
|
1178
|
+
onError: (err) =>
|
|
1179
|
+
reportDiagnostic({
|
|
1180
|
+
component: "telemetry",
|
|
1181
|
+
event: "runner.telemetry.collector_failed",
|
|
1182
|
+
message: "telemetry collector failed",
|
|
1183
|
+
err,
|
|
1184
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1185
|
+
}),
|
|
1072
1186
|
});
|
|
1073
1187
|
|
|
1074
1188
|
emit({
|
|
@@ -1095,8 +1209,24 @@ export async function runRunner(
|
|
|
1095
1209
|
process.env.HQ_QMD_REINDEX_ON_SYNC !== "0"
|
|
1096
1210
|
) {
|
|
1097
1211
|
try {
|
|
1098
|
-
reindexAfterSync(parsed.hqRoot
|
|
1099
|
-
|
|
1212
|
+
reindexAfterSync(parsed.hqRoot, {
|
|
1213
|
+
log: ({ event, message, err, context }) =>
|
|
1214
|
+
reportDiagnostic({
|
|
1215
|
+
component: "qmd-reindex",
|
|
1216
|
+
event,
|
|
1217
|
+
message,
|
|
1218
|
+
err,
|
|
1219
|
+
context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
|
|
1220
|
+
}),
|
|
1221
|
+
});
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
reportDiagnostic({
|
|
1224
|
+
component: "qmd-reindex",
|
|
1225
|
+
event: "runner.qmd_reindex.failed",
|
|
1226
|
+
message: "post-sync qmd reindex threw",
|
|
1227
|
+
err,
|
|
1228
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1229
|
+
});
|
|
1100
1230
|
// Defensive: reindexAfterSync already swallows internally.
|
|
1101
1231
|
}
|
|
1102
1232
|
}
|
|
@@ -1113,11 +1243,58 @@ export async function runRunner(
|
|
|
1113
1243
|
if (process.env.HQ_CONFLICT_PRUNE_ON_SYNC !== "0") {
|
|
1114
1244
|
try {
|
|
1115
1245
|
pruneConflictIndex(parsed.hqRoot);
|
|
1116
|
-
} catch {
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
reportDiagnostic({
|
|
1248
|
+
component: "conflict-index",
|
|
1249
|
+
event: "runner.conflict_index.prune_failed",
|
|
1250
|
+
message: "post-sync conflict index prune failed",
|
|
1251
|
+
err,
|
|
1252
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1253
|
+
});
|
|
1117
1254
|
// Defensive: a prune failure must never affect the sync outcome.
|
|
1118
1255
|
}
|
|
1119
1256
|
}
|
|
1120
1257
|
|
|
1258
|
+
// Post-sync agent codex-instructions materialization — best-effort tail step
|
|
1259
|
+
// (runs AFTER `all-complete`, never affects the exit code). For an AGENT box
|
|
1260
|
+
// ONLY (`custom:entityType === "agent"`), project the synced personal/ agent
|
|
1261
|
+
// overlay (agents-profile.md + agent-capabilities/*.md) into the codex GLOBAL
|
|
1262
|
+
// instruction file ~/.codex/AGENTS.md, which `codex exec` natively merges on
|
|
1263
|
+
// every run. This is the codex mirror of Claude's ~/.claude/CLAUDE.md:
|
|
1264
|
+
// DURABLE (outside the hq-root, so `hq rescue` never touches it) and
|
|
1265
|
+
// fleet-SCALABLE (every box runs @latest each cycle). A human running
|
|
1266
|
+
// `hq sync` (person identity) never triggers it. Opt-out: set
|
|
1267
|
+
// HQ_AGENT_CODEX_MATERIALIZE=0. The write is idempotent and never destructive
|
|
1268
|
+
// (skips when the overlay is empty), and never throws (mirrors the qmd /
|
|
1269
|
+
// conflict-prune tail steps above).
|
|
1270
|
+
if (
|
|
1271
|
+
claims?.["custom:entityType"] === "agent" &&
|
|
1272
|
+
process.env.HQ_AGENT_CODEX_MATERIALIZE !== "0"
|
|
1273
|
+
) {
|
|
1274
|
+
try {
|
|
1275
|
+
materializeCodexAgents({
|
|
1276
|
+
hqRoot: parsed.hqRoot,
|
|
1277
|
+
log: ({ event, message, err, context }) =>
|
|
1278
|
+
reportDiagnostic({
|
|
1279
|
+
component: "agent-codex-instructions",
|
|
1280
|
+
event,
|
|
1281
|
+
message,
|
|
1282
|
+
err,
|
|
1283
|
+
context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
|
|
1284
|
+
}),
|
|
1285
|
+
});
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
reportDiagnostic({
|
|
1288
|
+
component: "agent-codex-instructions",
|
|
1289
|
+
event: "runner.agent_codex_instructions.failed",
|
|
1290
|
+
message: "post-sync agent codex-instructions materialization threw",
|
|
1291
|
+
err,
|
|
1292
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1293
|
+
});
|
|
1294
|
+
// Defensive: materializeCodexAgents already swallows internally.
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1121
1298
|
// Exit 2 only when something actually threw (`errors.length > 0`). A clean
|
|
1122
1299
|
// conflict-abort sets `partial: true` in the JSON but exits 0 — the Tauri
|
|
1123
1300
|
// menubar's non-zero-exit Sentry capture would otherwise fire for normal
|
package/src/cli/share.ts
CHANGED
|
@@ -888,15 +888,6 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
888
888
|
let propagateDeletePolicy: DeletePolicy =
|
|
889
889
|
options.propagateDeletePolicy ?? "owned-only";
|
|
890
890
|
const baseEmit = options.onEvent ?? defaultConsoleLogger;
|
|
891
|
-
// Mirror push progress into the shared cross-process snapshot (see sync.ts).
|
|
892
|
-
const recordProgress = createSyncProgressRecorder({
|
|
893
|
-
company: company ?? null,
|
|
894
|
-
phase: "push",
|
|
895
|
-
});
|
|
896
|
-
const emit: ShareEmit = (event) => {
|
|
897
|
-
baseEmit(event);
|
|
898
|
-
recordProgress(event);
|
|
899
|
-
};
|
|
900
891
|
|
|
901
892
|
if (vaultConfig && entityContext) {
|
|
902
893
|
throw new Error(
|
|
@@ -928,6 +919,21 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
|
|
|
928
919
|
propagateDeletePolicy = "currency-gated";
|
|
929
920
|
}
|
|
930
921
|
|
|
922
|
+
// Mirror push progress into the shared cross-process snapshot (see sync.ts).
|
|
923
|
+
// Record the friendly company slug, or null for the personal vault so the
|
|
924
|
+
// menubar shows "Personal" — never the raw prs_/cmp_ UID.
|
|
925
|
+
const recordProgress = createSyncProgressRecorder({
|
|
926
|
+
company:
|
|
927
|
+
ctx.uid.startsWith("prs_") || options.personalMode === true
|
|
928
|
+
? null
|
|
929
|
+
: ctx.slug,
|
|
930
|
+
phase: "push",
|
|
931
|
+
});
|
|
932
|
+
const emit: ShareEmit = (event) => {
|
|
933
|
+
baseEmit(event);
|
|
934
|
+
recordProgress(event);
|
|
935
|
+
};
|
|
936
|
+
|
|
931
937
|
const syncRoot = options.personalMode === true
|
|
932
938
|
? hqRoot
|
|
933
939
|
: path.join(hqRoot, "companies", ctx.slug);
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -46,6 +46,7 @@ vi.mock("./reindex.js", () => ({
|
|
|
46
46
|
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
47
47
|
import * as s3Module from "../s3.js";
|
|
48
48
|
import { reindex } from "./reindex.js";
|
|
49
|
+
import { readSyncProgress } from "../sync-progress.js";
|
|
49
50
|
|
|
50
51
|
const mockConfig: VaultServiceConfig = {
|
|
51
52
|
apiUrl: "https://vault-api.test",
|
|
@@ -144,6 +145,22 @@ describe("sync", () => {
|
|
|
144
145
|
expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
it("records the company slug, not the input UID, in the shared progress snapshot", async () => {
|
|
149
|
+
// sync() is called with the company UID, but the menubar popover must show
|
|
150
|
+
// the friendly slug — so the producer records ctx.slug, never the raw UID.
|
|
151
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
152
|
+
{ key: "docs/x.md", size: 10, lastModified: new Date(), etag: '"x"' },
|
|
153
|
+
]);
|
|
154
|
+
await sync({
|
|
155
|
+
company: "cmp_01ABCDEF",
|
|
156
|
+
vaultConfig: mockConfig,
|
|
157
|
+
hqRoot: tmpDir,
|
|
158
|
+
});
|
|
159
|
+
const snap = readSyncProgress();
|
|
160
|
+
expect(snap).not.toBeNull();
|
|
161
|
+
expect(snap!.company).toBe("acme"); // ctx.slug, not "cmp_01ABCDEF"
|
|
162
|
+
});
|
|
163
|
+
|
|
147
164
|
it("F15: public sync entrypoint refuses an already-held operation lock", async () => {
|
|
148
165
|
process.env.HQ_OP_LOCK_TIMEOUT = "0";
|
|
149
166
|
const lockPath = lockPathFor(tmpDir);
|
package/src/cli/sync.ts
CHANGED
|
@@ -658,6 +658,23 @@ function logNotifyFailure(err: unknown): void {
|
|
|
658
658
|
}
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
/** Log a non-fatal post-sync maintenance failure without changing sync state. */
|
|
662
|
+
function logPostSyncMaintenanceFailure(
|
|
663
|
+
task: string,
|
|
664
|
+
hqRoot: string,
|
|
665
|
+
err: unknown,
|
|
666
|
+
): void {
|
|
667
|
+
try {
|
|
668
|
+
console.error(
|
|
669
|
+
`[hq-sync] ${task} failed (non-fatal; hqRoot=${hqRoot}): ${
|
|
670
|
+
err instanceof Error ? err.message : String(err)
|
|
671
|
+
}`,
|
|
672
|
+
);
|
|
673
|
+
} catch {
|
|
674
|
+
// swallow — logging must never break sync
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
661
678
|
/**
|
|
662
679
|
* Sync (pull) all allowed files from the entity vault.
|
|
663
680
|
*/
|
|
@@ -710,17 +727,6 @@ async function syncWithOperationLockHeld(
|
|
|
710
727
|
async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
711
728
|
const { company, vaultConfig, hqRoot } = options;
|
|
712
729
|
const baseEmit = options.onEvent ?? defaultConsoleLogger;
|
|
713
|
-
// Mirror every progress event into the shared cross-process snapshot so the
|
|
714
|
-
// menubar shows live progress for THIS sync regardless of who launched it
|
|
715
|
-
// (auto-sync / Sync Now / CLI). Best-effort; never affects the sync.
|
|
716
|
-
const recordProgress = createSyncProgressRecorder({
|
|
717
|
-
company: company ?? null,
|
|
718
|
-
phase: "pull",
|
|
719
|
-
});
|
|
720
|
-
const emit: SyncEventEmitter = (event) => {
|
|
721
|
-
baseEmit(event);
|
|
722
|
-
recordProgress(event);
|
|
723
|
-
};
|
|
724
730
|
|
|
725
731
|
const companyRef = company ?? resolveActiveCompany(hqRoot);
|
|
726
732
|
if (!companyRef) {
|
|
@@ -738,6 +744,20 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
|
|
|
738
744
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
739
745
|
const startedAt = new Date().toISOString();
|
|
740
746
|
|
|
747
|
+
// Mirror every progress event into the shared cross-process snapshot so the
|
|
748
|
+
// menubar shows live progress for THIS sync regardless of who launched it
|
|
749
|
+
// (auto-sync / Sync Now / CLI). Record the friendly company slug (e.g.
|
|
750
|
+
// "indigo") — or null for the personal vault so the menubar shows "Personal"
|
|
751
|
+
// — never the raw prs_/cmp_ UID. Best-effort; never affects the sync.
|
|
752
|
+
const recordProgress = createSyncProgressRecorder({
|
|
753
|
+
company: options.personalMode === true ? null : ctx.slug,
|
|
754
|
+
phase: "pull",
|
|
755
|
+
});
|
|
756
|
+
const emit: SyncEventEmitter = (event) => {
|
|
757
|
+
baseEmit(event);
|
|
758
|
+
recordProgress(event);
|
|
759
|
+
};
|
|
760
|
+
|
|
741
761
|
if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
|
|
742
762
|
const journal = migrateToV2(readJournal(journalSlug));
|
|
743
763
|
gcTombstones(journal, Date.now());
|
|
@@ -1466,7 +1486,8 @@ function finalizePullRun(
|
|
|
1466
1486
|
if (!run.options.skipReindex && changedOnDisk) {
|
|
1467
1487
|
try {
|
|
1468
1488
|
reindex({ repoRoot: run.hqRoot, skipLock: true });
|
|
1469
|
-
} catch {
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
|
|
1470
1491
|
// best-effort: a post-sync refresh failure never fails the sync
|
|
1471
1492
|
}
|
|
1472
1493
|
}
|
package/src/context.ts
CHANGED
|
@@ -76,9 +76,15 @@ function slugCacheKey(callerKey: string, slug: string): string {
|
|
|
76
76
|
/**
|
|
77
77
|
* Closed-set of recognised entity-UID prefixes. Adding a new entity type
|
|
78
78
|
* means appending one entry here AND extending the dispatch in
|
|
79
|
-
* `resolveEntityContext` (cmp_ → /sts/vend, prs_ → /sts/vend-self).
|
|
79
|
+
* `resolveEntityContext` (cmp_ → /sts/vend, prs_/agt_ → /sts/vend-self).
|
|
80
|
+
*
|
|
81
|
+
* `agt_` (US-013, unblocks US-004): a headless agent box syncs its OWN
|
|
82
|
+
* personal overlay via `hq-sync-runner --personal`, which resolves the
|
|
83
|
+
* agent's own entity (`agt_*`, bucket `hq-vault-agt-<uid>`). Without `agt_`
|
|
84
|
+
* here an agt_ uid is mistaken for a SLUG and routed through the company
|
|
85
|
+
* by-slug lookup; with it, the uid is recognised and self-vended below.
|
|
80
86
|
*/
|
|
81
|
-
export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
|
|
87
|
+
export const KNOWN_UID_PREFIXES = ["cmp_", "prs_", "agt_"] as const;
|
|
82
88
|
|
|
83
89
|
/**
|
|
84
90
|
* Look up an entity by slug or UID via vault-service, then vend STS-scoped
|
|
@@ -103,7 +109,12 @@ export async function resolveEntityContext(
|
|
|
103
109
|
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
104
110
|
companyUidOrSlug.startsWith(p),
|
|
105
111
|
);
|
|
106
|
-
|
|
112
|
+
// Self-vend covers BOTH person (`prs_`) and agent (`agt_`) entities — each
|
|
113
|
+
// owns a personal vault gated by self-ownership at `POST /sts/vend-self`,
|
|
114
|
+
// not the membership-gated company `POST /sts/vend`. (US-013, unblocks
|
|
115
|
+
// US-004: an agent box self-vends its own `hq-vault-agt-<uid>` vault.)
|
|
116
|
+
const looksLikePerson =
|
|
117
|
+
companyUidOrSlug.startsWith("prs_") || companyUidOrSlug.startsWith("agt_");
|
|
107
118
|
|
|
108
119
|
// For slug lookups, scope the cache key by the caller. Resolving the
|
|
109
120
|
// access token here (once) doubles as a way to extract the sub for
|
|
@@ -137,9 +148,9 @@ export async function resolveEntityContext(
|
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
// Step 2: Dispatch credential vending by RESOLVED-uid prefix.
|
|
140
|
-
// cmp_*
|
|
141
|
-
// prs_* → POST /sts/vend-self (
|
|
142
|
-
// slug
|
|
151
|
+
// cmp_* → POST /sts/vend (company path; membership-gated)
|
|
152
|
+
// prs_* / agt_* → POST /sts/vend-self (self path; self-ownership-gated)
|
|
153
|
+
// slug → resolves to a cmp_* (the slug path is company-only)
|
|
143
154
|
//
|
|
144
155
|
// HQ-59 vend-skip: when the run uses the presigned-URL transport for company
|
|
145
156
|
// vaults, a `cmp_*` context needs no STS creds (the presign transport
|
package/src/qmd-reindex.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface ReindexOptions {
|
|
|
56
56
|
readCompanies?: (companiesDir: string) => string[];
|
|
57
57
|
/** Returns true if the knowledge dir has at least one indexable .md file. */
|
|
58
58
|
hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
|
|
59
|
+
/** Optional diagnostic sink for unexpected swallowed failures. */
|
|
60
|
+
log?: (diagnostic: {
|
|
61
|
+
event: string;
|
|
62
|
+
message: string;
|
|
63
|
+
err: unknown;
|
|
64
|
+
context?: Record<string, unknown>;
|
|
65
|
+
}) => void;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/**
|
|
@@ -110,7 +117,17 @@ export function reindexAfterSync(
|
|
|
110
117
|
const embed = exec(["embed"]);
|
|
111
118
|
result.embedded = embed.status === 0;
|
|
112
119
|
}
|
|
113
|
-
} catch {
|
|
120
|
+
} catch (err) {
|
|
121
|
+
try {
|
|
122
|
+
opts.log?.({
|
|
123
|
+
event: "runner.qmd_reindex.internal_failed",
|
|
124
|
+
message: "post-sync qmd reindex failed",
|
|
125
|
+
err,
|
|
126
|
+
context: { hqRoot },
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
/* diagnostics must never affect sync */
|
|
130
|
+
}
|
|
114
131
|
// Reindex is best-effort; the sync result is authoritative. Swallow.
|
|
115
132
|
}
|
|
116
133
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
codexRowTurnId,
|
|
10
10
|
parseCodexSessionMeta,
|
|
11
11
|
collectAndSendSkillTelemetry,
|
|
12
|
+
readFileRegion,
|
|
12
13
|
} from "./skill-telemetry.js";
|
|
13
14
|
import type { SkillInvocationBatch } from "./vault-client.js";
|
|
14
15
|
|
|
@@ -1157,3 +1158,96 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
|
|
|
1157
1158
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
1158
1159
|
});
|
|
1159
1160
|
});
|
|
1161
|
+
|
|
1162
|
+
describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", () => {
|
|
1163
|
+
const INT32_MAX = 2 ** 31 - 1;
|
|
1164
|
+
|
|
1165
|
+
it("never issues a single read whose length exceeds Int32, even for a multi-GiB region", async () => {
|
|
1166
|
+
// The original code did `fh.read(buf, 0, currentSize - offset, offset)` in
|
|
1167
|
+
// one shot. Once a session log's unread tail passed ~2 GiB, the length
|
|
1168
|
+
// argument was no longer an Int32, so Node's native fs.read asserted
|
|
1169
|
+
// (`args[3]->IsInt32()`) and SIGABRT'd the whole runner. Simulate a 3 GiB
|
|
1170
|
+
// region with a recording fake handle (no real allocation) and prove every
|
|
1171
|
+
// native read length stays within Int32.
|
|
1172
|
+
const THREE_GIB = 3 * 1024 * 1024 * 1024;
|
|
1173
|
+
const lengths: number[] = [];
|
|
1174
|
+
let cursor = 0;
|
|
1175
|
+
const fakeFh = {
|
|
1176
|
+
read: vi.fn(
|
|
1177
|
+
async (_buf: Buffer, _off: number, length: number, position: number) => {
|
|
1178
|
+
lengths.push(length);
|
|
1179
|
+
expect(position).toBe(cursor); // contiguous, in order
|
|
1180
|
+
cursor += length;
|
|
1181
|
+
return { bytesRead: length };
|
|
1182
|
+
},
|
|
1183
|
+
),
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
const total = await readFileRegion(
|
|
1187
|
+
fakeFh as unknown as Parameters<typeof readFileRegion>[0],
|
|
1188
|
+
Buffer.alloc(0), // recording fake never touches the buffer
|
|
1189
|
+
0,
|
|
1190
|
+
THREE_GIB,
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
expect(total).toBe(THREE_GIB);
|
|
1194
|
+
expect(lengths.length).toBeGreaterThan(1); // it actually chunked
|
|
1195
|
+
for (const len of lengths) {
|
|
1196
|
+
expect(len).toBeGreaterThan(0);
|
|
1197
|
+
expect(len).toBeLessThanOrEqual(INT32_MAX);
|
|
1198
|
+
}
|
|
1199
|
+
expect(lengths.reduce((a, b) => a + b, 0)).toBe(THREE_GIB);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it("reads an entire real region correctly across multiple chunks", async () => {
|
|
1203
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "readregion-"));
|
|
1204
|
+
const filePath = path.join(tmp, "data.bin");
|
|
1205
|
+
const payload = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789", "utf-8");
|
|
1206
|
+
await fs.writeFile(filePath, payload);
|
|
1207
|
+
|
|
1208
|
+
const fh = await fs.open(filePath, "r");
|
|
1209
|
+
try {
|
|
1210
|
+
const buf = Buffer.alloc(payload.length);
|
|
1211
|
+
// Tiny chunk size forces the loop to iterate many times.
|
|
1212
|
+
const read = await readFileRegion(fh, buf, 0, payload.length, 7);
|
|
1213
|
+
expect(read).toBe(payload.length);
|
|
1214
|
+
expect(buf.equals(payload)).toBe(true);
|
|
1215
|
+
} finally {
|
|
1216
|
+
await fh.close();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Reading a sub-region from an offset also lands correctly.
|
|
1220
|
+
const fh2 = await fs.open(filePath, "r");
|
|
1221
|
+
try {
|
|
1222
|
+
const buf = Buffer.alloc(10);
|
|
1223
|
+
const read = await readFileRegion(fh2, buf, 5, 10, 4);
|
|
1224
|
+
expect(read).toBe(10);
|
|
1225
|
+
expect(buf.toString("utf-8")).toBe(payload.toString("utf-8", 5, 15));
|
|
1226
|
+
} finally {
|
|
1227
|
+
await fh2.close();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it("stops cleanly on a short/zero read (file truncated since stat)", async () => {
|
|
1234
|
+
// Ask for 100 bytes but the file only holds 30: the handle yields bytes up
|
|
1235
|
+
// to what's left, then signals EOF with a 0-byte read.
|
|
1236
|
+
let remaining = 30;
|
|
1237
|
+
const fakeFh = {
|
|
1238
|
+
read: vi.fn(async (_b: Buffer, _o: number, length: number) => {
|
|
1239
|
+
const n = Math.min(length, remaining);
|
|
1240
|
+
remaining -= n;
|
|
1241
|
+
return { bytesRead: n };
|
|
1242
|
+
}),
|
|
1243
|
+
};
|
|
1244
|
+
const got = await readFileRegion(
|
|
1245
|
+
fakeFh as unknown as Parameters<typeof readFileRegion>[0],
|
|
1246
|
+
Buffer.alloc(0),
|
|
1247
|
+
0,
|
|
1248
|
+
100,
|
|
1249
|
+
8,
|
|
1250
|
+
);
|
|
1251
|
+
expect(got).toBe(30);
|
|
1252
|
+
});
|
|
1253
|
+
});
|
package/src/skill-telemetry.ts
CHANGED
|
@@ -594,6 +594,51 @@ async function listJsonlFiles(root: string): Promise<string[]> {
|
|
|
594
594
|
// session context without slurping a multi-MiB transcript.
|
|
595
595
|
const CODEX_META_PREFIX_BYTES = 64 * 1024;
|
|
596
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Largest length passed to a single `FileHandle.read()`. Node's native fs.read
|
|
599
|
+
* asserts `args[3]->IsInt32()` on the length argument, so a single read whose
|
|
600
|
+
* length exceeds 2**31-1 (≈2 GiB) does NOT throw a catchable error — it aborts
|
|
601
|
+
* the whole process with SIGABRT. A skill/session log whose unread tail had
|
|
602
|
+
* grown past ~2 GiB crashed the entire `hq sync` runner this way
|
|
603
|
+
* (Sentry HQ-SYNC-WEB-15). 256 MiB stays comfortably within Int32 while keeping
|
|
604
|
+
* the read-loop iteration count small.
|
|
605
|
+
*/
|
|
606
|
+
const MAX_FS_READ_CHUNK_BYTES = 256 * 1024 * 1024;
|
|
607
|
+
|
|
608
|
+
/** Minimal structural view of the `FileHandle.read` we depend on. */
|
|
609
|
+
type ReadableFileHandle = {
|
|
610
|
+
read(
|
|
611
|
+
buffer: Buffer,
|
|
612
|
+
offset: number,
|
|
613
|
+
length: number,
|
|
614
|
+
position: number,
|
|
615
|
+
): Promise<{ bytesRead: number }>;
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Read `length` bytes from `fh` starting at byte `position` into `buf`, issuing
|
|
620
|
+
* native reads no larger than `chunkBytes` so the length argument never exceeds
|
|
621
|
+
* Int32 and trips the SIGABRT-on-assert path (HQ-SYNC-WEB-15). Returns the total
|
|
622
|
+
* bytes actually read — which may be < `length` if the file was truncated
|
|
623
|
+
* between `stat` and this read (a 0-byte read means EOF, so we stop).
|
|
624
|
+
*/
|
|
625
|
+
export async function readFileRegion(
|
|
626
|
+
fh: ReadableFileHandle,
|
|
627
|
+
buf: Buffer,
|
|
628
|
+
position: number,
|
|
629
|
+
length: number,
|
|
630
|
+
chunkBytes: number = MAX_FS_READ_CHUNK_BYTES,
|
|
631
|
+
): Promise<number> {
|
|
632
|
+
let filled = 0;
|
|
633
|
+
while (filled < length) {
|
|
634
|
+
const chunk = Math.min(chunkBytes, length - filled);
|
|
635
|
+
const { bytesRead } = await fh.read(buf, filled, chunk, position + filled);
|
|
636
|
+
if (bytesRead === 0) break; // EOF / truncated since stat — stop cleanly.
|
|
637
|
+
filled += bytesRead;
|
|
638
|
+
}
|
|
639
|
+
return filled;
|
|
640
|
+
}
|
|
641
|
+
|
|
597
642
|
/** Read cwd + sessionId from a Codex rollout's leading `session_meta` line.
|
|
598
643
|
* Always reads from offset 0 (independent of the byte cursor). Best-effort:
|
|
599
644
|
* any error → empty context (events then fail the hqRoot scope filter, which
|
|
@@ -605,7 +650,7 @@ async function readCodexSessionContext(
|
|
|
605
650
|
const fh = await fs.open(filePath, "r");
|
|
606
651
|
try {
|
|
607
652
|
const buf = Buffer.alloc(CODEX_META_PREFIX_BYTES);
|
|
608
|
-
const
|
|
653
|
+
const bytesRead = await readFileRegion(fh, buf, 0, CODEX_META_PREFIX_BYTES);
|
|
609
654
|
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n", 1)[0]?.trim();
|
|
610
655
|
if (!firstLine) return {};
|
|
611
656
|
return parseCodexSessionMeta(JSON.parse(firstLine)) ?? {};
|
|
@@ -791,8 +836,11 @@ export async function collectAndSendSkillTelemetry(
|
|
|
791
836
|
try {
|
|
792
837
|
const length = Math.max(0, currentSize - offset);
|
|
793
838
|
const buf = Buffer.alloc(length);
|
|
794
|
-
|
|
795
|
-
|
|
839
|
+
// Chunked: a single fh.read() with length > Int32 aborts the process
|
|
840
|
+
// with SIGABRT (HQ-SYNC-WEB-15). `bytesRead` may be < length if the
|
|
841
|
+
// file was truncated since stat — bound the decode to it.
|
|
842
|
+
const bytesRead = await readFileRegion(fh, buf, offset, length);
|
|
843
|
+
content = buf.toString("utf-8", 0, bytesRead);
|
|
796
844
|
} finally {
|
|
797
845
|
await fh.close();
|
|
798
846
|
}
|