@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 +16 -0
- package/dist/commands/bot-jobs.js +7 -11
- package/dist/commands/bot.js +3 -1
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +85 -20
- package/dist/lib/bot/local-jobs.d.ts +109 -0
- package/dist/lib/bot/local-jobs.js +459 -0
- package/dist/lib/lanes/registry-flag.d.ts +27 -0
- package/dist/lib/lanes/registry-flag.js +129 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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(
|
|
424
|
-
|
|
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;
|
package/dist/commands/bot.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|