@indigoai-us/hq-cli 5.185.0 → 5.186.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 CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.186.0] — 2026-09-25
6
+
7
+ ### Changed
8
+
9
+ - A local bot's scheduled jobs now live with the bot. `hq bot jobs` stores them in the bot's folder (`jobs.json`) and the running bot fires them itself, through the same path as a cloud-fired job. No cloud setting is involved. A run missed while the computer slept or the bot was stopped runs once as a catch-up; a job never overlaps itself. The 25-job cap per bot is unchanged.
10
+
11
+ ## [5.185.1] — 2026-09-25
12
+
13
+ ### Fixed
14
+
15
+ - `lanes.hire-grants` resolves its company-scoped hq-flags value before gating.
16
+ A valid local override wins; missing rows or failed reads leave enforcement
17
+ off. Backfills check the unscoped value and each target company's value
18
+ before writing. `hire_grant_exhausted` names the grantor and includes the
19
+ same recovery message in JSON and on stderr.
20
+
5
21
  ## [5.185.0] — 2026-09-25
6
22
 
7
23
  ### Fixed
@@ -18,9 +18,9 @@ import chalk from "chalk";
18
18
  import { execFileSync } from "node:child_process";
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
- import { BotApi, BotApiError } from "../lib/bot/api.js";
21
+ import { localJobsClient } from "../lib/bot/local-jobs.js";
22
+ import { BotApiError } from "../lib/bot/api.js";
22
23
  import { effectiveBotKind } from "../lib/bot/config.js";
23
- import { identityMismatchMessage, tokenAgentUid } from "../lib/heartbeat-identity.js";
24
24
  // ─── Schedule translation ────────────────────────────────────────────────────
25
25
  /** Mirrors hq-pro-agents `MIN_RECURRING_INTERVAL_SECONDS`. */
26
26
  export const MIN_RECURRING_INTERVAL_SECONDS = 5 * 60;
