@indigoai-us/hq-cli 5.231.1 → 5.233.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/command-catalog.generated.d.ts +21 -0
- package/dist/command-catalog.generated.js +27 -0
- package/dist/commands/core-ontology.js +2 -0
- package/dist/commands/lanes.js +40 -0
- package/dist/lib/checkpoint-ontology/apply.d.ts +6 -1
- package/dist/lib/checkpoint-ontology/apply.js +10 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +11 -0
- package/dist/lib/checkpoint-ontology/plan.js +13 -2
- package/dist/lib/checkpoint-ontology/sync-client.d.ts +38 -0
- package/dist/lib/checkpoint-ontology/sync-client.js +84 -0
- package/dist/lib/lanes/wait.d.ts +39 -0
- package/dist/lib/lanes/wait.js +159 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.233.0] — 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq lanes wait` exits on the first selected envelope, pending-question, or
|
|
10
|
+
state change across the requested lanes. It uses a startup baseline and a
|
|
11
|
+
bounded two-second poll interval.
|
|
12
|
+
|
|
13
|
+
## [5.232.0] — 2026-09-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `checkpoint.ontology.sync-gated` (off by default) turns the checkpoint
|
|
18
|
+
ontology step on like `checkpoint.ontology`. Older CLI builds do not read it,
|
|
19
|
+
so it can be turned on widely without them. With either flag on, the step runs
|
|
20
|
+
only when the hq-cloud bundled with this CLI and the one the hq daemon runs
|
|
21
|
+
are both 6.18.7 or later; otherwise it skips and says which copy is older.
|
|
22
|
+
It also skips while the daemon's sync still runs from before its hq-cloud
|
|
23
|
+
copy was installed. `hq core ontology apply` checks again before it writes and
|
|
24
|
+
reports `sync_client_old` with the reason. Earlier hq-cloud releases pushed
|
|
25
|
+
stale copies of deleted entities.
|
|
26
|
+
|
|
5
27
|
## [5.231.1] — 2026-09-27
|
|
6
28
|
|
|
7
29
|
### Fixed
|
|
@@ -7773,6 +7773,27 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
7773
7773
|
readonly description: "JSON on stdout (always)";
|
|
7774
7774
|
}];
|
|
7775
7775
|
readonly subcommands: readonly [];
|
|
7776
|
+
}, {
|
|
7777
|
+
readonly name: "wait";
|
|
7778
|
+
readonly description: "Wait for the first requested change across lanes";
|
|
7779
|
+
readonly aliases: readonly [];
|
|
7780
|
+
readonly hidden: false;
|
|
7781
|
+
readonly usage: "[options]";
|
|
7782
|
+
readonly arguments: readonly [];
|
|
7783
|
+
readonly options: readonly [{
|
|
7784
|
+
readonly flags: "--any <lane-id...>";
|
|
7785
|
+
readonly description: "Lane ids to monitor";
|
|
7786
|
+
}, {
|
|
7787
|
+
readonly flags: "--for <change-type>";
|
|
7788
|
+
readonly description: "envelope | question | state";
|
|
7789
|
+
}, {
|
|
7790
|
+
readonly flags: "--timeout <seconds>";
|
|
7791
|
+
readonly description: "Positive timeout in seconds";
|
|
7792
|
+
}, {
|
|
7793
|
+
readonly flags: "--json";
|
|
7794
|
+
readonly description: "JSON output";
|
|
7795
|
+
}];
|
|
7796
|
+
readonly subcommands: readonly [];
|
|
7776
7797
|
}, {
|
|
7777
7798
|
readonly name: "watch";
|
|
7778
7799
|
readonly description: "Watch lane state and question changes until all lanes are terminal";
|
|
@@ -10051,6 +10051,33 @@ export const COMMAND_CATALOG = [
|
|
|
10051
10051
|
],
|
|
10052
10052
|
"subcommands": []
|
|
10053
10053
|
},
|
|
10054
|
+
{
|
|
10055
|
+
"name": "wait",
|
|
10056
|
+
"description": "Wait for the first requested change across lanes",
|
|
10057
|
+
"aliases": [],
|
|
10058
|
+
"hidden": false,
|
|
10059
|
+
"usage": "[options]",
|
|
10060
|
+
"arguments": [],
|
|
10061
|
+
"options": [
|
|
10062
|
+
{
|
|
10063
|
+
"flags": "--any <lane-id...>",
|
|
10064
|
+
"description": "Lane ids to monitor"
|
|
10065
|
+
},
|
|
10066
|
+
{
|
|
10067
|
+
"flags": "--for <change-type>",
|
|
10068
|
+
"description": "envelope | question | state"
|
|
10069
|
+
},
|
|
10070
|
+
{
|
|
10071
|
+
"flags": "--timeout <seconds>",
|
|
10072
|
+
"description": "Positive timeout in seconds"
|
|
10073
|
+
},
|
|
10074
|
+
{
|
|
10075
|
+
"flags": "--json",
|
|
10076
|
+
"description": "JSON output"
|
|
10077
|
+
}
|
|
10078
|
+
],
|
|
10079
|
+
"subcommands": []
|
|
10080
|
+
},
|
|
10054
10081
|
{
|
|
10055
10082
|
"name": "watch",
|
|
10056
10083
|
"description": "Watch lane state and question changes until all lanes are terminal",
|
|
@@ -86,6 +86,8 @@ export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
|
|
|
86
86
|
throw new OntologyUsageError("ontology: the run payload names no company");
|
|
87
87
|
}
|
|
88
88
|
const report = applyOntologyRun({ hqRoot, runDir, company, now: deps.now?.() });
|
|
89
|
+
if (report.status === "sync_client_old")
|
|
90
|
+
return `ontology: skipped (${report.skip_reason}); nothing written`;
|
|
89
91
|
if (report.status !== "applied")
|
|
90
92
|
return `ontology: ${report.status.replaceAll("_", " ")}; nothing written`;
|
|
91
93
|
return (`ontology: ${report.created.length} created, ${report.updated.length} updated, ` +
|
package/dist/commands/lanes.js
CHANGED
|
@@ -52,6 +52,7 @@ import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
|
52
52
|
import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
53
53
|
import { parseCliEffort, resolveLaneEffort, } from "../lib/lanes/effort.js";
|
|
54
54
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
55
|
+
import { LANE_WAIT_INVALID_INPUT_EXIT_CODE, LANE_WAIT_TIMEOUT_EXIT_CODE, runLaneWait, } from "../lib/lanes/wait.js";
|
|
55
56
|
import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
|
|
56
57
|
import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages, } from "../lib/lanes/dropbox.js";
|
|
57
58
|
import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision, } from "../lib/lanes/watcher.js";
|
|
@@ -2127,6 +2128,37 @@ function yieldEventLoop() {
|
|
|
2127
2128
|
setImmediate(resolve);
|
|
2128
2129
|
});
|
|
2129
2130
|
}
|
|
2131
|
+
function laneWaitHumanLine(result) {
|
|
2132
|
+
const details = Object.entries(result.change)
|
|
2133
|
+
.filter(([key]) => key !== "type")
|
|
2134
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
2135
|
+
.join(" ");
|
|
2136
|
+
return `lane=${result.lane_id} change=${result.change.type}${details ? ` ${details}` : ""} at=${result.at}`;
|
|
2137
|
+
}
|
|
2138
|
+
async function runWait(ctx, opts) {
|
|
2139
|
+
const timeoutSeconds = opts.timeout === undefined ? Number.NaN : Number(opts.timeout);
|
|
2140
|
+
try {
|
|
2141
|
+
const result = await runLaneWait(ctx.hqRoot(), opts.any ?? [], opts.for ?? [], timeoutSeconds * 1000, { now: ctx.now, sleep: ctx.sleep });
|
|
2142
|
+
if (result) {
|
|
2143
|
+
console.log(opts.json ? JSON.stringify(result) : laneWaitHumanLine(result));
|
|
2144
|
+
return 0;
|
|
2145
|
+
}
|
|
2146
|
+
console.log(opts.json
|
|
2147
|
+
? JSON.stringify({ ok: false, error: "wait_timeout" })
|
|
2148
|
+
: "wait_timeout");
|
|
2149
|
+
return LANE_WAIT_TIMEOUT_EXIT_CODE;
|
|
2150
|
+
}
|
|
2151
|
+
catch (err) {
|
|
2152
|
+
if (!isLanesError(err) || err.error !== "wait_invalid_input")
|
|
2153
|
+
throw err;
|
|
2154
|
+
const output = { ok: false, error: err.error, message: err.message };
|
|
2155
|
+
if (opts.json)
|
|
2156
|
+
console.log(JSON.stringify(output));
|
|
2157
|
+
else
|
|
2158
|
+
process.stderr.write(`${err.message}\n`);
|
|
2159
|
+
return LANE_WAIT_INVALID_INPUT_EXIT_CODE;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2130
2162
|
function withCasRetry(fn, attempts = 5) {
|
|
2131
2163
|
let last;
|
|
2132
2164
|
for (let i = 0; i < attempts; i += 1) {
|
|
@@ -9492,6 +9524,14 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9492
9524
|
.action(wrap(async (laneId, opts) => {
|
|
9493
9525
|
await runAdmit(ctx, laneId, opts);
|
|
9494
9526
|
}));
|
|
9527
|
+
lanes
|
|
9528
|
+
.command("wait")
|
|
9529
|
+
.description("Wait for the first requested change across lanes")
|
|
9530
|
+
.option("--any <lane-id...>", "Lane ids to monitor")
|
|
9531
|
+
.option("--for <change-type>", "envelope | question | state", collectRepeatable, [])
|
|
9532
|
+
.option("--timeout <seconds>", "Positive timeout in seconds")
|
|
9533
|
+
.option("--json", "JSON output")
|
|
9534
|
+
.action(wrapText(async (opts) => runWait(ctx, opts)));
|
|
9495
9535
|
lanes
|
|
9496
9536
|
.command("watch")
|
|
9497
9537
|
.description("Watch lane state and question changes until all lanes are terminal")
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type DuplicateReport, type MergeBackOutcome } from "./multi-writer.js";
|
|
9
9
|
import { type CompanyBriefResult } from "./company-brief.js";
|
|
10
|
+
import { type SyncClientVersions } from "./sync-client.js";
|
|
10
11
|
import { type WritableEntityType } from "./identity.js";
|
|
11
12
|
import { type EntityIndex } from "./tree.js";
|
|
12
13
|
export declare const CAPS: {
|
|
@@ -30,7 +31,9 @@ export interface ApplyReport {
|
|
|
30
31
|
schema: "hq-checkpoint-ontology-report.v1";
|
|
31
32
|
company: string;
|
|
32
33
|
run_id: string;
|
|
33
|
-
status: "applied" | "no_candidates" | "invalid_candidates";
|
|
34
|
+
status: "applied" | "no_candidates" | "invalid_candidates" | "sync_client_old";
|
|
35
|
+
/** Present only with status `sync_client_old`: which sync client copy is older or unknown. */
|
|
36
|
+
skip_reason?: string;
|
|
34
37
|
planned_inputs: number;
|
|
35
38
|
planned_tokens: number;
|
|
36
39
|
candidates: number;
|
|
@@ -94,6 +97,8 @@ export interface ApplyOptions {
|
|
|
94
97
|
/** The payload company. Must equal the plan's company. */
|
|
95
98
|
company: string;
|
|
96
99
|
now?: Date;
|
|
100
|
+
/** Test seam for the sync client check; defaults to reading this machine's hq-cloud copies. */
|
|
101
|
+
syncClientVersions?: () => SyncClientVersions;
|
|
97
102
|
}
|
|
98
103
|
export declare class ApplyError extends Error {
|
|
99
104
|
constructor(message: string);
|
|
@@ -12,6 +12,7 @@ import { entityKey, freshAliases, MAX_NAME_LENGTH, ownNames } from "./aliases.js
|
|
|
12
12
|
import { recordOutbox, scanDuplicates } from "./multi-writer.js";
|
|
13
13
|
import { writeCompanyBrief } from "./company-brief.js";
|
|
14
14
|
import { entitiesRoot, resolveOntologyCompany, safeWriteFile } from "./company.js";
|
|
15
|
+
import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
|
|
15
16
|
import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
|
|
16
17
|
import { canonicalizeName, entityId, isRejectedName, isWritableEntityType, normalizeEntityName, squash, toSlug, } from "./identity.js";
|
|
17
18
|
import { CANDIDATES_FILE, CANDIDATES_SCHEMA, PLAN_FILE, PLAN_SCHEMA, REPORT_FILE, readLedger, writeLedger, } from "./plan.js";
|
|
@@ -232,6 +233,15 @@ export function applyOntologyRun(options) {
|
|
|
232
233
|
fs.writeFileSync(path.join(options.runDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`);
|
|
233
234
|
return report;
|
|
234
235
|
};
|
|
236
|
+
// CO-45: the sync client can change between plan and apply, so check it
|
|
237
|
+
// again before anything is written.
|
|
238
|
+
const syncClient = checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)());
|
|
239
|
+
if (!syncClient.ok) {
|
|
240
|
+
report.status = "sync_client_old";
|
|
241
|
+
report.skip_reason = syncClient.reason;
|
|
242
|
+
report.inputs_pending = plan.inputs.filter((i) => i.kind !== "session").map((i) => i.id);
|
|
243
|
+
return finish();
|
|
244
|
+
}
|
|
235
245
|
const candidatesPath = path.join(options.runDir, CANDIDATES_FILE);
|
|
236
246
|
if (!fs.existsSync(candidatesPath)) {
|
|
237
247
|
report.status = "no_candidates";
|
|
@@ -11,7 +11,16 @@ import { type CompanyBriefResult } from "./company-brief.js";
|
|
|
11
11
|
import { type OntologyCompany } from "./company.js";
|
|
12
12
|
import { type MultiWriterStart } from "./multi-writer.js";
|
|
13
13
|
import { type EntityHint } from "./hints.js";
|
|
14
|
+
import { type SyncClientVersions } from "./sync-client.js";
|
|
14
15
|
export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
16
|
+
/**
|
|
17
|
+
* Turns the step on like `checkpoint.ontology`, but only builds that check the
|
|
18
|
+
* sync client read it (CO-45, owner decision CO-D-005). Older CLI builds read
|
|
19
|
+
* only `checkpoint.ontology`, so this key can default on without starting the
|
|
20
|
+
* step next to an hq-cloud older than 6.18.7. Either key needs a sync client of
|
|
21
|
+
* 6.18.7 or later; see ./sync-client.ts.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ONTOLOGY_SYNC_GATED_FLAG = "checkpoint.ontology.sync-gated";
|
|
15
24
|
export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
|
|
16
25
|
/**
|
|
17
26
|
* Applies only when the payload's sibling backend is grok. On gold-v2, Claude
|
|
@@ -159,6 +168,8 @@ export interface PlanOptions {
|
|
|
159
168
|
payload: CheckpointPayloadLike;
|
|
160
169
|
readFlag: FlagReader;
|
|
161
170
|
now?: Date;
|
|
171
|
+
/** This machine's hq-cloud versions; defaults to reading them. */
|
|
172
|
+
syncClientVersions?: () => SyncClientVersions;
|
|
162
173
|
}
|
|
163
174
|
export type PlanOutcome = {
|
|
164
175
|
status: "skipped";
|
|
@@ -14,8 +14,17 @@ import { writeCompanyBrief } from "./company-brief.js";
|
|
|
14
14
|
import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
|
|
15
15
|
import { startMultiWriterRun } from "./multi-writer.js";
|
|
16
16
|
import { entityHints, wordWeights } from "./hints.js";
|
|
17
|
+
import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
|
|
17
18
|
import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
|
|
18
19
|
export const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
20
|
+
/**
|
|
21
|
+
* Turns the step on like `checkpoint.ontology`, but only builds that check the
|
|
22
|
+
* sync client read it (CO-45, owner decision CO-D-005). Older CLI builds read
|
|
23
|
+
* only `checkpoint.ontology`, so this key can default on without starting the
|
|
24
|
+
* step next to an hq-cloud older than 6.18.7. Either key needs a sync client of
|
|
25
|
+
* 6.18.7 or later; see ./sync-client.ts.
|
|
26
|
+
*/
|
|
27
|
+
export const ONTOLOGY_SYNC_GATED_FLAG = "checkpoint.ontology.sync-gated";
|
|
19
28
|
export const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
|
|
20
29
|
/**
|
|
21
30
|
* Applies only when the payload's sibling backend is grok. On gold-v2, Claude
|
|
@@ -231,10 +240,12 @@ export async function planOntologyRun(options) {
|
|
|
231
240
|
if (!resolution.ok)
|
|
232
241
|
return { status: "skipped", reason: resolution.reason };
|
|
233
242
|
const company = resolution.company;
|
|
234
|
-
|
|
243
|
+
const ontologyOn = (await options.readFlag(ONTOLOGY_FLAG, company)) || (await options.readFlag(ONTOLOGY_SYNC_GATED_FLAG, company));
|
|
244
|
+
const syncClient = ontologyOn ? checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)()) : null;
|
|
245
|
+
if (!ontologyOn || !syncClient?.ok) {
|
|
235
246
|
// A preference recorded while the ontology was on must not outlive it.
|
|
236
247
|
fs.rmSync(backendPreferencePath(hqRoot, company), { force: true });
|
|
237
|
-
return { status: "skipped", reason: "flag off" };
|
|
248
|
+
return { status: "skipped", reason: syncClient && !syncClient.ok ? syncClient.reason : "flag off" };
|
|
238
249
|
}
|
|
239
250
|
const briefOn = await options.readFlag(COMPANY_BRIEF_FLAG, company);
|
|
240
251
|
const ignoreAnnotations = (await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company)) && payload.backend === "grok";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync client check for the checkpoint ontology step (CO-45, owner
|
|
3
|
+
* decision CO-D-005).
|
|
4
|
+
*
|
|
5
|
+
* The step writes entity files locally and a sync client pushes them. hq-cloud
|
|
6
|
+
* before 6.18.7 pushed stale local copies of deleted entities as new objects
|
|
7
|
+
* and overwrote newer entities with older ones (INC-CO-RESURRECT). hq-flags
|
|
8
|
+
* cannot target by client version, so the step checks this machine's hq-cloud
|
|
9
|
+
* copies itself: the one bundled with this CLI (`hq sync`) and the one the hq
|
|
10
|
+
* daemon runs. If either is older than 6.18.7, or the bundled copy cannot be
|
|
11
|
+
* read, the step does not run. The daemon picks up a new hq-cloud only when
|
|
12
|
+
* its sync unit restarts, so a sync unit that started before its copy was
|
|
13
|
+
* installed may still run older code; the step does not run then either.
|
|
14
|
+
*
|
|
15
|
+
* The desktop app's sync runner is not visible from here. The vault's
|
|
16
|
+
* `vault.refuse-stale-entity-recreate` guard refuses those two writes from any
|
|
17
|
+
* client.
|
|
18
|
+
*/
|
|
19
|
+
export declare const MIN_SYNC_CLIENT_VERSION = "6.18.7";
|
|
20
|
+
export interface SyncClientVersions {
|
|
21
|
+
/** hq-cloud bundled with this CLI, or null when it cannot be read. */
|
|
22
|
+
bundled: string | null;
|
|
23
|
+
/** hq-cloud the hq daemon runs, or null when it cannot be read. */
|
|
24
|
+
daemon: string | null;
|
|
25
|
+
/** True when the daemon's running sync unit started before that copy was installed. */
|
|
26
|
+
daemonRestartPending?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export type SyncClientCheck = {
|
|
29
|
+
ok: true;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
/** Read this machine's hq-cloud versions. Never throws. */
|
|
35
|
+
export declare function readSyncClientVersions(): SyncClientVersions;
|
|
36
|
+
/** Every readable copy must be at least 6.18.7, and the bundled copy must be readable. */
|
|
37
|
+
export declare function checkSyncClient(versions: SyncClientVersions): SyncClientCheck;
|
|
38
|
+
//# sourceMappingURL=sync-client.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync client check for the checkpoint ontology step (CO-45, owner
|
|
3
|
+
* decision CO-D-005).
|
|
4
|
+
*
|
|
5
|
+
* The step writes entity files locally and a sync client pushes them. hq-cloud
|
|
6
|
+
* before 6.18.7 pushed stale local copies of deleted entities as new objects
|
|
7
|
+
* and overwrote newer entities with older ones (INC-CO-RESURRECT). hq-flags
|
|
8
|
+
* cannot target by client version, so the step checks this machine's hq-cloud
|
|
9
|
+
* copies itself: the one bundled with this CLI (`hq sync`) and the one the hq
|
|
10
|
+
* daemon runs. If either is older than 6.18.7, or the bundled copy cannot be
|
|
11
|
+
* read, the step does not run. The daemon picks up a new hq-cloud only when
|
|
12
|
+
* its sync unit restarts, so a sync unit that started before its copy was
|
|
13
|
+
* installed may still run older code; the step does not run then either.
|
|
14
|
+
*
|
|
15
|
+
* The desktop app's sync runner is not visible from here. The vault's
|
|
16
|
+
* `vault.refuse-stale-entity-recreate` guard refuses those two writes from any
|
|
17
|
+
* client.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import semver from "semver";
|
|
22
|
+
import { bundledHqCloud, currentHqCloud } from "../daemon/hq-cloud.js";
|
|
23
|
+
import { daemonPaths } from "../daemon/paths.js";
|
|
24
|
+
export const MIN_SYNC_CLIENT_VERSION = "6.18.7";
|
|
25
|
+
/** Read this machine's hq-cloud versions. Never throws. */
|
|
26
|
+
export function readSyncClientVersions() {
|
|
27
|
+
const bundled = bundledHqCloud()?.version ?? null;
|
|
28
|
+
let daemon = null;
|
|
29
|
+
let daemonRestartPending = false;
|
|
30
|
+
try {
|
|
31
|
+
const paths = daemonPaths();
|
|
32
|
+
const picked = currentHqCloud(paths.root);
|
|
33
|
+
daemon = picked?.version ?? null;
|
|
34
|
+
if (picked) {
|
|
35
|
+
try {
|
|
36
|
+
daemonRestartPending = syncStartedBefore(paths.statePath, path.join(picked.root, "package.json"));
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
// Unknown start time: assume the running sync may be older.
|
|
40
|
+
daemonRestartPending = true;
|
|
41
|
+
console.warn(`ontology: could not read the hq daemon's sync start time: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
console.warn(`ontology: could not read the hq daemon's hq-cloud version: ${err instanceof Error ? err.message : String(err)}`);
|
|
47
|
+
}
|
|
48
|
+
return { bundled, daemon, daemonRestartPending };
|
|
49
|
+
}
|
|
50
|
+
/** Whether the daemon's running sync unit started before `copyPackageJson` was written. No state file: no running unit. */
|
|
51
|
+
function syncStartedBefore(statePath, copyPackageJson) {
|
|
52
|
+
let state;
|
|
53
|
+
try {
|
|
54
|
+
state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err.code === "ENOENT")
|
|
58
|
+
return false;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
const sync = state.units?.find((u) => u.id === "sync" && u.status === "running");
|
|
62
|
+
if (!sync || typeof sync.startedAt !== "number")
|
|
63
|
+
return false;
|
|
64
|
+
return fs.statSync(copyPackageJson).mtimeMs > sync.startedAt;
|
|
65
|
+
}
|
|
66
|
+
/** Every readable copy must be at least 6.18.7, and the bundled copy must be readable. */
|
|
67
|
+
export function checkSyncClient(versions) {
|
|
68
|
+
if (versions.bundled === null) {
|
|
69
|
+
return { ok: false, reason: `sync client version unknown; needs hq-cloud ${MIN_SYNC_CLIENT_VERSION} or later` };
|
|
70
|
+
}
|
|
71
|
+
for (const [name, version] of [["bundled", versions.bundled], ["daemon", versions.daemon]]) {
|
|
72
|
+
if (version === null)
|
|
73
|
+
continue;
|
|
74
|
+
const parsed = semver.valid(version);
|
|
75
|
+
if (parsed === null || semver.lt(parsed, MIN_SYNC_CLIENT_VERSION)) {
|
|
76
|
+
return { ok: false, reason: `sync client hq-cloud ${version} (${name}) is older than ${MIN_SYNC_CLIENT_VERSION}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (versions.daemonRestartPending) {
|
|
80
|
+
return { ok: false, reason: `the hq daemon's sync has not restarted onto hq-cloud ${versions.daemon ?? "(unknown)"} yet` };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true };
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=sync-client.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LaneRecord, QuestionRecord } from "./types.js";
|
|
2
|
+
export declare const LANE_WAIT_POLL_INTERVAL_MS = 2000;
|
|
3
|
+
export declare const LANE_WAIT_MAX_LANES = 64;
|
|
4
|
+
export declare const LANE_WAIT_TIMEOUT_EXIT_CODE = 4;
|
|
5
|
+
export declare const LANE_WAIT_INVALID_INPUT_EXIT_CODE = 2;
|
|
6
|
+
export declare const LANE_WAIT_CHANGE_TYPES: readonly ["envelope", "question", "state"];
|
|
7
|
+
export type LaneWaitChangeType = (typeof LANE_WAIT_CHANGE_TYPES)[number];
|
|
8
|
+
export type LaneWaitChange = {
|
|
9
|
+
type: "envelope";
|
|
10
|
+
ref: string;
|
|
11
|
+
decision: "done" | "ask" | "blocked";
|
|
12
|
+
} | {
|
|
13
|
+
type: "question";
|
|
14
|
+
question_id: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "state";
|
|
17
|
+
from: string;
|
|
18
|
+
to: string;
|
|
19
|
+
};
|
|
20
|
+
export interface LaneWaitResult {
|
|
21
|
+
ok: true;
|
|
22
|
+
lane_id: string;
|
|
23
|
+
change: LaneWaitChange;
|
|
24
|
+
at: string;
|
|
25
|
+
}
|
|
26
|
+
export interface LaneWaitDeps {
|
|
27
|
+
readLane?: (hqRoot: string, laneId: string) => LaneRecord | null;
|
|
28
|
+
listQuestionIds?: (hqRoot: string) => string[];
|
|
29
|
+
readQuestion?: (hqRoot: string, questionId: string) => QuestionRecord | null;
|
|
30
|
+
now?: () => Date;
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Wait for the first requested change after taking a read-only baseline.
|
|
35
|
+
* Each poll reads every selected lane once and runs no lane-store mutation or
|
|
36
|
+
* lock. Question records are scanned in one pass when question changes matter.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runLaneWait(hqRoot: string, laneIds: readonly string[], changeTypes: readonly string[], timeoutMs: number, deps?: LaneWaitDeps): Promise<LaneWaitResult | null>;
|
|
39
|
+
//# sourceMappingURL=wait.d.ts.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { listQuestionIds, readLane, readQuestion } from "./store.js";
|
|
2
|
+
import { LanesError } from "./errors.js";
|
|
3
|
+
export const LANE_WAIT_POLL_INTERVAL_MS = 2_000;
|
|
4
|
+
export const LANE_WAIT_MAX_LANES = 64;
|
|
5
|
+
export const LANE_WAIT_TIMEOUT_EXIT_CODE = 4;
|
|
6
|
+
export const LANE_WAIT_INVALID_INPUT_EXIT_CODE = 2;
|
|
7
|
+
export const LANE_WAIT_CHANGE_TYPES = ["envelope", "question", "state"];
|
|
8
|
+
function defaultSleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
function invalidInput(message, details = {}) {
|
|
12
|
+
throw new LanesError("wait_invalid_input", message, details);
|
|
13
|
+
}
|
|
14
|
+
function validateInput(laneIds, changeTypes, timeoutMs) {
|
|
15
|
+
const normalizedLaneIds = laneIds.map((laneId) => laneId.trim());
|
|
16
|
+
if (normalizedLaneIds.length === 0 || normalizedLaneIds.some((laneId) => laneId.length === 0)) {
|
|
17
|
+
invalidInput("hq lanes wait requires at least one non-empty lane id", { lane_ids: laneIds });
|
|
18
|
+
}
|
|
19
|
+
if (normalizedLaneIds.some((laneId) => laneId === "." || laneId === ".." || /[\\/]/.test(laneId))) {
|
|
20
|
+
invalidInput("Lane ids must not contain path separators", { lane_ids: laneIds });
|
|
21
|
+
}
|
|
22
|
+
const uniqueLaneIds = [...new Set(normalizedLaneIds)];
|
|
23
|
+
if (uniqueLaneIds.length > LANE_WAIT_MAX_LANES) {
|
|
24
|
+
invalidInput(`hq lanes wait accepts at most ${LANE_WAIT_MAX_LANES} lane ids`, {
|
|
25
|
+
max: LANE_WAIT_MAX_LANES,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (changeTypes.length === 0 || changeTypes.some((kind) => !LANE_WAIT_CHANGE_TYPES.includes(kind))) {
|
|
29
|
+
invalidInput(`hq lanes wait requires one or more --for values: ${LANE_WAIT_CHANGE_TYPES.join(", ")}`, { accepted: [...LANE_WAIT_CHANGE_TYPES], values: changeTypes });
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isSafeInteger(Math.ceil(timeoutMs))) {
|
|
32
|
+
invalidInput("hq lanes wait requires a positive finite timeout", { timeout_ms: timeoutMs });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
laneIds: uniqueLaneIds,
|
|
36
|
+
changeTypes: [...new Set(changeTypes)],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function snapshotLanes(hqRoot, laneIds, changeTypes, deps) {
|
|
40
|
+
const readLaneRecord = deps.readLane ?? readLane;
|
|
41
|
+
const lanes = new Map();
|
|
42
|
+
for (const laneId of laneIds) {
|
|
43
|
+
const lane = readLaneRecord(hqRoot, laneId);
|
|
44
|
+
if (!lane) {
|
|
45
|
+
throw new LanesError("wait_lane_unreadable", `Lane ${laneId} could not be read while waiting`, { lane_id: laneId });
|
|
46
|
+
}
|
|
47
|
+
lanes.set(laneId, lane);
|
|
48
|
+
}
|
|
49
|
+
const pendingQuestions = new Map();
|
|
50
|
+
if (changeTypes.includes("question")) {
|
|
51
|
+
const listIds = deps.listQuestionIds ?? listQuestionIds;
|
|
52
|
+
const readQuestionRecord = deps.readQuestion ?? readQuestion;
|
|
53
|
+
for (const questionId of new Set(listIds(hqRoot))) {
|
|
54
|
+
const question = readQuestionRecord(hqRoot, questionId);
|
|
55
|
+
if (!question) {
|
|
56
|
+
throw new LanesError("wait_question_unreadable", `Question ${questionId} disappeared while waiting`, { question_id: questionId });
|
|
57
|
+
}
|
|
58
|
+
if (question.status === "pending" && lanes.has(question.lane_id)) {
|
|
59
|
+
pendingQuestions.set(questionId, question);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { lanes, pendingQuestions };
|
|
64
|
+
}
|
|
65
|
+
function timestamp(value, fallbackMs) {
|
|
66
|
+
const parsed = value === undefined ? Number.NaN : Date.parse(value);
|
|
67
|
+
const atMs = Number.isFinite(parsed) ? parsed : fallbackMs;
|
|
68
|
+
return { at: new Date(atMs).toISOString(), atMs };
|
|
69
|
+
}
|
|
70
|
+
function envelopeSignature(lane) {
|
|
71
|
+
const envelope = lane.last_envelope;
|
|
72
|
+
return envelope === undefined
|
|
73
|
+
? undefined
|
|
74
|
+
: `${envelope.ref}\0${envelope.decision}\0${envelope.at}`;
|
|
75
|
+
}
|
|
76
|
+
function candidatesForSnapshot(previous, current, laneIds, changeTypes, observedAtMs) {
|
|
77
|
+
const candidates = [];
|
|
78
|
+
laneIds.forEach((laneId, laneOrder) => {
|
|
79
|
+
const before = previous.lanes.get(laneId);
|
|
80
|
+
const after = current.lanes.get(laneId);
|
|
81
|
+
if (!before || !after)
|
|
82
|
+
return;
|
|
83
|
+
changeTypes.forEach((type, typeOrder) => {
|
|
84
|
+
if (type === "envelope" && envelopeSignature(before) !== envelopeSignature(after)) {
|
|
85
|
+
const envelope = after.last_envelope;
|
|
86
|
+
if (envelope) {
|
|
87
|
+
const when = timestamp(envelope.at, observedAtMs);
|
|
88
|
+
candidates.push({
|
|
89
|
+
laneId,
|
|
90
|
+
change: { type, ref: envelope.ref, decision: envelope.decision },
|
|
91
|
+
...when,
|
|
92
|
+
laneOrder,
|
|
93
|
+
typeOrder,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (type === "state" && before.state !== after.state) {
|
|
98
|
+
const when = timestamp(after.updated_at, observedAtMs);
|
|
99
|
+
candidates.push({
|
|
100
|
+
laneId,
|
|
101
|
+
change: { type, from: before.state, to: after.state },
|
|
102
|
+
...when,
|
|
103
|
+
laneOrder,
|
|
104
|
+
typeOrder,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (type === "question") {
|
|
108
|
+
for (const [questionId, question] of current.pendingQuestions) {
|
|
109
|
+
if (question.lane_id !== laneId || previous.pendingQuestions.has(questionId))
|
|
110
|
+
continue;
|
|
111
|
+
const when = timestamp(question.asked_at, observedAtMs);
|
|
112
|
+
candidates.push({
|
|
113
|
+
laneId,
|
|
114
|
+
change: { type, question_id: questionId },
|
|
115
|
+
...when,
|
|
116
|
+
laneOrder,
|
|
117
|
+
typeOrder,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
return candidates.sort((a, b) => a.atMs - b.atMs || a.laneOrder - b.laneOrder || a.typeOrder - b.typeOrder);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Wait for the first requested change after taking a read-only baseline.
|
|
127
|
+
* Each poll reads every selected lane once and runs no lane-store mutation or
|
|
128
|
+
* lock. Question records are scanned in one pass when question changes matter.
|
|
129
|
+
*/
|
|
130
|
+
export async function runLaneWait(hqRoot, laneIds, changeTypes, timeoutMs, deps = {}) {
|
|
131
|
+
const input = validateInput(laneIds, changeTypes, timeoutMs);
|
|
132
|
+
const now = deps.now ?? (() => new Date());
|
|
133
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
134
|
+
const startedAtMs = now().getTime();
|
|
135
|
+
const deadlineMs = startedAtMs + timeoutMs;
|
|
136
|
+
let previous = snapshotLanes(hqRoot, input.laneIds, input.changeTypes, deps);
|
|
137
|
+
while (true) {
|
|
138
|
+
const remainingMs = deadlineMs - now().getTime();
|
|
139
|
+
if (remainingMs <= 0)
|
|
140
|
+
return null;
|
|
141
|
+
await sleep(Math.min(LANE_WAIT_POLL_INTERVAL_MS, remainingMs));
|
|
142
|
+
const observedAtMs = now().getTime();
|
|
143
|
+
const current = snapshotLanes(hqRoot, input.laneIds, input.changeTypes, deps);
|
|
144
|
+
const candidates = candidatesForSnapshot(previous, current, input.laneIds, input.changeTypes, observedAtMs);
|
|
145
|
+
const first = candidates.find((candidate) => candidate.atMs <= deadlineMs);
|
|
146
|
+
if (first) {
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
lane_id: first.laneId,
|
|
150
|
+
change: first.change,
|
|
151
|
+
at: first.at,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (observedAtMs >= deadlineMs)
|
|
155
|
+
return null;
|
|
156
|
+
previous = current;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=wait.js.map
|