@indigoai-us/hq-cli 5.271.0 → 5.273.0-alpha.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.
@@ -18,7 +18,7 @@ import * as os from "node:os";
18
18
  import * as path from "node:path";
19
19
  import { major as semverMajor, valid as validSemver } from "semver";
20
20
  import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits } from "../lib/daemon/catalog.js";
21
- import { applyServiceToggle, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
21
+ import { applyServiceToggle, effectiveSyncOwner, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
22
22
  import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
23
23
  import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
24
24
  import { ROOT_REFUSAL, SYSTEM_UNIT_DIR, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, } from "../lib/daemon/install.js";
@@ -27,7 +27,10 @@ import { isDaemonProcess, runningDaemonPid } from "../lib/daemon/lock.js";
27
27
  import { bundledHqCloud } from "../lib/daemon/hq-cloud.js";
28
28
  import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "../lib/daemon/machine-type.js";
29
29
  import { daemonPaths } from "../lib/daemon/paths.js";
30
+ import { processCommandLine } from "../lib/daemon/lock.js";
30
31
  import { writeControlRequest } from "../lib/daemon/requests.js";
32
+ import { isSyncRunnerCommandLine, readWatchRunnerOwnerStatus, watchRunnerOwnerStatusPath } from "../lib/daemon/sync-runner.js";
33
+ import { getSyncModeForCompany, setSyncMode } from "./sync-mode.js";
31
34
  import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
32
35
  class DaemonCommandError extends Error {
33
36
  }
@@ -96,6 +99,7 @@ async function loadDefaults(over) {
96
99
  windowsUser: over.windowsUser ?? (platform === "win32" ? mesh.resolveWindowsUser(env) : ""),
97
100
  windowsHeadlessLauncher: over.windowsHeadlessLauncher ?? (platform === "win32" ? mesh.resolveWindowsHeadlessLauncher(env) : undefined),
98
101
  systemUnitDir: over.systemUnitDir ?? SYSTEM_UNIT_DIR,
102
+ syncModeClient: over.syncModeClient,
99
103
  };
100
104
  }
101
105
  function detect(d, flag, config) {
@@ -372,6 +376,135 @@ function queueRequest(d, request, what) {
372
376
  const running = runningDaemonPid(paths.lockPath, d.daemonAlive) !== null;
373
377
  d.out(running ? `Asked the daemon to ${what}.` : `Queued "${what}"; the daemon is not running, so it applies when it starts.`);
374
378
  }
379
+ function daemonSyncModeClient(d) {
380
+ if (d.syncModeClient)
381
+ return d.syncModeClient();
382
+ return (async () => {
383
+ const [{ VaultClient }, { buildVaultConfig, ensureCognitoToken }] = await Promise.all([
384
+ import("@indigoai-us/hq-cloud"),
385
+ import("../utils/cognito-session.js"),
386
+ ]);
387
+ const token = await ensureCognitoToken();
388
+ return new VaultClient(buildVaultConfig(token));
389
+ })();
390
+ }
391
+ function daemonSyncStatus(d) {
392
+ const host = currentHost(d);
393
+ const { paths } = host;
394
+ const config = readDaemonConfig(paths.configPath);
395
+ const daemonPid = runningDaemonPid(paths.lockPath, d.daemonAlive);
396
+ const daemonState = daemonPid === null ? null : readState(paths);
397
+ const syncUnit = daemonState?.units.find((unit) => unit.id === "sync");
398
+ const hqRoot = config.hqRoot ?? d.env.HQ_ROOT?.trim();
399
+ let ownerStatusCandidate = null;
400
+ if (hqRoot) {
401
+ try {
402
+ ownerStatusCandidate = readWatchRunnerOwnerStatus(watchRunnerOwnerStatusPath(hqRoot, d.home, d.env));
403
+ }
404
+ catch (error) {
405
+ const detail = error instanceof Error ? error.message : String(error);
406
+ d.err(`Could not read sync runner owner status; treating it as unavailable: ${detail}`);
407
+ }
408
+ }
409
+ const ownerCommandLine = ownerStatusCandidate
410
+ ? processCommandLine(ownerStatusCandidate.pid, { platform: d.platform, run: d.run })
411
+ : null;
412
+ const ownerStatus = ownerCommandLine && isSyncRunnerCommandLine(ownerCommandLine)
413
+ ? ownerStatusCandidate
414
+ : null;
415
+ return {
416
+ running: syncUnit?.status === "running",
417
+ paused: config.syncPaused === true,
418
+ syncOwner: effectiveSyncOwner(config, host.detected.type),
419
+ owner: ownerStatus?.owner ?? (syncUnit?.status === "running" ? "hq-daemon" : null),
420
+ runnerPid: ownerStatus?.pid ?? syncUnit?.pid ?? null,
421
+ hqCloudVersion: ownerStatus?.version ?? null,
422
+ lastHeartbeat: syncUnit?.lastHeartbeatAt ?? ownerStatus?.heartbeatAt ?? null,
423
+ lastPassResult: syncUnit?.lastPassResult ?? null,
424
+ unitStatus: syncUnit?.status ?? "not-running",
425
+ reason: syncUnit?.reason ?? null,
426
+ logPath: paths.unitLogPath("sync"),
427
+ };
428
+ }
429
+ async function runDaemonSyncStatus(d, opts) {
430
+ const status = daemonSyncStatus(d);
431
+ if (opts.json) {
432
+ d.out(JSON.stringify(status, null, 2));
433
+ return;
434
+ }
435
+ d.out(`Sync ${status.running ? "running" : status.unitStatus}${status.paused ? " (paused)" : ""}.`);
436
+ d.out(`Configured sync owner: ${status.syncOwner}.`);
437
+ d.out(`Owner: ${status.owner ?? "none"}; runner pid: ${status.runnerPid ?? "none"}; hq-cloud: ${status.hqCloudVersion ?? "unknown"}.`);
438
+ if (status.reason)
439
+ d.out(`Reason: ${status.reason}`);
440
+ d.out(`Log: ${status.logPath}`);
441
+ }
442
+ function queueDaemonSyncControl(d, request) {
443
+ const host = currentHost(d);
444
+ writeControlRequest(host.paths.requestsDir, request);
445
+ handOver(d, host);
446
+ return runningDaemonPid(host.paths.lockPath, d.daemonAlive) !== null;
447
+ }
448
+ async function runDaemonSyncNow(d, opts) {
449
+ const host = currentHost(d);
450
+ const config = readDaemonConfig(host.paths.configPath);
451
+ if (config.syncPaused)
452
+ throw new DaemonCommandError("sync is paused; run `hq daemon sync resume` first");
453
+ if (!enabledServices(host.detected.type, config).includes("sync")) {
454
+ throw new DaemonCommandError("sync is disabled for this machine; run `hq daemon enable sync` first");
455
+ }
456
+ const running = queueDaemonSyncControl(d, { action: "restart", unit: "sync" });
457
+ const result = { accepted: true, action: "restart-sync-runner", daemonRunning: running };
458
+ d.out(opts.json ? JSON.stringify(result, null, 2) : running ? "Asked the daemon to start a fresh sync pass." : "Queued a fresh sync pass for the next daemon start.");
459
+ }
460
+ async function runDaemonSyncPause(d, paused, opts) {
461
+ const host = currentHost(d);
462
+ const config = readDaemonConfig(host.paths.configPath);
463
+ if (config.syncPaused !== paused)
464
+ writeDaemonConfig(host.paths.configPath, { ...config, syncPaused: paused });
465
+ const daemonRunning = queueDaemonSyncControl(d, { action: "reconcile" });
466
+ const result = { paused, persisted: true, daemonRunning };
467
+ d.out(opts.json ? JSON.stringify(result, null, 2) : `Sync ${paused ? "paused" : "resumed"}${daemonRunning ? "" : "; the setting will apply when the daemon starts"}.`);
468
+ }
469
+ async function runDaemonSyncTakeOwnership(d, opts) {
470
+ const host = currentHost(d);
471
+ const config = readDaemonConfig(host.paths.configPath);
472
+ if (config.syncOwner !== "daemon")
473
+ writeDaemonConfig(host.paths.configPath, { ...config, syncOwner: "daemon" });
474
+ const daemonRunning = queueDaemonSyncControl(d, { action: "reconcile" });
475
+ const result = { syncOwner: "daemon", persisted: true, daemonRunning };
476
+ d.out(opts.json ? JSON.stringify(result, null, 2) : `Daemon sync ownership persisted${daemonRunning ? "; asked the daemon to reconcile" : "; it applies when the daemon starts"}.`);
477
+ }
478
+ async function runDaemonSyncModeGet(d, companySlug, opts) {
479
+ const result = await getSyncModeForCompany({ companySlug, vaultClient: await daemonSyncModeClient(d) });
480
+ const value = {
481
+ companySlug: result.companySlug,
482
+ membershipId: result.membershipId,
483
+ mode: result.config.syncMode,
484
+ isDefault: result.config.isDefault,
485
+ ...(result.config.updatedAt ? { updatedAt: result.config.updatedAt } : {}),
486
+ };
487
+ d.out(opts.json ? JSON.stringify(value, null, 2) : `${value.companySlug}: ${value.mode}${value.isDefault ? " (default)" : ""}`);
488
+ }
489
+ async function runDaemonSyncModeSet(d, companySlug, mode, opts) {
490
+ if (mode !== "all" && mode !== "shared") {
491
+ throw new DaemonCommandError(`Invalid sync mode '${mode}'. Use all or shared.`);
492
+ }
493
+ const result = await setSyncMode({
494
+ mode,
495
+ companySlug,
496
+ vaultClient: await daemonSyncModeClient(d),
497
+ });
498
+ const value = {
499
+ companySlug: result.companySlug,
500
+ membershipId: result.membershipId,
501
+ previousMode: result.previousMode,
502
+ mode: result.newMode,
503
+ isDefault: result.newConfig.isDefault,
504
+ ...(result.newConfig.updatedAt ? { updatedAt: result.newConfig.updatedAt } : {}),
505
+ };
506
+ d.out(opts.json ? JSON.stringify(value, null, 2) : `${value.companySlug}: ${value.previousMode} → ${value.mode}`);
507
+ }
375
508
  async function runToggle(d, action, services) {
376
509
  const host = currentHost(d);
377
510
  const { paths } = host;
@@ -603,6 +736,45 @@ export function registerDaemonCommand(program, over = {}) {
603
736
  .description("Show the machine type and each service's state")
604
737
  .option("--json", "Print machine-readable JSON")
605
738
  .action(wrap((d, opts) => runStatus(d, opts)));
739
+ const sync = daemon
740
+ .command("sync")
741
+ .description("Inspect and control the daemon's company sync runner");
742
+ sync
743
+ .command("status")
744
+ .description("Show sync ownership, heartbeat, last pass and log path")
745
+ .option("--json", "Print machine-readable JSON")
746
+ .action(wrap((d, opts) => runDaemonSyncStatus(d, opts)));
747
+ sync
748
+ .command("now")
749
+ .description("Start a fresh sync pass immediately")
750
+ .option("--json", "Print machine-readable JSON")
751
+ .action(wrap((d, opts) => runDaemonSyncNow(d, opts)));
752
+ sync
753
+ .command("pause")
754
+ .description("Pause daemon sync until resumed")
755
+ .option("--json", "Print machine-readable JSON")
756
+ .action(wrap((d, opts) => runDaemonSyncPause(d, true, opts)));
757
+ sync
758
+ .command("resume")
759
+ .description("Resume daemon sync after a pause")
760
+ .option("--json", "Print machine-readable JSON")
761
+ .action(wrap((d, opts) => runDaemonSyncPause(d, false, opts)));
762
+ sync
763
+ .command("take-ownership")
764
+ .description("Persist daemon ownership of desktop sync and reconcile")
765
+ .option("--json", "Print machine-readable JSON")
766
+ .action(wrap((d, opts) => runDaemonSyncTakeOwnership(d, opts)));
767
+ const mode = sync.command("mode").description("Read or change a company's sync footprint");
768
+ mode
769
+ .command("get <company>")
770
+ .description("Read a company's current all/shared sync mode")
771
+ .option("--json", "Print machine-readable JSON")
772
+ .action(wrap((d, company, opts) => runDaemonSyncModeGet(d, company, opts)));
773
+ mode
774
+ .command("set <company> <mode>")
775
+ .description("Set a company's sync mode to all or shared")
776
+ .option("--json", "Print machine-readable JSON")
777
+ .action(wrap((d, company, mode, opts) => runDaemonSyncModeSet(d, company, mode, opts)));
606
778
  daemon
607
779
  .command("logs [unit]")
608
780
  .description("Print the supervisor log, or one service's log")
@@ -62,6 +62,22 @@ export declare class FilesDeleteHttpError extends Error {
62
62
  export declare function formatFilesDeleteError(err: FilesDeleteHttpError, prefix: string): string;
63
63
  /** Injectable yes/no confirmation seam (stubbed in tests). */
64
64
  export type ConfirmFn = (message: string) => Promise<boolean>;
65
+ /**
66
+ * POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
67
+ * caller renders a single consistent error path.
68
+ *
69
+ * Scope is EITHER a company vault (`companyUid` set) OR the caller's personal
70
+ * vault (`personal: true`). For the personal case the server resolves the target
71
+ * person + bucket from the authenticated caller — we send NO uid, just the
72
+ * `personal` flag — so there is nothing for the client to get wrong or spoof.
73
+ */
74
+ export declare function callDeleteEndpoint(params: {
75
+ token: string;
76
+ companyUid?: string;
77
+ personal?: boolean;
78
+ prefix: string;
79
+ dryRun: boolean;
80
+ }): Promise<FilesDeleteResponse>;
65
81
  interface RunFilesDeleteParams {
66
82
  prefix: string;
67
83
  dryRun: boolean;
@@ -879,7 +879,7 @@ function realConfirm(message) {
879
879
  * person + bucket from the authenticated caller — we send NO uid, just the
880
880
  * `personal` flag — so there is nothing for the client to get wrong or spoof.
881
881
  */
882
- async function callDeleteEndpoint(params) {
882
+ export async function callDeleteEndpoint(params) {
883
883
  const res = await vaultApiFetch({
884
884
  token: params.token,
885
885
  path: "/v1/files/delete",
@@ -70,6 +70,15 @@ export interface SetSyncModeResult {
70
70
  export interface ShowSyncModesOptions {
71
71
  vaultClient: SyncModeVaultClient;
72
72
  }
73
+ export interface GetSyncModeOptions {
74
+ companySlug: string;
75
+ vaultClient: SyncModeVaultClient;
76
+ }
77
+ export interface GetSyncModeResult {
78
+ membershipId: string;
79
+ companySlug: string;
80
+ config: MembershipSyncConfig;
81
+ }
73
82
  export interface ShowSyncModesRow {
74
83
  companySlug: string;
75
84
  companyName?: string;
@@ -100,6 +109,8 @@ export declare function setSyncMode(options: SetSyncModeOptions): Promise<SetSyn
100
109
  * stable table output.
101
110
  */
102
111
  export declare function showSyncModes(options: ShowSyncModesOptions): Promise<ShowSyncModesRow[]>;
112
+ /** Read one company's effective sync mode without hiding API read failures. */
113
+ export declare function getSyncModeForCompany(options: GetSyncModeOptions): Promise<GetSyncModeResult>;
103
114
  /**
104
115
  * Render the `--show` table as plain text. No external dep — hq-cli doesn't
105
116
  * use cli-table3, so we hand-format columns to match the existing chalk +
@@ -163,6 +163,36 @@ export async function showSyncModes(options) {
163
163
  rows.sort((a, b) => a.companySlug.localeCompare(b.companySlug));
164
164
  return rows;
165
165
  }
166
+ /** Read one company's effective sync mode without hiding API read failures. */
167
+ export async function getSyncModeForCompany(options) {
168
+ const { companySlug, vaultClient } = options;
169
+ const memberships = await vaultClient.listMyMemberships();
170
+ const enriched = await Promise.all(memberships.map(async (membership) => {
171
+ try {
172
+ const entity = await vaultClient.entity.get(membership.companyUid);
173
+ return { ok: true, membership, slug: entity.slug };
174
+ }
175
+ catch (error) {
176
+ return { ok: false, membership, error };
177
+ }
178
+ }));
179
+ const match = enriched.find((row) => row.ok && row.slug === companySlug);
180
+ if (!match) {
181
+ const failedLookup = enriched.find((row) => !row.ok);
182
+ if (failedLookup && !failedLookup.ok)
183
+ throw failedLookup.error;
184
+ throw new Error(`No membership found for company '${companySlug}'.`);
185
+ }
186
+ for (const failedLookup of enriched) {
187
+ if (!failedLookup.ok) {
188
+ const detail = failedLookup.error instanceof Error ? failedLookup.error.message : String(failedLookup.error);
189
+ console.error(`getSyncModeForCompany: entity lookup failed for membership ${failedLookup.membership.membershipKey} while resolving '${companySlug}': ${detail}`);
190
+ }
191
+ }
192
+ const membershipId = match.membership.membershipKey;
193
+ const config = await vaultClient.getMembershipSyncConfig(membershipId);
194
+ return { membershipId, companySlug, config };
195
+ }
166
196
  // ── Table rendering (used by CLI action, separable for tests) ──────────────
167
197
  /**
168
198
  * Render the `--show` table as plain text. No external dep — hq-cli doesn't
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Merge numbered entity copies (`<slug> 2.md`) into their original file and
3
+ * delete them (CO-54, owner decision CO-D-007 option 2).
4
+ *
5
+ * Gated by `checkpoint.ontology.merge-copies`. A copy is a file named
6
+ * `<slug> <n>.md` next to `<slug>.md` of the same type, with the same `id`,
7
+ * in one duplicate group. Other members of a group are never touched.
8
+ *
9
+ * Two steps, run separately so sync can push the merged original before its
10
+ * copy goes away:
11
+ *
12
+ * - merge: the copy's fresh aliases, back-references and relationship lines
13
+ * are added to the original, `signal_count` takes the higher value, and
14
+ * entity files that link to the copy are re-pointed at the original. Every
15
+ * rewritten file is recorded in the outbox before it is written, so sync
16
+ * pushes it. Keys the checkpoint does not own keep the original's value.
17
+ * - delete: a copy is deleted from the vault, by exact key, only when merging
18
+ * it again would change nothing and no entity file or signal links to it.
19
+ * The vault keeps every old version. The local file goes after the vault
20
+ * delete succeeds, or when the vault no longer has the key.
21
+ *
22
+ * Copies of one original merge in path order, each into the result of the
23
+ * previous one, and none of them counts as holding a name the others move.
24
+ * A copy whose body has lines the merged original lacks (prose the merge does
25
+ * not carry) is merged but never deleted. A copy linked from any file the
26
+ * merge cannot rewrite (a copy, a knowledge file, a signal) is held.
27
+ *
28
+ * A signal whose `entity_refs` names a copy holds that copy: signals are
29
+ * server-owned, so the ref cannot be re-pointed from here.
30
+ */
31
+ import { type OntologyCompany } from "./company.js";
32
+ import { type EntityIndex } from "./tree.js";
33
+ export declare const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
34
+ export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group";
35
+ export interface CopySkip {
36
+ copy: string;
37
+ reason: CopySkipReason;
38
+ }
39
+ export interface CopyPair {
40
+ type: string;
41
+ /** Company-relative path of the copy. */
42
+ copy: string;
43
+ /** Company-relative path of the original it merges into. */
44
+ original: string;
45
+ }
46
+ export interface CopyPlan extends CopyPair {
47
+ add_aliases: string[];
48
+ /** Copy names the original lacks that another entity already holds, so they do not move. */
49
+ dropped_aliases: string[];
50
+ add_backrefs: number;
51
+ add_relationship_lines: number;
52
+ /** The copy's higher `signal_count`, when it is higher. */
53
+ raise_signal_count?: number;
54
+ /** Keys the checkpoint does not own whose values differ; the original's value stays. */
55
+ kept_original_keys: string[];
56
+ /** Entity files that link to the copy. */
57
+ inbound_links: string[];
58
+ /** Signal files whose `entity_refs` name the copy. */
59
+ signal_refs: string[];
60
+ /** Why the copy cannot be merged or deleted from here. */
61
+ held?: "signal_refs" | "linked_from_unwritable" | "merge_invalid";
62
+ detail?: string;
63
+ /** Body lines of the copy the merged original lacks; a copy with any is never deleted. */
64
+ unmerged_body_lines: number;
65
+ }
66
+ export interface MergeCopiesReport {
67
+ company: string;
68
+ mode: "dry_run" | "merge" | "delete";
69
+ duplicate_groups_before: number;
70
+ duplicate_groups_after: number;
71
+ copies_found: number;
72
+ plans: CopyPlan[];
73
+ skipped: CopySkip[];
74
+ merged: string[];
75
+ relinked: string[];
76
+ deleted: string[];
77
+ /** Deleted copies the vault no longer had (a resumed run, or never synced). */
78
+ vault_already_absent: string[];
79
+ /** Copies still carrying content, links or refs, so not deleted this run. */
80
+ not_ready: {
81
+ copy: string;
82
+ reason: string;
83
+ }[];
84
+ errors: {
85
+ path: string;
86
+ error: string;
87
+ }[];
88
+ /** Entity-file links whose target file does not exist. */
89
+ broken_links_before: number;
90
+ broken_links_after: number;
91
+ /** Links in `company-brief.md` to a copy; the brief is re-rendered by the next checkpoint. */
92
+ brief_links_to_copies: string[];
93
+ }
94
+ /** Deletes one exact vault key; resolves to the number of objects deleted. */
95
+ export type DeleteVaultKey = (key: string) => Promise<number>;
96
+ export interface MergeCopiesOptions {
97
+ hqRoot: string;
98
+ company: OntologyCompany;
99
+ mode: "dry_run" | "merge" | "delete";
100
+ /** Handle at most this many copies (sorted by path). */
101
+ limit?: number;
102
+ now: Date;
103
+ deleteVaultKey?: DeleteVaultKey;
104
+ }
105
+ /** Copies in scope, and the ones skipped with a reason. Read-only. */
106
+ export declare function findCopyPairs(index: EntityIndex): {
107
+ pairs: CopyPair[];
108
+ skipped: CopySkip[];
109
+ };
110
+ /**
111
+ * Plan, merge or delete numbered copies for one company. The dry run writes
112
+ * nothing; merge writes entity files only; delete removes vault keys and
113
+ * local copy files only.
114
+ */
115
+ export declare function mergeCopies(options: MergeCopiesOptions): Promise<MergeCopiesReport>;
116
+ //# sourceMappingURL=merge-copies.d.ts.map