@@ -365,16 +365,13 @@ export async function runBotJobs(nameArg, opts, deps) {
365
365
  const kind = effectiveBotKind(config);
366
366
  const out = deps.out ?? ((line) => console.log(line));
367
367
  const ctx = { botName: name, kind };
368
+ // The schedule lives with the bot: this bot runs on this computer, so its
369
+ // jobs live in its folder and it fires them itself.
368
370
  let client;
369
371
  const jobsApi = async () => {
370
372
  if (client)
371
373
  return client;
372
- const token = await deps.botTokenSupplier(dir)();
373
- const tokenUid = tokenAgentUid(token);
374
- if (tokenUid && tokenUid !== config.agentUid) {
375
- refuse(identityMismatchMessage("bot jobs", config.agentUid, tokenUid));
376
- }
377
- client = deps.apiFor ? deps.apiFor(dir) : new BotApi({ token: async () => token });
374
+ client = deps.apiFor ? deps.apiFor(dir) : localJobsClient(dir);
378
375
  return client;
379
376
  };
380
377
  if (opts.add !== undefined && opts.rm !== undefined) {
@@ -420,9 +417,8 @@ export async function runBotJobs(nameArg, opts, deps) {
420
417
  }
421
418
  const words = ("human" in schedule && schedule.human) || describeSchedule(created);
422
419
  out(chalk.green(`Scheduled ${created.jobId}: ${name} will ${body.prompt.replace(/\s+/g, " ").slice(0, 120)} — ${words}.`));
423
- out(chalk.gray("If this computer is asleep or off when a run is due, it is picked up as a catch-up when the computer " +
424
- "wakes, as long as it wakes within about an hour. After a longer outage that run is skipped and the " +
425
- "job carries on at its next scheduled time."));
420
+ out(chalk.gray(`It runs on this computer while ${name} is running. If a run is due while the computer is asleep or ` +
421
+ `${name} is stopped, it runs once, as a catch-up, the next time ${name} is running.`));
426
422
  return;
427
423
  }
428
424
  let jobs;
@@ -1,3 +1,4 @@
1
+ import { withLocalJobs } from "../lib/bot/local-jobs.js";
1
2
  import { OwnerContextCache, lookupOwnerContext } from "../lib/bot/owner-context.js";
2
3
  import { hasActivePromotion } from "../lib/bot/promotion-receipt.js";
3
4
  import { readInflightTurns } from "../lib/bot/inflight.js";
@@ -866,7 +867,8 @@ async function runBotResident(nameArg) {
866
867
  if (healNodePathFromJob(name, dir, config))
867
868
  return;
868
869
  const token = botTokenSupplier(dir);
869
- const api = new BotApi({ token });
870
+ // The bot's own schedule lives in its folder and fires from this loop.
871
+ const api = withLocalJobs(new BotApi({ token }), dir);
870
872
  const owner = new OwnerContextCache(() => lookupOwnerContext(config.ownerUid), Date.now, undefined, config.ownerUid);
871
873
  const handle = await runBot({
872
874
  dir,
@@ -79,7 +79,7 @@ export interface LanesCommandDeps {
79
79
  /** Test seam for the default-off outpost placement rollout flag. */
80
80
  lanePlacementEnabled?: () => boolean;
81
81
  /** Test seam for the default-off lane hire-grants rollout gate. */
82
- laneHireGrantsEnabled?: () => boolean;
82
+ laneHireGrantsEnabled?: (company?: LaneCompanyRef | null) => boolean | Promise<boolean>;
83
83
  }
84
84
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
85
85
  export declare function parseSeniorRef(raw: string): SeniorRef;
@@ -13,6 +13,7 @@ import * as path from "node:path";
13
13
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
14
14
  import { ensureCognitoToken } from "../utils/cognito-session.js";
15
15
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
16
+ import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
16
17
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
17
18
  import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
18
19
  export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
@@ -52,8 +53,8 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
52
53
  function defaultLanePlacementEnabled() {
53
54
  return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
54
55
  }
55
- function defaultLaneHireGrantsEnabled() {
56
- return resolveProcessFlagGate(LANE_HIRE_GRANTS_FLAG, { fallback: false }, () => false);
56
+ function defaultLaneHireGrantsEnabled(company) {
57
+ return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
57
58
  }
58
59
  function withoutProviderHome(value) {
59
60
  if (Array.isArray(value))
@@ -73,6 +74,9 @@ function printJson(value) {
73
74
  }
74
75
  function failJson(err) {
75
76
  if (isLanesError(err)) {
77
+ if (err.error === "hire_grant_exhausted") {
78
+ process.stderr.write(`hq lanes: ${err.message}\n`);
79
+ }
76
80
  printJson(err.toJSON());
77
81
  process.exit(1);
78
82
  }
@@ -515,12 +519,14 @@ const OUTPOST_HIRE_SUMMARY_WAIT_MS = 15_000;
515
519
  const OUTPOST_HIRE_SUMMARY_CONCURRENCY = 4;
516
520
  async function laneHirePresentation(ctx, lane, lanes) {
517
521
  const local = laneHireFields(lane, lanes);
518
- if (!ctx.laneHireGrantsEnabled() ||
519
- lane.host.kind !== "outpost" ||
522
+ if (lane.host.kind !== "outpost" ||
520
523
  isTerminalLaneState(lane.state) ||
521
524
  laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
522
525
  return { ...local, hire_source: "local" };
523
526
  }
527
+ if (!await ctx.laneHireGrantsEnabled(lane.company)) {
528
+ return { ...local, hire_source: "local" };
529
+ }
524
530
  try {
525
531
  return { ...(await ownerLaneHireFields(ctx, lane)), hire_source: "owner" };
526
532
  }
@@ -548,14 +554,15 @@ async function mapWithConcurrency(values, concurrency, map) {
548
554
  await Promise.all(workers);
549
555
  return results;
550
556
  }
551
- function withResumeHireAdmission(ctx, lane, fn) {
557
+ async function withResumeHireAdmission(ctx, lane, fn) {
552
558
  const hqRoot = ctx.hqRoot();
553
- if (!ctx.laneHireGrantsEnabled() ||
554
- !isTerminalLaneState(lane.state) ||
559
+ if (!isTerminalLaneState(lane.state) ||
555
560
  lane.senior.kind !== "lane" ||
556
561
  !lane.senior.id) {
557
562
  return fn();
558
563
  }
564
+ if (!await ctx.laneHireGrantsEnabled(lane.company))
565
+ return fn();
559
566
  return withHireGrantAdmission(hqRoot, lane.senior.id, ({ parent, children, parentGrant, availableChildGrant }) => {
560
567
  const current = readLane(hqRoot, lane.lane_id);
561
568
  // A different resume already reactivated this lane; it occupies the slot
@@ -588,11 +595,33 @@ function withResumeHireAdmission(ctx, lane, fn) {
588
595
  }
589
596
  function hireGrantCapacityError(parent, children) {
590
597
  const grant = parent.hire_grant ?? 0;
591
- return new LanesError("hire_grant_exhausted", `Senior lane ${parent.lane_id} has hire grant ${grant} and ${children.length} alive direct children (${children.map((child) => child.lane_id).join(", ") || "none"}). Ask its senior for more: write an ask envelope with the desired total grant and why; the senior runs hq lanes grant ${parent.lane_id} --hire <n>.`, {
598
+ const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
599
+ parent.senior.id
600
+ ? { kind: parent.senior.kind, id: parent.senior.id }
601
+ : null;
602
+ const grantorReason = grantor
603
+ ? undefined
604
+ : parent.senior.kind === "user"
605
+ ? "senior_is_user"
606
+ : "parent_senior_missing_id";
607
+ const capacity = children.length === grant
608
+ ? `is at its hire grant (${children.length} of ${grant} alive children)`
609
+ : `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
610
+ const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
611
+ const authorityGuidance = grantor
612
+ ? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
613
+ : parent.senior.kind === "user"
614
+ ? `Its senior is the user and can raise it with: ${grantCommand}`
615
+ : `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
616
+ const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
617
+ return new LanesError("hire_grant_exhausted", message, {
592
618
  senior_lane_id: parent.lane_id,
593
619
  parent_grant: grant,
594
620
  alive_children: children.map((child) => child.lane_id),
595
621
  alive_child_count: children.length,
622
+ grantor,
623
+ ...(grantorReason ? { grantor_reason: grantorReason } : {}),
624
+ message,
596
625
  ask_guidance: {
597
626
  envelope: "decision=ask; include the desired total grant and the reason",
598
627
  senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
@@ -1858,7 +1887,12 @@ async function runCreate(ctx, opts) {
1858
1887
  }
1859
1888
  const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
1860
1889
  assertSeniorExists(hqRoot, senior, ctx);
1861
- const hireGrantsEnabled = ctx.laneHireGrantsEnabled();
1890
+ // Resolve tenant context before the registry read so the flag evaluator
1891
+ // applies the same company scope as this lane.
1892
+ const company = companyFlag
1893
+ ? await ctx.resolveCompany(companyFlag)
1894
+ : null;
1895
+ const hireGrantsEnabled = await ctx.laneHireGrantsEnabled(company);
1862
1896
  if (requestedHireGrant !== undefined &&
1863
1897
  !callerMatchesSenior(ctx, senior)) {
1864
1898
  throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(senior)} may set a lane's hire grant`, { senior, hire_grant: requestedHireGrant });
@@ -1874,9 +1908,6 @@ async function runCreate(ctx, opts) {
1874
1908
  throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
1875
1909
  }
1876
1910
  // A no-company lane must not call company resolution (openMeshTransport).
1877
- const company = companyFlag
1878
- ? await ctx.resolveCompany(companyFlag)
1879
- : null;
1880
1911
  let meshCompany = company;
1881
1912
  if (meshCompanyFlag) {
1882
1913
  const resolvedMeshCompany = await ctx.resolveCompany(meshCompanyFlag);
@@ -3024,6 +3055,17 @@ async function runList(ctx, opts) {
3024
3055
  return true;
3025
3056
  })
3026
3057
  .sort(compareLaneQueueOrder);
3058
+ const companyContexts = new Map();
3059
+ for (const lane of selectedLanes) {
3060
+ if (lane.host.kind !== "outpost" ||
3061
+ isTerminalLaneState(lane.state) ||
3062
+ laneRunsHere(lane, host)) {
3063
+ continue;
3064
+ }
3065
+ const company = lane.company;
3066
+ companyContexts.set(JSON.stringify([company?.uid ?? null, company?.slug ?? null]), company);
3067
+ }
3068
+ await Promise.all([...companyContexts.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
3027
3069
  const hireByLane = new Map(await mapWithConcurrency(selectedLanes, OUTPOST_HIRE_SUMMARY_CONCURRENCY, async (lane) => [
3028
3070
  lane.lane_id,
3029
3071
  await laneHirePresentation(ctx, lane, allLanes),
@@ -3545,6 +3587,7 @@ async function runGrant(ctx, laneId, opts) {
3545
3587
  await dispatchOutpostLaneCommand(ctx, initial, "grant", { hire: String(hireGrant) });
3546
3588
  return;
3547
3589
  }
3590
+ const enforce = await ctx.laneHireGrantsEnabled(initial.company);
3548
3591
  const lockedLaneIds = [
3549
3592
  laneId,
3550
3593
  ...(initial.senior.kind === "lane" && initial.senior.id
@@ -3557,7 +3600,6 @@ async function runGrant(ctx, laneId, opts) {
3557
3600
  throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(current.senior)} may change lane ${laneId}'s hire grant`, { lane_id: laneId, senior: current.senior });
3558
3601
  }
3559
3602
  const hierarchy = loadAllLanes(hqRoot);
3560
- const enforce = ctx.laneHireGrantsEnabled();
3561
3603
  if (enforce && current.senior.kind === "lane") {
3562
3604
  if (!current.senior.id) {
3563
3605
  throw new LanesError("hire_grant_senior_invalid", `Lane ${laneId} has a lane senior without an id`, {
@@ -3611,7 +3653,7 @@ async function runBackfillGrants(ctx, opts) {
3611
3653
  }
3612
3654
  const apply = Boolean(opts.apply);
3613
3655
  const hqRoot = ctx.hqRoot();
3614
- if (apply && ctx.laneHireGrantsEnabled()) {
3656
+ if (apply && await ctx.laneHireGrantsEnabled(null)) {
3615
3657
  throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
3616
3658
  }
3617
3659
  if (apply && ctx.env.HQ_LANE_ID?.trim()) {
@@ -3642,6 +3684,24 @@ async function runBackfillGrants(ctx, opts) {
3642
3684
  }
3643
3685
  let rows = planHireGrantBackfill(allLanes, profileMaxConcurrent);
3644
3686
  if (apply) {
3687
+ const lanesById = new Map(allLanes.map((lane) => [lane.lane_id, lane]));
3688
+ const targetCompanies = new Map();
3689
+ for (const row of rows) {
3690
+ if (row.action === "unchanged")
3691
+ continue;
3692
+ const lane = lanesById.get(row.lane_id);
3693
+ if (!lane) {
3694
+ throw new LanesError("hire_grant_backfill_target_changed", `Backfill target lane ${row.lane_id} is unavailable`, { lane_id: row.lane_id });
3695
+ }
3696
+ const company = lane.company;
3697
+ if (!company)
3698
+ continue;
3699
+ targetCompanies.set(JSON.stringify([company.uid ?? null, company.slug ?? null]), company);
3700
+ }
3701
+ const companyGates = await Promise.all([...targetCompanies.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
3702
+ if (companyGates.some(Boolean)) {
3703
+ throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
3704
+ }
3645
3705
  const shortfalls = rows.filter((row) => row.grant_shortfall > 0);
3646
3706
  if (shortfalls.length > 0) {
3647
3707
  throw new LanesError("hire_grant_backfill_allocation_shortfall", "Existing parent grants do not cover the grants allocated to alive children; resolve these rows before applying the backfill", { lanes: shortfalls });
@@ -4009,7 +4069,7 @@ async function prepareAccountMove(ctx, lane, input) {
4009
4069
  if (admission.action === "refuse" && admission.error)
4010
4070
  throw admission.error;
4011
4071
  const queued = admission.action === "queue";
4012
- const moved = withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
4072
+ const moved = await withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
4013
4073
  // The copy and every check above were made from `lane`; a record
4014
4074
  // that moved on since (another move, a spawn, a cancel) invalidates
4015
4075
  // them, so the write is refused rather than applied to a state it
@@ -4061,7 +4121,7 @@ async function runMessage(ctx, laneId, opts) {
4061
4121
  assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
4062
4122
  // Check before transcript recovery, account probing, or message writes.
4063
4123
  // The final state transition repeats this check under the senior lock.
4064
- withResumeHireAdmission(ctx, lane, () => undefined);
4124
+ await withResumeHireAdmission(ctx, lane, () => undefined);
4065
4125
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
4066
4126
  // An account move is refused on its own terms first: a running lane and a
4067
4127
  // provider without session resume get the move's named errors, not the
@@ -4341,7 +4401,7 @@ async function runResume(ctx, laneId, opts) {
4341
4401
  }
4342
4402
  // Fail before provider/session work if a terminal child would consume a
4343
4403
  // slot that its senior has already assigned elsewhere.
4344
- withResumeHireAdmission(ctx, lane, () => undefined);
4404
+ await withResumeHireAdmission(ctx, lane, () => undefined);
4345
4405
  assertLaneRunsHereForOperatorMutation(ctx, lane, "resume");
4346
4406
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
4347
4407
  if (opts.model !== undefined && opts.account === undefined) {
@@ -4913,14 +4973,19 @@ async function runReparent(ctx, laneId, opts) {
4913
4973
  const nextSenior = parseSeniorRef(opts.senior);
4914
4974
  assertSeniorExists(hqRoot, nextSenior, ctx);
4915
4975
  const initial = readLane(hqRoot, laneId);
4976
+ const needsHireGrantGate = nextSenior.kind === "lane" &&
4977
+ seniorKey(initial.senior) !== seniorKey(nextSenior);
4978
+ const hireGrantsEnabled = needsHireGrantGate
4979
+ ? await ctx.laneHireGrantsEnabled(initial.company)
4980
+ : false;
4916
4981
  const lockIds = [
4917
4982
  laneId,
4918
4983
  ...(initial.senior.kind === "lane" && initial.senior.id ? [initial.senior.id] : []),
4919
4984
  ...(nextSenior.kind === "lane" && nextSenior.id ? [nextSenior.id] : []),
4920
4985
  ];
4921
- withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts));
4986
+ withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts, hireGrantsEnabled));
4922
4987
  }
4923
- function runReparentLocked(ctx, laneId, opts) {
4988
+ function runReparentLocked(ctx, laneId, opts, hireGrantsEnabled) {
4924
4989
  const hqRoot = ctx.hqRoot();
4925
4990
  if (!opts.senior) {
4926
4991
  throw new LanesError("missing_senior", "--senior is required", {});
@@ -4934,7 +4999,7 @@ function runReparentLocked(ctx, laneId, opts) {
4934
4999
  }
4935
5000
  const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
4936
5001
  assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
4937
- if (ctx.laneHireGrantsEnabled() &&
5002
+ if (hireGrantsEnabled &&
4938
5003
  nextSenior.kind === "lane" &&
4939
5004
  seniorKey(lane.senior) !== seniorKey(nextSenior)) {
4940
5005
  const parent = readLane(hqRoot, nextSenior.id);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Scheduled jobs for a bot that runs on this computer.
3
+ *
4
+ * The schedule lives with the bot. A local bot keeps its jobs in `jobs.json`
5
+ * in its own folder and fires them itself, from the same loop that checks its
6
+ * inbox. A due job becomes an ordinary `channel: "job"` inbox item, so it runs
7
+ * through exactly the path a cloud-fired job does (own session, "scheduled
8
+ * job" header in the owner's DM, catch-up label when late). Only the three
9
+ * calls that talk to the cloud about a run are answered here instead:
10
+ * pulling due runs, acking them, and recording how they finished.
11
+ *
12
+ * Rules:
13
+ * - Jobs fire only while the bot is running. After a sleep or an outage, a job
14
+ * runs ONCE for its newest missed time, labelled a catch-up; older missed
15
+ * times are not replayed.
16
+ * - A job never overlaps itself. A new fire waits until the previous run of
17
+ * the same job has finished (or has been silent for an hour).
18
+ * - A fire is recorded before it is handed over, so a crash never runs the
19
+ * same due time twice.
20
+ */
21
+ import { type BotApi, type BotJobRecord, type InboxItem, type JobRunCompletion } from "./api.js";
22
+ export declare const LOCAL_JOBS_FILE = "jobs.json";
23
+ export declare const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
24
+ export interface LocalJob {
25
+ jobId: string;
26
+ prompt: string;
27
+ /** Standard 5-field cron, evaluated in `timezone`. */
28
+ cron: string;
29
+ timezone: string;
30
+ context?: Record<string, unknown>;
31
+ createdAt: string;
32
+ /** The newest due time already handed to the bot. */
33
+ lastFiredAt?: string;
34
+ /** The run in progress, if any. */
35
+ inFlight?: {
36
+ messageId: string;
37
+ since: string;
38
+ };
39
+ lastRunAt?: string;
40
+ lastStatus?: string;
41
+ runCount?: number;
42
+ }
43
+ export declare function localJobsPath(dir: string): string;
44
+ export declare function readLocalJobs(dir: string): LocalJob[];
45
+ export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
46
+ /**
47
+ * Run a read-change-write of `jobs.json` under an exclusive lock. The running
48
+ * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
49
+ * processes; without the lock one can write back a stale copy and silently
50
+ * undo the other's change.
51
+ */
52
+ export declare function withJobsLock<T>(dir: string, fn: () => T): T;
53
+ export interface CronMatcher {
54
+ minutes: Set<number>;
55
+ hours: Set<number>;
56
+ days: Set<number>;
57
+ months: Set<number>;
58
+ weekdays: Set<number>;
59
+ dayRestricted: boolean;
60
+ weekdayRestricted: boolean;
61
+ }
62
+ /**
63
+ * The job's schedule as a standard 5-field cron. Jobs are stored in the same
64
+ * form the cloud uses (`cron(m h dom mon dow year)`, Sunday = 1, `?` for the
65
+ * unused day field), so the same `hq bot jobs` input works for either home.
66
+ */
67
+ export declare function toStandardCron(expr: string): string | null;
68
+ /** Parse a standard 5-field cron, or null when it is not one. */
69
+ export declare function parseCron(expr: string): CronMatcher | null;
70
+ export declare function cronMatches(cron: CronMatcher, date: Date, timeZone: string): boolean;
71
+ /**
72
+ * The newest time this job was due, at or before `now` and after the last one
73
+ * already fired. Null when nothing new is due.
74
+ */
75
+ /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
76
+ export declare function rateIntervalMs(expr: string): number | null;
77
+ export declare function isRunnableSchedule(expr: string): boolean;
78
+ export declare function latestDueFire(job: LocalJob, now: Date): Date | null;
79
+ /** The next time this job is due after `now`, within a week; null otherwise. */
80
+ export declare function nextDueFire(job: LocalJob, now: Date): Date | null;
81
+ export declare function toJobRecord(job: LocalJob, now?: Date): BotJobRecord;
82
+ export declare function addLocalJob(dir: string, input: {
83
+ prompt: string;
84
+ cron: string;
85
+ timezone: string;
86
+ context?: Record<string, unknown>;
87
+ }, now?: Date): LocalJob;
88
+ /** Remove a job. Returns false when the bot has no job with that id. */
89
+ export declare function removeLocalJob(dir: string, jobId: string): boolean;
90
+ /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
91
+ export declare function resetLocalJobChecks(): void;
92
+ export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
93
+ export declare function isLocalJobMessage(messageId: string): boolean;
94
+ /** Record how a local run finished and release the job for its next fire. */
95
+ export declare function finishLocalJobRun(dir: string, jobId: string, messageId: string, completion: Pick<JobRunCompletion, "status">, now?: Date): void;
96
+ /**
97
+ * The bot's cloud client, with its scheduled runs answered on this computer:
98
+ * due local jobs join the inbox, and their ack and completion are recorded
99
+ * locally instead of being sent to the cloud.
100
+ */
101
+ export declare function withLocalJobs(api: BotApi, dir: string, clock?: () => Date): BotApi;
102
+ /** The most jobs one bot may hold (the same cap the cloud uses). */
103
+ export declare const MAX_LOCAL_JOBS = 25;
104
+ /**
105
+ * `hq bot jobs` against the bot's own folder: the same three calls the cloud
106
+ * client offers, answered from `jobs.json`.
107
+ */
108
+ export declare function localJobsClient(dir: string, clock?: () => Date): Pick<BotApi, "listJobs" | "createJob" | "cancelJob">;
109
+ //# sourceMappingURL=local-jobs.d.ts.map
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Scheduled jobs for a bot that runs on this computer.
3
+ *
4
+ * The schedule lives with the bot. A local bot keeps its jobs in `jobs.json`
5
+ * in its own folder and fires them itself, from the same loop that checks its
6
+ * inbox. A due job becomes an ordinary `channel: "job"` inbox item, so it runs
7
+ * through exactly the path a cloud-fired job does (own session, "scheduled
8
+ * job" header in the owner's DM, catch-up label when late). Only the three
9
+ * calls that talk to the cloud about a run are answered here instead:
10
+ * pulling due runs, acking them, and recording how they finished.
11
+ *
12
+ * Rules:
13
+ * - Jobs fire only while the bot is running. After a sleep or an outage, a job
14
+ * runs ONCE for its newest missed time, labelled a catch-up; older missed
15
+ * times are not replayed.
16
+ * - A job never overlaps itself. A new fire waits until the previous run of
17
+ * the same job has finished (or has been silent for an hour).
18
+ * - A fire is recorded before it is handed over, so a crash never runs the
19
+ * same due time twice.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { randomUUID } from "node:crypto";
24
+ import { BotApiError } from "./api.js";
25
+ export const LOCAL_JOBS_FILE = "jobs.json";
26
+ export const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
27
+ /** How far back a missed fire is still looked for (one week). */
28
+ const LOOKBACK_MINUTES = 7 * 24 * 60;
29
+ /** A run that never reported back releases its job after this long. */
30
+ const IN_FLIGHT_RELEASE_MS = 60 * 60 * 1000;
31
+ /** A fire is "late" (a catch-up) once this long past its due time. */
32
+ const STALE_AFTER_MS = 10 * 60 * 1000;
33
+ export function localJobsPath(dir) {
34
+ return path.join(dir, LOCAL_JOBS_FILE);
35
+ }
36
+ export function readLocalJobs(dir) {
37
+ try {
38
+ const parsed = JSON.parse(fs.readFileSync(localJobsPath(dir), "utf8"));
39
+ return Array.isArray(parsed.jobs) ? parsed.jobs.filter((job) => !!job && typeof job.jobId === "string") : [];
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ export function writeLocalJobs(dir, jobs) {
46
+ const file = localJobsPath(dir);
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
49
+ fs.renameSync(tmp, file);
50
+ }
51
+ // ─── Lock ────────────────────────────────────────────────────────────────────
52
+ /** A lock older than this belongs to a process that died holding it. */
53
+ const LOCK_STALE_MS = 30_000;
54
+ const LOCK_WAIT_MS = 5_000;
55
+ function sleepSync(ms) {
56
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
57
+ }
58
+ /**
59
+ * Run a read-change-write of `jobs.json` under an exclusive lock. The running
60
+ * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
61
+ * processes; without the lock one can write back a stale copy and silently
62
+ * undo the other's change.
63
+ */
64
+ export function withJobsLock(dir, fn) {
65
+ const lock = `${localJobsPath(dir)}.lock`;
66
+ const deadline = Date.now() + LOCK_WAIT_MS;
67
+ for (;;) {
68
+ try {
69
+ fs.writeFileSync(lock, String(process.pid), { flag: "wx" });
70
+ break;
71
+ }
72
+ catch (err) {
73
+ if (err.code !== "EEXIST")
74
+ throw err;
75
+ try {
76
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
77
+ fs.rmSync(lock, { force: true });
78
+ continue;
79
+ }
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (Date.now() > deadline)
85
+ throw new Error(`The bot's job list is busy (${lock}). Try again in a moment.`);
86
+ sleepSync(25);
87
+ }
88
+ }
89
+ try {
90
+ return fn();
91
+ }
92
+ finally {
93
+ fs.rmSync(lock, { force: true });
94
+ }
95
+ }
96
+ // ─── Cron ────────────────────────────────────────────────────────────────────
97
+ const FIELD_RANGES = [
98
+ [0, 59], // minute
99
+ [0, 23], // hour
100
+ [1, 31], // day of month
101
+ [1, 12], // month
102
+ [0, 7], // day of week (0 and 7 are Sunday)
103
+ ];
104
+ function parseField(field, [min, max]) {
105
+ const out = new Set();
106
+ for (const part of field.split(",")) {
107
+ const [rangePart, stepPart] = part.split("/");
108
+ const step = stepPart === undefined ? 1 : Number(stepPart);
109
+ if (!Number.isInteger(step) || step < 1)
110
+ return null;
111
+ let lo;
112
+ let hi;
113
+ if (rangePart === "*") {
114
+ lo = min;
115
+ hi = max;
116
+ }
117
+ else if (/^\d+-\d+$/.test(rangePart ?? "")) {
118
+ [lo, hi] = rangePart.split("-").map(Number);
119
+ }
120
+ else if (/^\d+$/.test(rangePart ?? "")) {
121
+ lo = Number(rangePart);
122
+ hi = stepPart === undefined ? lo : max;
123
+ }
124
+ else {
125
+ return null;
126
+ }
127
+ if (lo < min || hi > max || lo > hi)
128
+ return null;
129
+ for (let v = lo; v <= hi; v += step)
130
+ out.add(v);
131
+ }
132
+ return out;
133
+ }
134
+ const DAY_NAMES = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
135
+ const MONTH_NAMES = {
136
+ JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
137
+ };
138
+ function replaceNames(field, names) {
139
+ return field.replace(/[A-Za-z]{3}/g, (name) => {
140
+ const value = names[name.toUpperCase()];
141
+ return value === undefined ? name : String(value);
142
+ });
143
+ }
144
+ /**
145
+ * The job's schedule as a standard 5-field cron. Jobs are stored in the same
146
+ * form the cloud uses (`cron(m h dom mon dow year)`, Sunday = 1, `?` for the
147
+ * unused day field), so the same `hq bot jobs` input works for either home.
148
+ */
149
+ export function toStandardCron(expr) {
150
+ const trimmed = expr.trim();
151
+ const wrapped = /^cron\((.*)\)$/.exec(trimmed);
152
+ if (!wrapped)
153
+ return trimmed.split(/\s+/).length === 5 ? trimmed : null;
154
+ const fields = wrapped[1].trim().split(/\s+/);
155
+ if (fields.length !== 6)
156
+ return null;
157
+ const [minute, hour, dom, month, dow] = fields;
158
+ const standardDow = dow === "?" || dow === "*"
159
+ ? "*"
160
+ : replaceNames(dow, { SUN: 1, MON: 2, TUE: 3, WED: 4, THU: 5, FRI: 6, SAT: 7 }).replace(/\d+/g, (n) => String(Number(n) - 1));
161
+ return [minute, hour, dom === "?" ? "*" : dom, month, standardDow].join(" ");
162
+ }
163
+ /** Parse a standard 5-field cron, or null when it is not one. */
164
+ export function parseCron(expr) {
165
+ const standard = toStandardCron(expr);
166
+ if (!standard)
167
+ return null;
168
+ const fields = standard.split(/\s+/).map((field, i) => i === 3 ? replaceNames(field, MONTH_NAMES) : i === 4 ? replaceNames(field, DAY_NAMES) : field);
169
+ if (fields.length !== 5)
170
+ return null;
171
+ const sets = fields.map((field, i) => parseField(field, FIELD_RANGES[i]));
172
+ if (sets.some((set) => set === null))
173
+ return null;
174
+ const [minutes, hours, days, months, weekdays] = sets;
175
+ if (weekdays.has(7))
176
+ weekdays.add(0);
177
+ return {
178
+ minutes: minutes,
179
+ hours: hours,
180
+ days: days,
181
+ months: months,
182
+ weekdays: weekdays,
183
+ dayRestricted: fields[2] !== "*",
184
+ weekdayRestricted: fields[4] !== "*",
185
+ };
186
+ }
187
+ const WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
188
+ const formatters = new Map();
189
+ function zonedParts(date, timeZone) {
190
+ let format = formatters.get(timeZone);
191
+ if (!format) {
192
+ format = new Intl.DateTimeFormat("en-US", {
193
+ timeZone,
194
+ hour12: false,
195
+ minute: "numeric",
196
+ hour: "numeric",
197
+ day: "numeric",
198
+ month: "numeric",
199
+ weekday: "short",
200
+ });
201
+ formatters.set(timeZone, format);
202
+ }
203
+ const parts = format.formatToParts(date);
204
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
205
+ return {
206
+ minute: Number(get("minute")),
207
+ hour: Number(get("hour")) % 24,
208
+ day: Number(get("day")),
209
+ month: Number(get("month")),
210
+ weekday: WEEKDAY[get("weekday")] ?? -1,
211
+ };
212
+ }
213
+ export function cronMatches(cron, date, timeZone) {
214
+ const t = zonedParts(date, timeZone);
215
+ if (!cron.minutes.has(t.minute) || !cron.hours.has(t.hour) || !cron.months.has(t.month))
216
+ return false;
217
+ const dayOk = cron.days.has(t.day);
218
+ const weekdayOk = cron.weekdays.has(t.weekday);
219
+ // Standard cron: when both day fields are restricted, either may match.
220
+ if (cron.dayRestricted && cron.weekdayRestricted)
221
+ return dayOk || weekdayOk;
222
+ return dayOk && weekdayOk;
223
+ }
224
+ /**
225
+ * The newest time this job was due, at or before `now` and after the last one
226
+ * already fired. Null when nothing new is due.
227
+ */
228
+ /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
229
+ export function rateIntervalMs(expr) {
230
+ const match = /^rate\((\d+)\s+(minute|minutes|hour|hours|day|days)\)$/.exec(expr.trim());
231
+ if (!match)
232
+ return null;
233
+ const n = Number(match[1]);
234
+ const unit = match[2].startsWith("minute") ? 60_000 : match[2].startsWith("hour") ? 3_600_000 : 86_400_000;
235
+ return n > 0 ? n * unit : null;
236
+ }
237
+ export function isRunnableSchedule(expr) {
238
+ return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
239
+ }
240
+ export function latestDueFire(job, now) {
241
+ const interval = rateIntervalMs(job.cron);
242
+ if (interval) {
243
+ const origin = Date.parse(job.createdAt);
244
+ const steps = Math.floor((now.getTime() - origin) / interval);
245
+ if (steps < 1)
246
+ return null;
247
+ const due = origin + steps * interval;
248
+ return job.lastFiredAt && Date.parse(job.lastFiredAt) >= due ? null : new Date(due);
249
+ }
250
+ const cron = parseCron(job.cron);
251
+ if (!cron)
252
+ return null;
253
+ const start = new Date(now);
254
+ start.setUTCSeconds(0, 0);
255
+ const floor = Math.max(job.lastFiredAt ? Date.parse(job.lastFiredAt) : Date.parse(job.createdAt), start.getTime() - LOOKBACK_MINUTES * 60_000);
256
+ for (let t = start.getTime(); t > floor; t -= 60_000) {
257
+ const at = new Date(t);
258
+ if (cronMatches(cron, at, job.timezone))
259
+ return at;
260
+ }
261
+ return null;
262
+ }
263
+ /** The next time this job is due after `now`, within a week; null otherwise. */
264
+ export function nextDueFire(job, now) {
265
+ const interval = rateIntervalMs(job.cron);
266
+ if (interval) {
267
+ const origin = Date.parse(job.createdAt);
268
+ const steps = Math.max(0, Math.floor((now.getTime() - origin) / interval)) + 1;
269
+ return new Date(origin + steps * interval);
270
+ }
271
+ const cron = parseCron(job.cron);
272
+ if (!cron)
273
+ return null;
274
+ const start = new Date(now);
275
+ start.setUTCSeconds(0, 0);
276
+ for (let i = 1; i <= LOOKBACK_MINUTES; i += 1) {
277
+ const at = new Date(start.getTime() + i * 60_000);
278
+ if (cronMatches(cron, at, job.timezone))
279
+ return at;
280
+ }
281
+ return null;
282
+ }
283
+ // ─── The job list (what `hq bot jobs` reads and writes) ──────────────────────
284
+ export function toJobRecord(job, now = new Date()) {
285
+ const next = nextDueFire(job, now);
286
+ return {
287
+ jobId: job.jobId,
288
+ prompt: job.prompt,
289
+ status: "active",
290
+ schedule: { kind: "recurring", cron: job.cron, timezone: job.timezone },
291
+ ...(job.context ? { context: job.context } : {}),
292
+ createdAt: job.createdAt,
293
+ ...(next ? { nextRunAt: next.toISOString() } : {}),
294
+ ...(job.lastRunAt ? { lastRunAt: job.lastRunAt } : {}),
295
+ ...(job.lastStatus ? { lastStatus: job.lastStatus } : {}),
296
+ ...(job.runCount ? { runCount: job.runCount } : {}),
297
+ };
298
+ }
299
+ export function addLocalJob(dir, input, now = new Date()) {
300
+ if (!isRunnableSchedule(input.cron))
301
+ throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
302
+ const job = {
303
+ jobId: `job_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
304
+ prompt: input.prompt,
305
+ cron: input.cron,
306
+ timezone: input.timezone,
307
+ ...(input.context ? { context: input.context } : {}),
308
+ createdAt: now.toISOString(),
309
+ };
310
+ withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobs(dir), job]));
311
+ return job;
312
+ }
313
+ /** Remove a job. Returns false when the bot has no job with that id. */
314
+ export function removeLocalJob(dir, jobId) {
315
+ return withJobsLock(dir, () => {
316
+ const jobs = readLocalJobs(dir);
317
+ const next = jobs.filter((job) => job.jobId !== jobId);
318
+ if (next.length === jobs.length)
319
+ return false;
320
+ writeLocalJobs(dir, next);
321
+ return true;
322
+ });
323
+ }
324
+ // ─── Firing ──────────────────────────────────────────────────────────────────
325
+ /**
326
+ * Hand over every job that is due now. Each fire is recorded (lastFiredAt and
327
+ * inFlight) before its inbox item is returned.
328
+ */
329
+ const lastCheckedMinute = new Map();
330
+ /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
331
+ export function resetLocalJobChecks() {
332
+ lastCheckedMinute.clear();
333
+ }
334
+ export function takeDueLocalJobRuns(dir, now = new Date()) {
335
+ // Schedules have minute resolution and the inbox is polled every few
336
+ // seconds: look at the jobs once per minute.
337
+ const minute = Math.floor(now.getTime() / 60_000);
338
+ if (lastCheckedMinute.get(dir) === minute)
339
+ return [];
340
+ lastCheckedMinute.set(dir, minute);
341
+ return withJobsLock(dir, () => {
342
+ const jobs = readLocalJobs(dir);
343
+ if (jobs.length === 0)
344
+ return [];
345
+ const items = [];
346
+ let changed = false;
347
+ const next = jobs.map((job) => {
348
+ if (job.inFlight && now.getTime() - Date.parse(job.inFlight.since) < IN_FLIGHT_RELEASE_MS)
349
+ return job;
350
+ const due = latestDueFire(job, now);
351
+ if (!due) {
352
+ if (job.inFlight) {
353
+ changed = true;
354
+ return { ...job, inFlight: undefined };
355
+ }
356
+ return job;
357
+ }
358
+ const firedAt = due.toISOString();
359
+ const messageId = `${LOCAL_JOB_MESSAGE_PREFIX}${job.jobId}-${due.getTime()}`;
360
+ items.push({
361
+ messageId,
362
+ channel: "job",
363
+ jobId: job.jobId,
364
+ prompt: job.prompt,
365
+ ...(job.context ? { context: job.context } : {}),
366
+ firedAt,
367
+ staleAfter: new Date(due.getTime() + STALE_AFTER_MS).toISOString(),
368
+ receivedAt: now.toISOString(),
369
+ });
370
+ changed = true;
371
+ return { ...job, lastFiredAt: firedAt, inFlight: { messageId, since: now.toISOString() } };
372
+ });
373
+ if (changed)
374
+ writeLocalJobs(dir, next);
375
+ return items;
376
+ });
377
+ }
378
+ export function isLocalJobMessage(messageId) {
379
+ return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
380
+ }
381
+ /** Record how a local run finished and release the job for its next fire. */
382
+ export function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
383
+ withJobsLock(dir, () => {
384
+ const jobs = readLocalJobs(dir);
385
+ let changed = false;
386
+ const next = jobs.map((job) => {
387
+ if (job.jobId !== jobId)
388
+ return job;
389
+ changed = true;
390
+ const releases = !job.inFlight || job.inFlight.messageId === messageId;
391
+ return {
392
+ ...job,
393
+ ...(releases ? { inFlight: undefined } : {}),
394
+ lastRunAt: now.toISOString(),
395
+ lastStatus: completion.status,
396
+ runCount: (job.runCount ?? 0) + (completion.status === "succeeded" || completion.status === "failed" ? 1 : 0),
397
+ };
398
+ });
399
+ if (changed)
400
+ writeLocalJobs(dir, next);
401
+ });
402
+ }
403
+ /**
404
+ * The bot's cloud client, with its scheduled runs answered on this computer:
405
+ * due local jobs join the inbox, and their ack and completion are recorded
406
+ * locally instead of being sent to the cloud.
407
+ */
408
+ export function withLocalJobs(api, dir, clock = () => new Date()) {
409
+ const local = Object.create(api);
410
+ local.pullInbox = async (agentUid) => {
411
+ // Cloud first: if that call fails, nothing local has been taken yet, so
412
+ // no due run is recorded as fired and then dropped.
413
+ const cloud = await api.pullInbox(agentUid);
414
+ return [...cloud, ...takeDueLocalJobRuns(dir, clock())];
415
+ };
416
+ local.ackInbox = async (agentUid, messageId) => {
417
+ if (isLocalJobMessage(messageId))
418
+ return;
419
+ await api.ackInbox(agentUid, messageId);
420
+ };
421
+ local.completeJobRun = async (agentUid, jobId, messageId, completion) => {
422
+ if (isLocalJobMessage(messageId)) {
423
+ finishLocalJobRun(dir, jobId, messageId, completion, clock());
424
+ return;
425
+ }
426
+ await api.completeJobRun(agentUid, jobId, messageId, completion);
427
+ };
428
+ return local;
429
+ }
430
+ /** The most jobs one bot may hold (the same cap the cloud uses). */
431
+ export const MAX_LOCAL_JOBS = 25;
432
+ /**
433
+ * `hq bot jobs` against the bot's own folder: the same three calls the cloud
434
+ * client offers, answered from `jobs.json`.
435
+ */
436
+ export function localJobsClient(dir, clock = () => new Date()) {
437
+ return {
438
+ async listJobs() {
439
+ const now = clock();
440
+ return readLocalJobs(dir).map((job) => toJobRecord(job, now));
441
+ },
442
+ async createJob(_agentUid, input) {
443
+ if (input.schedule.kind !== "recurring") {
444
+ throw new BotApiError(400, "local jobs", "JOB_VALIDATION", "A local bot runs recurring jobs only.");
445
+ }
446
+ if (readLocalJobs(dir).length >= MAX_LOCAL_JOBS) {
447
+ throw new BotApiError(409, "local jobs", "JOB_QUOTA_EXCEEDED", `at most ${MAX_LOCAL_JOBS} jobs`);
448
+ }
449
+ const job = addLocalJob(dir, { prompt: input.prompt, cron: input.schedule.cron, timezone: input.schedule.timezone, ...(input.context ? { context: input.context } : {}) }, clock());
450
+ return toJobRecord(job, clock());
451
+ },
452
+ async cancelJob(_agentUid, jobId) {
453
+ if (!removeLocalJob(dir, jobId)) {
454
+ throw new BotApiError(404, "local jobs", "JOB_NOT_FOUND", `Job '${jobId}' not found`);
455
+ }
456
+ },
457
+ };
458
+ }
459
+ //# sourceMappingURL=local-jobs.js.map
@@ -0,0 +1,27 @@
1
+ import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
2
+ import type { LaneCompanyRef } from "./types.js";
3
+ export declare const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
4
+ export declare const LANES_FLAG_READ_TIMEOUT_MS = 5000;
5
+ export interface LanesFlagClient {
6
+ ready(): Promise<void>;
7
+ explain(flagKey: string, options?: {
8
+ fallback?: boolean;
9
+ }): FlagResolution;
10
+ close(): void;
11
+ }
12
+ export type LanesFlagClientFactory = (options: FlagClientOptions) => LanesFlagClient;
13
+ export interface ReadLanesFlagOptions {
14
+ company?: LaneCompanyRef | null;
15
+ env?: NodeJS.ProcessEnv;
16
+ createClient?: LanesFlagClientFactory;
17
+ getToken?: () => string | Promise<string>;
18
+ reportFailure?: (error: unknown) => void;
19
+ }
20
+ /**
21
+ * Read a lanes flag once for each company identity in this CLI process. The
22
+ * cache contains only an in-flight or settled boolean promise; it is never
23
+ * written to disk. A local override is checked on every call and wins first.
24
+ */
25
+ export declare function readLanesRegistryFlag(flagKey: string, options?: ReadLanesFlagOptions): Promise<boolean>;
26
+ export declare function _resetLanesRegistryFlagCacheForTests(): void;
27
+ //# sourceMappingURL=registry-flag.d.ts.map
@@ -0,0 +1,129 @@
1
+ import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
2
+ import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
3
+ export const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
4
+ export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
5
+ const registryReads = new Map();
6
+ class MissingFlagTokenError extends Error {
7
+ constructor() {
8
+ super("No cached HQ identity token is available");
9
+ this.name = "MissingFlagTokenError";
10
+ }
11
+ }
12
+ class MissingFlagRowError extends Error {
13
+ constructor(flagKey) {
14
+ super(`The hq-flags registry did not resolve ${flagKey}`);
15
+ this.name = "MissingFlagRowError";
16
+ }
17
+ }
18
+ function errorClass(error) {
19
+ return error instanceof Error ? error.name : "UnknownError";
20
+ }
21
+ function reportReadFailure(flagKey, error) {
22
+ const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
23
+ process.stderr.write(`${JSON.stringify({
24
+ event: `lanes.${suffix}.flag_read_failed`,
25
+ flag: flagKey,
26
+ error: errorClass(error),
27
+ fallback: false,
28
+ })}\n`);
29
+ }
30
+ function cacheKey(flagKey, company, endpoint) {
31
+ return JSON.stringify([
32
+ flagKey,
33
+ endpoint,
34
+ company?.uid ?? null,
35
+ company?.slug ?? null,
36
+ ]);
37
+ }
38
+ /**
39
+ * Read a lanes flag once for each company identity in this CLI process. The
40
+ * cache contains only an in-flight or settled boolean promise; it is never
41
+ * written to disk. A local override is checked on every call and wins first.
42
+ */
43
+ export async function readLanesRegistryFlag(flagKey, options = {}) {
44
+ const env = options.env ?? process.env;
45
+ const localOverride = readLocalOverride(flagKey, env);
46
+ if (localOverride !== null)
47
+ return localOverride;
48
+ const endpoint = env.HQ_FLAGS_API_URL?.trim() || LANES_FLAG_API_DEFAULT_ENDPOINT;
49
+ const key = cacheKey(flagKey, options.company, endpoint);
50
+ const cached = registryReads.get(key);
51
+ if (cached)
52
+ return cached;
53
+ const pending = readRegistryValue(flagKey, endpoint, options);
54
+ registryReads.set(key, pending);
55
+ return pending;
56
+ }
57
+ async function readRegistryValue(flagKey, endpoint, options) {
58
+ const company = options.company;
59
+ const companyUid = company?.uid?.trim();
60
+ const companySlug = company?.slug?.trim();
61
+ let client;
62
+ let enabled = false;
63
+ let failed = false;
64
+ let reported = false;
65
+ const reportFailure = (error) => {
66
+ failed = true;
67
+ if (reported)
68
+ return;
69
+ reported = true;
70
+ (options.reportFailure ?? ((cause) => reportReadFailure(flagKey, cause)))(error);
71
+ };
72
+ try {
73
+ const acquireToken = options.getToken ?? (async () => {
74
+ try {
75
+ return await ensureCognitoIdToken({ interactive: false });
76
+ }
77
+ catch (error) {
78
+ if (error instanceof Error &&
79
+ error.message.startsWith("No valid HQ session")) {
80
+ throw new MissingFlagTokenError();
81
+ }
82
+ throw error;
83
+ }
84
+ });
85
+ const getToken = async () => {
86
+ const token = await acquireToken();
87
+ if (typeof token !== "string" || token.trim().length === 0) {
88
+ throw new MissingFlagTokenError();
89
+ }
90
+ return token;
91
+ };
92
+ client = (options.createClient ?? createFlagClient)({
93
+ endpoint,
94
+ getToken,
95
+ env: {},
96
+ refreshIntervalMs: 0,
97
+ requestTimeoutMs: LANES_FLAG_READ_TIMEOUT_MS,
98
+ ...(companyUid ? { companyUid } : {}),
99
+ ...(companySlug ? { companyIdentifiers: [companySlug] } : {}),
100
+ onError: reportFailure,
101
+ });
102
+ await client.ready();
103
+ const resolution = client.explain(flagKey, { fallback: false });
104
+ if (failed)
105
+ return false;
106
+ if (resolution.source !== "snapshot" && resolution.source !== "local-override") {
107
+ reportFailure(new MissingFlagRowError(flagKey));
108
+ }
109
+ else {
110
+ enabled = resolution.value;
111
+ }
112
+ }
113
+ catch (error) {
114
+ reportFailure(error);
115
+ }
116
+ finally {
117
+ try {
118
+ client?.close();
119
+ }
120
+ catch (error) {
121
+ reportFailure(error);
122
+ }
123
+ }
124
+ return failed ? false : enabled;
125
+ }
126
+ export function _resetLanesRegistryFlagCacheForTests() {
127
+ registryReads.clear();
128
+ }
129
+ //# sourceMappingURL=registry-flag.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.185.0",
3
+ "version": "5.186.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {