@indigoai-us/hq-cli 5.345.191 → 5.345.194
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 +431 -0
- package/assets/bot-workers/setup/worker.yaml +91 -34
- package/dist/command-catalog.generated.d.ts +145 -19
- package/dist/command-catalog.generated.js +184 -21
- package/dist/commands/bot-jobs.d.ts +21 -18
- package/dist/commands/bot-jobs.js +127 -39
- package/dist/commands/bot.d.ts +4 -3
- package/dist/commands/bot.js +265 -140
- package/dist/commands/core-jobs-schedule.js +10 -0
- package/dist/commands/daemon.js +37 -0
- package/dist/commands/files.d.ts +41 -6
- package/dist/commands/files.js +108 -27
- package/dist/commands/pack-start-bot.d.ts +4 -6
- package/dist/commands/pack-start-bot.js +4 -8
- package/dist/commands/signals-run.d.ts +46 -0
- package/dist/commands/signals-run.js +180 -0
- package/dist/commands/signals.d.ts +1 -0
- package/dist/commands/signals.js +3 -0
- package/dist/commands/sources.d.ts +58 -0
- package/dist/commands/sources.js +294 -26
- package/dist/commands/staff/reset-test-account.d.ts +10 -0
- package/dist/commands/staff/reset-test-account.js +18 -2
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +35 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/api.d.ts +3 -2
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-cron.d.ts +121 -0
- package/dist/lib/bot/fleet-cron.js +319 -0
- package/dist/lib/bot/fleet-local.d.ts +93 -1
- package/dist/lib/bot/fleet-local.js +546 -34
- package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
- package/dist/lib/bot/fleet-owner-companies.js +30 -0
- package/dist/lib/bot/fleet-watch.d.ts +25 -0
- package/dist/lib/bot/fleet-watch.js +51 -0
- package/dist/lib/bot/index.d.ts +1 -1
- package/dist/lib/bot/index.js +1 -1
- package/dist/lib/bot/job-run.d.ts +5 -4
- package/dist/lib/bot/job-run.js +5 -4
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
- package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
- package/dist/lib/bot/local-jobs.d.ts +13 -2
- package/dist/lib/bot/local-jobs.js +17 -4
- package/dist/lib/bot/progress.d.ts +14 -0
- package/dist/lib/bot/progress.js +47 -1
- package/dist/lib/bot/run.d.ts +55 -2
- package/dist/lib/bot/run.js +192 -10
- package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
- package/dist/lib/bot/setup-tool-offer.js +16 -0
- package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
- package/dist/lib/bot/signals-engine-flag.js +30 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +29 -9
- package/dist/lib/daemon/legacy.d.ts +6 -0
- package/dist/lib/daemon/legacy.js +16 -10
- package/dist/lib/daemon/run.d.ts +1 -0
- package/dist/lib/daemon/run.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.d.ts +13 -1
- package/dist/lib/flag-registry.js +46 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
- package/dist/lib/hot-channels/adapters/attio.js +367 -29
- package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
- package/dist/lib/hot-channels/adapters/gmail.js +59 -2
- package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
- package/dist/lib/hot-channels/adapters/linear.js +71 -1
- package/dist/lib/hot-channels/adapters/meetings.js +4 -1
- package/dist/lib/hot-channels/adapters/slack.js +7 -3
- package/dist/lib/hot-channels/alert.js +1 -1
- package/dist/lib/hot-channels/contract.d.ts +11 -0
- package/dist/lib/hot-channels/contract.js +1 -0
- package/dist/lib/hot-channels/cursors.js +1 -1
- package/dist/lib/hot-channels/landing.d.ts +6 -2
- package/dist/lib/hot-channels/landing.js +13 -6
- package/dist/lib/hot-channels/ledger.js +2 -0
- package/dist/lib/hot-channels/loop.d.ts +6 -0
- package/dist/lib/hot-channels/loop.js +19 -4
- package/dist/lib/hot-channels/registry.js +9 -1
- package/dist/lib/hot-channels/run.d.ts +18 -0
- package/dist/lib/hot-channels/run.js +15 -4
- package/dist/lib/hot-channels/signals-pass.d.ts +34 -2
- package/dist/lib/hot-channels/signals-pass.js +149 -38
- package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
- package/dist/lib/hot-channels/signals-redo.js +207 -0
- package/dist/lib/hot-channels/sync-lock.js +22 -6
- package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
- package/dist/lib/mesh/live/daemon/install.js +117 -30
- package/dist/lib/signals/adapters-catalog.d.ts +77 -0
- package/dist/lib/signals/adapters-catalog.js +194 -0
- package/dist/lib/signals/engine.d.ts +202 -0
- package/dist/lib/signals/engine.js +504 -0
- package/dist/lib/signals/failure-alert.d.ts +89 -0
- package/dist/lib/signals/failure-alert.js +175 -0
- package/dist/lib/signals/local-date.d.ts +3 -0
- package/dist/lib/signals/local-date.js +6 -0
- package/dist/lib/signals/projection.d.ts +42 -0
- package/dist/lib/signals/projection.js +67 -0
- package/dist/lib/signals/reply.d.ts +112 -0
- package/dist/lib/signals/reply.js +368 -0
- package/dist/lib/signals/run-lock.d.ts +36 -0
- package/dist/lib/signals/run-lock.js +148 -0
- package/dist/lib/signals/run-record.d.ts +24 -0
- package/dist/lib/signals/run-record.js +82 -0
- package/dist/lib/signals/sanitize.d.ts +105 -0
- package/dist/lib/signals/sanitize.js +439 -0
- package/dist/lib/signals/settings.d.ts +10 -0
- package/dist/lib/signals/settings.js +48 -0
- package/dist/lib/signals/steps/adapters.d.ts +14 -0
- package/dist/lib/signals/steps/adapters.js +156 -0
- package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
- package/dist/lib/signals/steps/entity-similarity.js +47 -0
- package/dist/lib/signals/steps/extract.d.ts +118 -0
- package/dist/lib/signals/steps/extract.js +793 -0
- package/dist/lib/signals/steps/garden.d.ts +24 -0
- package/dist/lib/signals/steps/garden.js +250 -0
- package/dist/lib/signals/steps/types.d.ts +117 -0
- package/dist/lib/signals/steps/types.js +4 -0
- package/dist/lib/signals/steps/upload.d.ts +43 -0
- package/dist/lib/signals/steps/upload.js +324 -0
- package/dist/lib/signals/write-guard.d.ts +63 -0
- package/dist/lib/signals/write-guard.js +287 -0
- package/dist/utils/resident-package-preload.js +16 -0
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +1 -1
- package/package.json +9 -9
- package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
- package/dist/lib/bot/fleet-default-flag.js +0 -127
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface OwnerMembership {
|
|
2
|
+
companyUid: string;
|
|
3
|
+
companySlug?: string;
|
|
4
|
+
status: string;
|
|
5
|
+
}
|
|
6
|
+
export interface FleetOwnerCompaniesDependencies {
|
|
7
|
+
/** The signed-in owner's memberships; never prompts for sign-in. */
|
|
8
|
+
memberships?: () => Promise<OwnerMembership[]>;
|
|
9
|
+
}
|
|
10
|
+
/** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
|
|
11
|
+
export declare function fleetOwnerCompanies(dependencies?: FleetOwnerCompaniesDependencies): Promise<string[]>;
|
|
12
|
+
//# sourceMappingURL=fleet-owner-companies.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
2
|
+
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
3
|
+
const MEMBERSHIP_TIMEOUT_MS = 10_000;
|
|
4
|
+
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
5
|
+
const COMPANY_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
6
|
+
async function signedInMemberships() {
|
|
7
|
+
const token = await ensureCognitoToken({ tokenSource: "person", interactive: false });
|
|
8
|
+
const res = await vaultApiFetch({ token, path: "/membership/me", signal: AbortSignal.timeout(MEMBERSHIP_TIMEOUT_MS) });
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(`membership lookup returned ${res.status}`);
|
|
11
|
+
const data = (await res.json());
|
|
12
|
+
if (!Array.isArray(data.memberships))
|
|
13
|
+
throw new Error("membership lookup returned no list");
|
|
14
|
+
return data.memberships;
|
|
15
|
+
}
|
|
16
|
+
/** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
|
|
17
|
+
export async function fleetOwnerCompanies(dependencies = {}) {
|
|
18
|
+
const memberships = await (dependencies.memberships ?? signedInMemberships)();
|
|
19
|
+
const companies = new Set();
|
|
20
|
+
for (const membership of memberships) {
|
|
21
|
+
const slug = membership.companySlug?.trim() ?? "";
|
|
22
|
+
if (membership.status !== "active" || !COMPANY_UID_RE.test(membership.companyUid))
|
|
23
|
+
continue;
|
|
24
|
+
if (!COMPANY_SLUG_RE.test(slug) || slug === "personal")
|
|
25
|
+
continue;
|
|
26
|
+
companies.add(slug);
|
|
27
|
+
}
|
|
28
|
+
return [...companies].sort((a, b) => a.localeCompare(b));
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=fleet-owner-companies.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type BotConfig } from "./config.js";
|
|
2
|
+
import type { FleetLocalReleasePin, FleetLocalTargetRelease } from "./fleet-local.js";
|
|
3
|
+
export interface FleetWatchBot {
|
|
4
|
+
name: string;
|
|
5
|
+
dir: string;
|
|
6
|
+
config: BotConfig;
|
|
7
|
+
}
|
|
8
|
+
export interface FleetWatchDeps {
|
|
9
|
+
listBots(): FleetWatchBot[];
|
|
10
|
+
target(): Promise<FleetLocalTargetRelease | null>;
|
|
11
|
+
installed(bot: FleetWatchBot): boolean;
|
|
12
|
+
heldForPromotion(bot: FleetWatchBot): boolean;
|
|
13
|
+
needsAdoption(bot: FleetWatchBot, release: FleetLocalReleasePin): boolean;
|
|
14
|
+
busy(bot: FleetWatchBot): string | null;
|
|
15
|
+
update(bot: FleetWatchBot): Promise<"unchanged" | "updated" | "waiting">;
|
|
16
|
+
log(line: string): void;
|
|
17
|
+
}
|
|
18
|
+
export interface FleetWatchResult {
|
|
19
|
+
updated: string[];
|
|
20
|
+
waiting: string[];
|
|
21
|
+
failed: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function listFleetBots(root: string): FleetWatchBot[];
|
|
24
|
+
export declare function runFleetBotsUpdate(deps: FleetWatchDeps): Promise<FleetWatchResult>;
|
|
25
|
+
//# sourceMappingURL=fleet-watch.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { HQ_FLEET_RUNTIME_ENGINE_ID, readBotConfig } from "./config.js";
|
|
4
|
+
export function listFleetBots(root) {
|
|
5
|
+
if (!fs.existsSync(root))
|
|
6
|
+
return [];
|
|
7
|
+
return fs
|
|
8
|
+
.readdirSync(root, { withFileTypes: true })
|
|
9
|
+
.filter((entry) => entry.isDirectory())
|
|
10
|
+
.map((entry) => ({ name: entry.name, dir: path.join(root, entry.name), config: readBotConfig(path.join(root, entry.name)) }))
|
|
11
|
+
.filter((bot) => bot.config !== null && bot.config.engine?.id === HQ_FLEET_RUNTIME_ENGINE_ID)
|
|
12
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
13
|
+
}
|
|
14
|
+
export async function runFleetBotsUpdate(deps) {
|
|
15
|
+
const result = { updated: [], waiting: [], failed: [] };
|
|
16
|
+
const candidates = deps
|
|
17
|
+
.listBots()
|
|
18
|
+
.filter((bot) => bot.config.fleetAuthPending !== true && !deps.heldForPromotion(bot) && deps.installed(bot));
|
|
19
|
+
if (candidates.length === 0)
|
|
20
|
+
return result;
|
|
21
|
+
const target = await deps.target();
|
|
22
|
+
const version = target?.release.version ?? "release";
|
|
23
|
+
for (const bot of candidates) {
|
|
24
|
+
if (bot.config.fleetRestartPending !== true && (!target || !deps.needsAdoption(bot, target.release)))
|
|
25
|
+
continue;
|
|
26
|
+
const busy = deps.busy(bot);
|
|
27
|
+
if (busy) {
|
|
28
|
+
deps.log(`${bot.name}: HQ Fleet Local ${version} is ready; waiting because ${busy}; trying again next run`);
|
|
29
|
+
result.waiting.push(bot.name);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const outcome = await deps.update(bot);
|
|
34
|
+
if (outcome === "waiting") {
|
|
35
|
+
deps.log(`${bot.name}: HQ Fleet Local ${version} is installed; the restart waits until the bot is idle; trying again next run`);
|
|
36
|
+
result.waiting.push(bot.name);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (outcome === "unchanged")
|
|
40
|
+
continue;
|
|
41
|
+
deps.log(`${bot.name}: moved to HQ Fleet Local ${version} and restarted`);
|
|
42
|
+
result.updated.push(bot.name);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
deps.log(`${bot.name}: could not move to HQ Fleet Local ${version}: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
result.failed.push(bot.name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=fleet-watch.js.map
|
package/dist/lib/bot/index.d.ts
CHANGED
|
@@ -26,6 +26,6 @@ export * from "./lock.js";
|
|
|
26
26
|
export * from "./display-name.js";
|
|
27
27
|
export * from "./decisions.js";
|
|
28
28
|
export * from "./fleet-local.js";
|
|
29
|
-
export * from "./fleet-
|
|
29
|
+
export * from "./fleet-owner-companies.js";
|
|
30
30
|
export * from "./fleet-python.js";
|
|
31
31
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/lib/bot/index.js
CHANGED
|
@@ -26,6 +26,6 @@ export * from "./lock.js";
|
|
|
26
26
|
export * from "./display-name.js";
|
|
27
27
|
export * from "./decisions.js";
|
|
28
28
|
export * from "./fleet-local.js";
|
|
29
|
-
export * from "./fleet-
|
|
29
|
+
export * from "./fleet-owner-companies.js";
|
|
30
30
|
export * from "./fleet-python.js";
|
|
31
31
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scheduled job runs: the third kind of inbox item a bot can be handed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A local bot's jobs live in its own jobs.json and its inbox loop fires them
|
|
5
|
+
* (lib/bot/local-jobs.ts): a due job becomes a `channel: "job"` inbox item
|
|
6
|
+
* carrying the job's own prompt. A hosted agent's job arrives the same way,
|
|
7
|
+
* written by hq-pro-agents from its cloud schedule. Either way there is no
|
|
8
|
+
* crontab, no launchd timer and no system scheduler on this computer.
|
|
8
9
|
*
|
|
9
10
|
* Two rules shape everything here (owner decisions, 2026-09-15):
|
|
10
11
|
*
|
package/dist/lib/bot/job-run.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scheduled job runs: the third kind of inbox item a bot can be handed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A local bot's jobs live in its own jobs.json and its inbox loop fires them
|
|
5
|
+
* (lib/bot/local-jobs.ts): a due job becomes a `channel: "job"` inbox item
|
|
6
|
+
* carrying the job's own prompt. A hosted agent's job arrives the same way,
|
|
7
|
+
* written by hq-pro-agents from its cloud schedule. Either way there is no
|
|
8
|
+
* crontab, no launchd timer and no system scheduler on this computer.
|
|
8
9
|
*
|
|
9
10
|
* Two rules shape everything here (owner decisions, 2026-09-15):
|
|
10
11
|
*
|
|
@@ -15,8 +15,19 @@
|
|
|
15
15
|
* `hq bot jobs <bot> --reschedule <jobId> --every "<when>"`, or removes it with
|
|
16
16
|
* `--rm`, and a removed job is not added back on the next start.
|
|
17
17
|
*
|
|
18
|
-
* When the job fires the bot runs the
|
|
19
|
-
* (
|
|
18
|
+
* When the job fires the bot runs the signals engine in-process (US-006):
|
|
19
|
+
* `runSignals({ trigger: "job", catchUp })`, the same engine `hq signals run`
|
|
20
|
+
* uses (garden -> adapters -> extract -> garden -> upload, with the caps from
|
|
21
|
+
* core/settings/signals.yaml and a run record), and reports the run to the
|
|
22
|
+
* owner in the storyboarded reply format (lib/signals/reply.ts). When a run
|
|
23
|
+
* fails with a transient error, the run record carries `nextRetryAt` and the
|
|
24
|
+
* bot runs the job once more at that time, in-process (lib/bot/run.ts); only
|
|
25
|
+
* then does the failure reply offer to wait for the retry. The run started
|
|
26
|
+
* right after registration uses the `manual` trigger, so it never counts as
|
|
27
|
+
* the day's scheduled job. Extract
|
|
28
|
+
* spawns the member's own claude/codex under the bot's login, so the
|
|
29
|
+
* orchestration itself has no model turn. Without an engine wired in, the
|
|
30
|
+
* job falls back to the sync alone: the same hot-channel sync `hq sources sync` runs,
|
|
20
31
|
* with `trigger: "scheduled"` and `catchUp` set when the run fired late after
|
|
21
32
|
* a missed window. Catch-up is bounded by each adapter's `catchUpWindow`, and
|
|
22
33
|
* per-connection cursors keep a redelivered run from landing anything twice.
|
|
@@ -26,15 +37,23 @@
|
|
|
26
37
|
import type { HotChannelRunResult, WriteGuard } from "../../hot-channels/loop.js";
|
|
27
38
|
import type { RunTrigger } from "../../hot-channels/ledger.js";
|
|
28
39
|
import type { OnDemandSyncReport } from "../../hot-channels/run.js";
|
|
40
|
+
import { type RunSignalsOptions, type RunSignalsResult } from "../../signals/engine.js";
|
|
41
|
+
import { type FormattedReply } from "../../signals/reply.js";
|
|
29
42
|
import { type BotConfig } from "../config.js";
|
|
30
43
|
import { type LocalJob } from "../local-jobs.js";
|
|
31
44
|
export declare const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
|
|
32
45
|
/**
|
|
33
|
-
* 06:30 every day, in the job's time zone (the stored
|
|
46
|
+
* 06:30 every day, in the job's time zone (the stored six-field form of
|
|
34
47
|
* "30 6 * * *"). Only the first registration uses it; after that the job's
|
|
35
48
|
* own schedule in `jobs.json` is the one that runs.
|
|
36
49
|
*/
|
|
37
50
|
export declare const HOT_SIGNAL_SYNC_DEFAULT_CRON = "cron(30 6 * * ? *)";
|
|
51
|
+
export declare function hotSignalSyncRegistered(dir: string, company: string): boolean;
|
|
52
|
+
export declare function fleetHotSignalSyncRegistered(dir: string, company: string): boolean;
|
|
53
|
+
export declare function markFleetHotSignalSyncRegistered(dir: string, company: string, now?: Date): void;
|
|
54
|
+
export declare function markHotSignalSyncRegistered(dir: string, company: string, now?: Date): void;
|
|
55
|
+
export declare function forgetHotSignalSyncRegistrations(dir: string, keep: string | null): string[];
|
|
56
|
+
export declare function forgetHotSignalSyncRegistrationsExcept(dir: string, keep: readonly string[]): string[];
|
|
38
57
|
/** The private context that marks a job as this built-in, for one company. */
|
|
39
58
|
export declare function hotSignalSyncJobContext(company: string): Record<string, unknown>;
|
|
40
59
|
/** The company a job item syncs, when its context marks it as this built-in; else null. */
|
|
@@ -146,12 +165,47 @@ export interface HotSignalSyncRunInput {
|
|
|
146
165
|
*/
|
|
147
166
|
sendAlert?: (text: string) => Promise<void>;
|
|
148
167
|
log?: (message: string) => void;
|
|
168
|
+
/**
|
|
169
|
+
* The signals engine (`runSignals`). When set, a fired job runs the whole
|
|
170
|
+
* engine for the company instead of the sync alone; `hqRoot` is required.
|
|
171
|
+
*/
|
|
172
|
+
signals?: (options: SignalsEngineJobOptions) => Promise<RunSignalsResult>;
|
|
173
|
+
hqRoot?: string;
|
|
174
|
+
/** The job's zone, for the times in the reply. */
|
|
175
|
+
timeZone?: string;
|
|
176
|
+
/** When the run was due (the fired item's `firedAt`), for the catch-up line. */
|
|
177
|
+
scheduledFor?: string;
|
|
178
|
+
/** This run is the bot's automatic retry of a failed run; it never schedules another. */
|
|
179
|
+
retry?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Asks the bot to run the job once more at `at` (the run record's
|
|
182
|
+
* `nextRetryAt`). Returns true when the retry is scheduled; only then does
|
|
183
|
+
* the failure reply say when it tries again. Unset = no automatic retry.
|
|
184
|
+
*/
|
|
185
|
+
scheduleRetry?: (at: string) => boolean;
|
|
149
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The engine options a fired job passes. `signalsAlert` is the owner-DM
|
|
189
|
+
* sender for the signals-failure alert (US-018, hq-cli #2314). Until #2314
|
|
190
|
+
* adds it to `RunSignalsOptions`, `runSignals` ignores the field; once it
|
|
191
|
+
* lands, the engine alerts when a run lands sources and produces no signals.
|
|
192
|
+
*/
|
|
193
|
+
export type SignalsEngineJobOptions = RunSignalsOptions & {
|
|
194
|
+
signalsAlert?: {
|
|
195
|
+
sendOwnerDm: (text: string) => Promise<void>;
|
|
196
|
+
log?: (message: string) => void;
|
|
197
|
+
};
|
|
198
|
+
};
|
|
150
199
|
export interface HotSignalSyncRunOutcome {
|
|
151
200
|
ok: boolean;
|
|
152
201
|
/** Plain report for the owner's DM and the job's run output. */
|
|
153
202
|
text: string;
|
|
154
203
|
report: OnDemandSyncReport | null;
|
|
204
|
+
/** The engine's result and the storyboarded reply, when the engine ran. */
|
|
205
|
+
signals?: RunSignalsResult;
|
|
206
|
+
reply?: FormattedReply;
|
|
207
|
+
/** When the bot runs the automatic retry (the record's nextRetryAt), if one was scheduled. */
|
|
208
|
+
retryAt?: string;
|
|
155
209
|
}
|
|
156
210
|
/** Run the scheduled sync once and describe it in plain words. Never throws. */
|
|
157
211
|
export declare function runHotSignalSyncJob(input: HotSignalSyncRunInput): Promise<HotSignalSyncRunOutcome>;
|
|
@@ -15,8 +15,19 @@
|
|
|
15
15
|
* `hq bot jobs <bot> --reschedule <jobId> --every "<when>"`, or removes it with
|
|
16
16
|
* `--rm`, and a removed job is not added back on the next start.
|
|
17
17
|
*
|
|
18
|
-
* When the job fires the bot runs the
|
|
19
|
-
* (
|
|
18
|
+
* When the job fires the bot runs the signals engine in-process (US-006):
|
|
19
|
+
* `runSignals({ trigger: "job", catchUp })`, the same engine `hq signals run`
|
|
20
|
+
* uses (garden -> adapters -> extract -> garden -> upload, with the caps from
|
|
21
|
+
* core/settings/signals.yaml and a run record), and reports the run to the
|
|
22
|
+
* owner in the storyboarded reply format (lib/signals/reply.ts). When a run
|
|
23
|
+
* fails with a transient error, the run record carries `nextRetryAt` and the
|
|
24
|
+
* bot runs the job once more at that time, in-process (lib/bot/run.ts); only
|
|
25
|
+
* then does the failure reply offer to wait for the retry. The run started
|
|
26
|
+
* right after registration uses the `manual` trigger, so it never counts as
|
|
27
|
+
* the day's scheduled job. Extract
|
|
28
|
+
* spawns the member's own claude/codex under the bot's login, so the
|
|
29
|
+
* orchestration itself has no model turn. Without an engine wired in, the
|
|
30
|
+
* job falls back to the sync alone: the same hot-channel sync `hq sources sync` runs,
|
|
20
31
|
* with `trigger: "scheduled"` and `catchUp` set when the run fired late after
|
|
21
32
|
* a missed window. Catch-up is bounded by each adapter's `catchUpWindow`, and
|
|
22
33
|
* per-connection cursors keep a redelivered run from landing anything twice.
|
|
@@ -26,11 +37,13 @@
|
|
|
26
37
|
import * as fs from "node:fs";
|
|
27
38
|
import * as path from "node:path";
|
|
28
39
|
import { JOB_RUN_OPTIONS_CONTEXT_KEY } from "../job-run.js";
|
|
40
|
+
import { localDate, runRecordRel } from "../../signals/engine.js";
|
|
41
|
+
import { formatJobErrorReply, formatLockedReply, formatRunReply, timeInWords } from "../../signals/reply.js";
|
|
29
42
|
import { effectiveBotCompanies, effectiveBotKind } from "../config.js";
|
|
30
43
|
import { ensureLocalJob, readLocalJobs, removeLocalJobsWhere, withJobsLock } from "../local-jobs.js";
|
|
31
44
|
export const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
|
|
32
45
|
/**
|
|
33
|
-
* 06:30 every day, in the job's time zone (the stored
|
|
46
|
+
* 06:30 every day, in the job's time zone (the stored six-field form of
|
|
34
47
|
* "30 6 * * *"). Only the first registration uses it; after that the job's
|
|
35
48
|
* own schedule in `jobs.json` is the one that runs.
|
|
36
49
|
*/
|
|
@@ -40,6 +53,9 @@ const BUILTIN_JOBS_FILE = "builtin-jobs.json";
|
|
|
40
53
|
function markerKey(company) {
|
|
41
54
|
return `${HOT_SIGNAL_SYNC_JOB_KIND}:${company}`;
|
|
42
55
|
}
|
|
56
|
+
function fleetMarkerKey(company) {
|
|
57
|
+
return `${HOT_SIGNAL_SYNC_JOB_KIND}:fleet:${company}`;
|
|
58
|
+
}
|
|
43
59
|
function readMarker(dir) {
|
|
44
60
|
try {
|
|
45
61
|
const parsed = JSON.parse(fs.readFileSync(path.join(dir, BUILTIN_JOBS_FILE), "utf-8"));
|
|
@@ -58,6 +74,64 @@ function writeMarker(dir, marker) {
|
|
|
58
74
|
fs.writeFileSync(tmp, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
|
|
59
75
|
fs.renameSync(tmp, file);
|
|
60
76
|
}
|
|
77
|
+
export function hotSignalSyncRegistered(dir, company) {
|
|
78
|
+
return Boolean(readMarker(dir).registered[markerKey(company)]);
|
|
79
|
+
}
|
|
80
|
+
export function fleetHotSignalSyncRegistered(dir, company) {
|
|
81
|
+
return Boolean(readMarker(dir).registered[fleetMarkerKey(company)]);
|
|
82
|
+
}
|
|
83
|
+
export function markFleetHotSignalSyncRegistered(dir, company, now = new Date()) {
|
|
84
|
+
withJobsLock(dir, () => {
|
|
85
|
+
const marker = readMarker(dir);
|
|
86
|
+
if (marker.registered[fleetMarkerKey(company)])
|
|
87
|
+
return;
|
|
88
|
+
marker.registered[fleetMarkerKey(company)] = now.toISOString();
|
|
89
|
+
writeMarker(dir, marker);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export function markHotSignalSyncRegistered(dir, company, now = new Date()) {
|
|
93
|
+
withJobsLock(dir, () => {
|
|
94
|
+
const marker = readMarker(dir);
|
|
95
|
+
if (marker.registered[markerKey(company)])
|
|
96
|
+
return;
|
|
97
|
+
marker.registered[markerKey(company)] = now.toISOString();
|
|
98
|
+
writeMarker(dir, marker);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function forgetHotSignalSyncRegistrations(dir, keep) {
|
|
102
|
+
const keepKey = keep ? markerKey(keep) : null;
|
|
103
|
+
return withJobsLock(dir, () => {
|
|
104
|
+
const marker = readMarker(dir);
|
|
105
|
+
const forgotten = [];
|
|
106
|
+
for (const key of Object.keys(marker.registered)) {
|
|
107
|
+
if (key.startsWith(HOT_SIGNAL_SYNC_JOB_KIND + ":") && key !== keepKey) {
|
|
108
|
+
delete marker.registered[key];
|
|
109
|
+
forgotten.push(key.slice(HOT_SIGNAL_SYNC_JOB_KIND.length + 1));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (forgotten.length > 0)
|
|
113
|
+
writeMarker(dir, marker);
|
|
114
|
+
return forgotten;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
export function forgetHotSignalSyncRegistrationsExcept(dir, keep) {
|
|
118
|
+
const keepKeys = new Set(keep.flatMap((company) => [markerKey(company), fleetMarkerKey(company)]));
|
|
119
|
+
return withJobsLock(dir, () => {
|
|
120
|
+
const marker = readMarker(dir);
|
|
121
|
+
const forgotten = [];
|
|
122
|
+
for (const key of Object.keys(marker.registered)) {
|
|
123
|
+
if (key.startsWith(HOT_SIGNAL_SYNC_JOB_KIND + ":") && !keepKeys.has(key)) {
|
|
124
|
+
delete marker.registered[key];
|
|
125
|
+
const company = key.slice(HOT_SIGNAL_SYNC_JOB_KIND.length + 1).replace(/^fleet:/, "");
|
|
126
|
+
if (!forgotten.includes(company))
|
|
127
|
+
forgotten.push(company);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (forgotten.length > 0)
|
|
131
|
+
writeMarker(dir, marker);
|
|
132
|
+
return forgotten;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
61
135
|
/** The private context that marks a job as this built-in, for one company. */
|
|
62
136
|
export function hotSignalSyncJobContext(company) {
|
|
63
137
|
return { [JOB_RUN_OPTIONS_CONTEXT_KEY]: { builtin: HOT_SIGNAL_SYNC_JOB_KIND, company } };
|
|
@@ -117,7 +191,8 @@ export function otherBotWithHotSignalSync(dir, company, botDirs) {
|
|
|
117
191
|
for (const other of botDirs) {
|
|
118
192
|
if (path.resolve(other) === self)
|
|
119
193
|
continue;
|
|
120
|
-
|
|
194
|
+
const registered = readMarker(other).registered;
|
|
195
|
+
if (registered[markerKey(company)] || registered[fleetMarkerKey(company)])
|
|
121
196
|
return other;
|
|
122
197
|
if (readLocalJobs(other).some((job) => hotSignalSyncJobCompany(job.context) === company))
|
|
123
198
|
return other;
|
|
@@ -227,8 +302,92 @@ function errorMessage(err) {
|
|
|
227
302
|
return err instanceof Error ? err.message : String(err);
|
|
228
303
|
}
|
|
229
304
|
export const noLocalSignalsPass = async () => 0;
|
|
305
|
+
/**
|
|
306
|
+
* Today's run record for the company, or null. The automatic retry reads it
|
|
307
|
+
* first: when another run (a "Try now", a brief run) already finished the day
|
|
308
|
+
* complete or partial since the failure, the retry has nothing to do.
|
|
309
|
+
*/
|
|
310
|
+
function readTodaysRecord(hqRoot, company, now) {
|
|
311
|
+
try {
|
|
312
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(hqRoot, runRecordRel(localDate(now), company)), "utf8"));
|
|
313
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async function runSignalsEngineJob(input) {
|
|
320
|
+
if (!input.hqRoot) {
|
|
321
|
+
const reply = formatJobErrorReply(`Morning signals for ${input.company} did not run.`, "The bot has no HQ folder set, so it cannot find the company's files.");
|
|
322
|
+
return { ok: false, text: reply.text, report: null, reply };
|
|
323
|
+
}
|
|
324
|
+
const timeZone = input.timeZone ?? "UTC";
|
|
325
|
+
if (input.retry) {
|
|
326
|
+
const today = readTodaysRecord(input.hqRoot, input.company, input.now());
|
|
327
|
+
if (today && (today.status === "complete" || today.status === "partial")) {
|
|
328
|
+
const at = timeInWords(today.finishedAt, timeZone);
|
|
329
|
+
const text = `Morning signals for ${input.company} already finished${at ? ` at ${at}` : ""} after another run, so the automatic retry had nothing to do.`;
|
|
330
|
+
return { ok: true, text, report: null };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const options = {
|
|
334
|
+
hqRoot: input.hqRoot,
|
|
335
|
+
company: input.company,
|
|
336
|
+
// The run right after the job is registered is a setup run, not the day's
|
|
337
|
+
// scheduled run: a non-job trigger leaves jobRanAt alone, so the morning
|
|
338
|
+
// job still runs (a bot first started at 01:00 is not "already ran").
|
|
339
|
+
trigger: input.firstRun ? "manual" : "job",
|
|
340
|
+
catchUp: input.catchUp,
|
|
341
|
+
now: input.now,
|
|
342
|
+
// The signals-failure alert (US-018): the owner DM, sent even when the job is quiet.
|
|
343
|
+
...(input.sendAlert
|
|
344
|
+
? { signalsAlert: { sendOwnerDm: input.sendAlert, ...(input.log ? { log: input.log } : {}) } }
|
|
345
|
+
: {}),
|
|
346
|
+
};
|
|
347
|
+
let result;
|
|
348
|
+
try {
|
|
349
|
+
result = await input.signals(options);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
353
|
+
const reply = formatJobErrorReply(`Morning signals for ${input.company} failed.`, reason);
|
|
354
|
+
return { ok: false, text: reply.text, report: null, reply };
|
|
355
|
+
}
|
|
356
|
+
const replyOptions = {
|
|
357
|
+
timeZone,
|
|
358
|
+
...(input.scheduledFor ? { scheduledFor: input.scheduledFor } : {}),
|
|
359
|
+
};
|
|
360
|
+
const record = result.records.find((r) => r.company === input.company) ?? result.records[0];
|
|
361
|
+
if (!record) {
|
|
362
|
+
const locked = (result.locked ?? []).find((l) => l.company === input.company);
|
|
363
|
+
if (locked) {
|
|
364
|
+
const reply = formatLockedReply(locked, replyOptions);
|
|
365
|
+
return { ok: true, text: reply.text, report: null, signals: result, reply };
|
|
366
|
+
}
|
|
367
|
+
const reply = formatJobErrorReply(`Morning signals for ${input.company} did not finish.`, "The run left no run record, so I cannot say what happened.");
|
|
368
|
+
return { ok: false, text: reply.text, report: null, signals: result, reply };
|
|
369
|
+
}
|
|
370
|
+
// One automatic retry per failed scheduled run, only when the engine set a
|
|
371
|
+
// retry time (a transient failure) and the bot really scheduled it. The
|
|
372
|
+
// retry itself never schedules another.
|
|
373
|
+
const retryScheduled = record.status === "failed" &&
|
|
374
|
+
!record.alreadyRanToday &&
|
|
375
|
+
typeof record.nextRetryAt === "string" &&
|
|
376
|
+
!input.retry &&
|
|
377
|
+
input.scheduleRetry !== undefined &&
|
|
378
|
+
input.scheduleRetry(record.nextRetryAt);
|
|
379
|
+
const reply = formatRunReply(record, result.summary, {
|
|
380
|
+
...replyOptions,
|
|
381
|
+
...(retryScheduled ? { retryScheduled: true } : {}),
|
|
382
|
+
...(input.retry ? { wasRetry: true } : {}),
|
|
383
|
+
});
|
|
384
|
+
const ok = reply.variant !== "failed" && reply.variant !== "partial";
|
|
385
|
+
return { ok, text: reply.text, report: null, signals: result, reply, ...(retryScheduled ? { retryAt: record.nextRetryAt } : {}) };
|
|
386
|
+
}
|
|
230
387
|
/** Run the scheduled sync once and describe it in plain words. Never throws. */
|
|
231
388
|
export async function runHotSignalSyncJob(input) {
|
|
389
|
+
if (input.signals)
|
|
390
|
+
return runSignalsEngineJob({ ...input, signals: input.signals });
|
|
232
391
|
const signalsPass = input.signalsPass ?? noLocalSignalsPass;
|
|
233
392
|
let report;
|
|
234
393
|
try {
|
|
@@ -45,6 +45,7 @@ export interface LocalJob {
|
|
|
45
45
|
runCount?: number;
|
|
46
46
|
}
|
|
47
47
|
export declare function readLocalJobs(dir: string): LocalJob[];
|
|
48
|
+
export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
|
|
48
49
|
/**
|
|
49
50
|
* Run a read-change-write of `jobs.json` under an exclusive lock. The running
|
|
50
51
|
* bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
|
|
@@ -63,8 +64,9 @@ export interface CronMatcher {
|
|
|
63
64
|
}
|
|
64
65
|
/**
|
|
65
66
|
* The job's schedule as a standard 5-field cron. Jobs are stored in the same
|
|
66
|
-
* form
|
|
67
|
-
* unused day field), so the same `hq bot jobs` input works for
|
|
67
|
+
* six-field form hosted agent jobs use (`cron(m h dom mon dow year)`, Sunday =
|
|
68
|
+
* 1, `?` for the unused day field), so the same `hq bot jobs` input works for
|
|
69
|
+
* either home.
|
|
68
70
|
*/
|
|
69
71
|
export declare function toStandardCron(expr: string): string | null;
|
|
70
72
|
/** Parse a standard 5-field cron, or null when it is not one. */
|
|
@@ -106,6 +108,15 @@ export declare function removeLocalJobsWhere(dir: string, matches: (job: LocalJo
|
|
|
106
108
|
/** Forget the once-per-minute check (tests, and a bot restarted in-process). */
|
|
107
109
|
export declare function resetLocalJobChecks(): void;
|
|
108
110
|
export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
|
|
111
|
+
/** A run of a job in this bot's own jobs.json (its ack and completion stay local). */
|
|
112
|
+
export declare function isLocalJobMessage(messageId: string): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The message id of the bot's one automatic retry of a local job run (the
|
|
115
|
+
* signals job after a transient failure). It keeps the local prefix, so its
|
|
116
|
+
* ack and completion stay on this computer like any other local run.
|
|
117
|
+
*/
|
|
118
|
+
export declare function localJobRetryMessageId(jobId: string, dueMs: number): string;
|
|
119
|
+
export declare function isLocalJobRetryMessage(messageId: string): boolean;
|
|
109
120
|
/**
|
|
110
121
|
* The bot's cloud client, with its scheduled runs answered on this computer:
|
|
111
122
|
* due local jobs join the inbox, and their ack and completion are recorded
|
|
@@ -69,7 +69,7 @@ function readLocalJobsForAdd(dir) {
|
|
|
69
69
|
}
|
|
70
70
|
return parsed.jobs.filter((job) => !!job && typeof job.jobId === "string");
|
|
71
71
|
}
|
|
72
|
-
function writeLocalJobs(dir, jobs) {
|
|
72
|
+
export function writeLocalJobs(dir, jobs) {
|
|
73
73
|
const file = localJobsPath(dir);
|
|
74
74
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
75
75
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
|
|
@@ -170,8 +170,9 @@ function replaceNames(field, names) {
|
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
172
|
* The job's schedule as a standard 5-field cron. Jobs are stored in the same
|
|
173
|
-
* form
|
|
174
|
-
* unused day field), so the same `hq bot jobs` input works for
|
|
173
|
+
* six-field form hosted agent jobs use (`cron(m h dom mon dow year)`, Sunday =
|
|
174
|
+
* 1, `?` for the unused day field), so the same `hq bot jobs` input works for
|
|
175
|
+
* either home.
|
|
175
176
|
*/
|
|
176
177
|
export function toStandardCron(expr) {
|
|
177
178
|
const trimmed = expr.trim();
|
|
@@ -465,9 +466,21 @@ export function takeDueLocalJobRuns(dir, now = new Date()) {
|
|
|
465
466
|
return items;
|
|
466
467
|
});
|
|
467
468
|
}
|
|
468
|
-
|
|
469
|
+
/** A run of a job in this bot's own jobs.json (its ack and completion stay local). */
|
|
470
|
+
export function isLocalJobMessage(messageId) {
|
|
469
471
|
return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
|
|
470
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* The message id of the bot's one automatic retry of a local job run (the
|
|
475
|
+
* signals job after a transient failure). It keeps the local prefix, so its
|
|
476
|
+
* ack and completion stay on this computer like any other local run.
|
|
477
|
+
*/
|
|
478
|
+
export function localJobRetryMessageId(jobId, dueMs) {
|
|
479
|
+
return `${LOCAL_JOB_MESSAGE_PREFIX}${jobId}-retry-${dueMs}`;
|
|
480
|
+
}
|
|
481
|
+
export function isLocalJobRetryMessage(messageId) {
|
|
482
|
+
return isLocalJobMessage(messageId) && /-retry-\d+$/.test(messageId);
|
|
483
|
+
}
|
|
471
484
|
/** Record how a local run finished and release the job for its next fire. */
|
|
472
485
|
function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
|
|
473
486
|
withJobsLock(dir, () => {
|
|
@@ -70,6 +70,16 @@ export declare const PRE_TOOL_ASIDE_MAX_CHARS = 40;
|
|
|
70
70
|
* your agency docs now, then I'll look at the client folders.") and run past
|
|
71
71
|
* the limit; an hq-block is always content the desktop app must draw.
|
|
72
72
|
*/
|
|
73
|
+
/**
|
|
74
|
+
* A heads-up the model writes right before a long piece of work ("Give me
|
|
75
|
+
* about two minutes to check what's on this computer, then I'll ask my
|
|
76
|
+
* first question."). A bot that drops every pre-tool message
|
|
77
|
+
* (`dropPreToolMessages`) still lets ONE of these through per turn, so the
|
|
78
|
+
* person hears what is happening and how long it takes instead of watching
|
|
79
|
+
* a spinner. Short, one or two sentences, names a wait, no hq-block.
|
|
80
|
+
*/
|
|
81
|
+
export declare const WAIT_HEADS_UP_MAX_CHARS = 240;
|
|
82
|
+
export declare function isWaitHeadsUp(text: string): boolean;
|
|
73
83
|
export declare function isPreToolAside(text: string): boolean;
|
|
74
84
|
/** Whitespace-insensitive form used to recognise repeated text. */
|
|
75
85
|
export declare function normalizeForCompare(text: string): string;
|
|
@@ -87,6 +97,8 @@ export declare class ProgressPoster {
|
|
|
87
97
|
private readonly workingNoticeText;
|
|
88
98
|
private readonly buffer;
|
|
89
99
|
private readonly dropPreTool;
|
|
100
|
+
/** With dropPreToolMessages: one wait heads-up (isWaitHeadsUp) per turn is kept. */
|
|
101
|
+
private headsUpKept;
|
|
90
102
|
private readonly onDropped;
|
|
91
103
|
/**
|
|
92
104
|
* Messages waiting to learn whether a tool call follows: every message when
|
|
@@ -119,6 +131,8 @@ export declare class ProgressPoster {
|
|
|
119
131
|
constructor(opts: ProgressPosterOptions);
|
|
120
132
|
/** Arm the one-time "working on it" note. Call when the turn starts. */
|
|
121
133
|
start(): void;
|
|
134
|
+
/** Arm the still-working line again after a kept heads-up (start() skips a turn that said something). */
|
|
135
|
+
private rearmNoticeAfter;
|
|
122
136
|
/** One finished assistant message from the model. */
|
|
123
137
|
message(text: string): void;
|
|
124
138
|
/** The model called a tool: whatever it wrote just before was a note to itself. */
|