@indigoai-us/hq-cli 5.345.190 → 5.345.193
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 +420 -0
- package/assets/bot-workers/setup/worker.yaml +91 -34
- package/dist/command-catalog.generated.d.ts +96 -19
- package/dist/command-catalog.generated.js +124 -21
- package/dist/commands/bot-jobs.d.ts +21 -18
- package/dist/commands/bot-jobs.js +127 -39
- package/dist/commands/bot.d.ts +7 -4
- package/dist/commands/bot.js +289 -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 +45 -0
- package/dist/commands/signals-run.js +175 -0
- package/dist/commands/signals.d.ts +1 -0
- package/dist/commands/signals.js +3 -0
- package/dist/commands/sources.d.ts +11 -0
- package/dist/commands/sources.js +137 -25
- package/dist/commands/staff/reset-test-account.d.ts +10 -0
- package/dist/commands/staff/reset-test-account.js +18 -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 +113 -2
- package/dist/lib/bot/fleet-local.js +597 -35
- 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.js +18 -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 +1 -0
- package/dist/lib/hot-channels/signals-pass.js +7 -5
- 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 +196 -0
- package/dist/lib/signals/engine.js +495 -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 +113 -0
- package/dist/lib/signals/steps/extract.js +765 -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 +112 -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/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
|
@@ -17,6 +17,7 @@ import { randomUUID } from "node:crypto";
|
|
|
17
17
|
import * as fs from "node:fs/promises";
|
|
18
18
|
import * as os from "node:os";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
+
import { assertVaultPath } from "../signals/write-guard.js";
|
|
20
21
|
import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
|
|
21
22
|
/** A lock whose last heartbeat is older than this is stale. */
|
|
22
23
|
export const SYNC_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
@@ -42,7 +43,8 @@ export function syncLockPath(vaultRoot, personKey) {
|
|
|
42
43
|
function serialize(body) {
|
|
43
44
|
return `${JSON.stringify(body)}\n`;
|
|
44
45
|
}
|
|
45
|
-
async function tryCreate(file, body) {
|
|
46
|
+
async function tryCreate(file, body, check) {
|
|
47
|
+
check(file);
|
|
46
48
|
try {
|
|
47
49
|
await fs.writeFile(file, serialize(body), { encoding: "utf8", flag: "wx" });
|
|
48
50
|
return true;
|
|
@@ -102,6 +104,10 @@ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
|
|
|
102
104
|
const heartbeatMs = options.heartbeatMs ?? SYNC_LOCK_HEARTBEAT_MS;
|
|
103
105
|
const alive = options.pidAlive ?? pidAlive;
|
|
104
106
|
const file = syncLockPath(vaultRoot, personKey);
|
|
107
|
+
const check = (p) => {
|
|
108
|
+
assertVaultPath(vaultRoot, p);
|
|
109
|
+
};
|
|
110
|
+
check(file);
|
|
105
111
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
106
112
|
const startedAt = now().toISOString();
|
|
107
113
|
const body = { pid: process.pid, host: os.hostname(), startedAt, heartbeatAt: startedAt };
|
|
@@ -120,6 +126,8 @@ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
|
|
|
120
126
|
if (released || !(await ownsFile()))
|
|
121
127
|
return;
|
|
122
128
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
129
|
+
check(tmp);
|
|
130
|
+
check(file);
|
|
123
131
|
await fs.writeFile(tmp, serialize({ ...body, heartbeatAt: now().toISOString() }), "utf8");
|
|
124
132
|
await fs.rename(tmp, file);
|
|
125
133
|
};
|
|
@@ -140,19 +148,21 @@ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
|
|
|
140
148
|
released = true;
|
|
141
149
|
if (timer)
|
|
142
150
|
clearInterval(timer);
|
|
143
|
-
if (await ownsFile())
|
|
151
|
+
if (await ownsFile()) {
|
|
152
|
+
check(file);
|
|
144
153
|
await fs.rm(file, { force: true });
|
|
154
|
+
}
|
|
145
155
|
},
|
|
146
156
|
};
|
|
147
157
|
};
|
|
148
|
-
if (await tryCreate(file, body))
|
|
158
|
+
if (await tryCreate(file, body, check))
|
|
149
159
|
return held();
|
|
150
160
|
const stale = await isStale(file, now().getTime(), staleMs, alive);
|
|
151
161
|
if (stale === false)
|
|
152
162
|
return null;
|
|
153
|
-
if (stale === true && !(await takeOverStale(file, now, staleMs, alive)))
|
|
163
|
+
if (stale === true && !(await takeOverStale(file, now, staleMs, alive, check)))
|
|
154
164
|
return null;
|
|
155
|
-
return (await tryCreate(file, body)) ? held() : null;
|
|
165
|
+
return (await tryCreate(file, body, check)) ? held() : null;
|
|
156
166
|
}
|
|
157
167
|
/** A takeover guard older than this belongs to a contender that died mid-takeover. */
|
|
158
168
|
const TAKEOVER_GUARD_STALE_MS = 60 * 1000;
|
|
@@ -164,8 +174,10 @@ const TAKEOVER_GUARD_STALE_MS = 60 * 1000;
|
|
|
164
174
|
* (its holder released and a new run took the lock), it is put back. Returns
|
|
165
175
|
* false when this contender should give up.
|
|
166
176
|
*/
|
|
167
|
-
async function takeOverStale(file, now, staleMs, alive) {
|
|
177
|
+
async function takeOverStale(file, now, staleMs, alive, check) {
|
|
168
178
|
const guard = `${file}.takeover`;
|
|
179
|
+
check(guard);
|
|
180
|
+
check(file);
|
|
169
181
|
const takeGuard = async () => {
|
|
170
182
|
try {
|
|
171
183
|
await fs.writeFile(guard, `${process.pid}\n`, { encoding: "utf8", flag: "wx" });
|
|
@@ -188,6 +200,7 @@ async function takeOverStale(file, now, staleMs, alive) {
|
|
|
188
200
|
if (age < TAKEOVER_GUARD_STALE_MS)
|
|
189
201
|
return false;
|
|
190
202
|
const staleGuard = `${guard}.stale-${randomUUID()}`;
|
|
203
|
+
check(staleGuard);
|
|
191
204
|
await fs.rename(guard, staleGuard).catch(() => undefined);
|
|
192
205
|
await fs.rm(staleGuard, { force: true });
|
|
193
206
|
if (!(await takeGuard()))
|
|
@@ -200,6 +213,7 @@ async function takeOverStale(file, now, staleMs, alive) {
|
|
|
200
213
|
if ((await isStale(file, now().getTime(), staleMs, alive)) !== true)
|
|
201
214
|
return false;
|
|
202
215
|
const aside = `${file}.stale-${process.pid}-${randomUUID()}`;
|
|
216
|
+
check(aside);
|
|
203
217
|
try {
|
|
204
218
|
await fs.rename(file, aside);
|
|
205
219
|
}
|
|
@@ -210,6 +224,7 @@ async function takeOverStale(file, now, staleMs, alive) {
|
|
|
210
224
|
}
|
|
211
225
|
const moved = await fs.readFile(aside, "utf8").catch(() => null);
|
|
212
226
|
if (moved !== seen) {
|
|
227
|
+
check(file);
|
|
213
228
|
await fs.link(aside, file).catch(() => undefined);
|
|
214
229
|
await fs.rm(aside, { force: true });
|
|
215
230
|
return false;
|
|
@@ -218,6 +233,7 @@ async function takeOverStale(file, now, staleMs, alive) {
|
|
|
218
233
|
return true;
|
|
219
234
|
}
|
|
220
235
|
finally {
|
|
236
|
+
check(guard);
|
|
221
237
|
await fs.rm(guard, { force: true });
|
|
222
238
|
}
|
|
223
239
|
}
|
|
@@ -49,6 +49,11 @@ export interface InstallPaths {
|
|
|
49
49
|
windowsTaskXmlPath: string;
|
|
50
50
|
/** Windows: `DOMAIN\user` the task is registered for (logon trigger + principal). */
|
|
51
51
|
windowsUser: string;
|
|
52
|
+
/**
|
|
53
|
+
* Windows: Task Scheduler name to register. Defaults to WINDOWS_TASK_NAME;
|
|
54
|
+
* set to the per-user name when the shared name is taken by another account.
|
|
55
|
+
*/
|
|
56
|
+
windowsTaskName?: string;
|
|
52
57
|
/**
|
|
53
58
|
* Windows: `conhost.exe` used as `conhost --headless node hq ...` so the
|
|
54
59
|
* logon-triggered daemon gets no visible console window. Undefined when the
|
|
@@ -257,6 +262,23 @@ export declare function renderSystemdSystemUnit(paths: InstallPaths): string;
|
|
|
257
262
|
* resolveWindowsHeadlessLauncher); otherwise node is run directly.
|
|
258
263
|
*/
|
|
259
264
|
export declare function renderWindowsTaskXml(paths: InstallPaths): string;
|
|
265
|
+
/** Sidecar under the daemon dir naming the task this user registered. */
|
|
266
|
+
export declare const WINDOWS_TASK_NAME_FILE = "windows-task-name";
|
|
267
|
+
/**
|
|
268
|
+
* Per-user task name, e.g. `ai.getindigo.hq-mesh-daemon.alice`. Task names
|
|
269
|
+
* live in one machine-wide namespace: when the shared name already belongs to
|
|
270
|
+
* a task another account registered, a standard user cannot see, replace, or
|
|
271
|
+
* delete it, and `schtasks /Create /F` fails with "Access is denied". The
|
|
272
|
+
* per-user name avoids that collision.
|
|
273
|
+
*/
|
|
274
|
+
export declare function windowsPerUserTaskName(windowsUser: string): string;
|
|
275
|
+
/** Task name this user's install registered (sidecar), else the shared name. */
|
|
276
|
+
export declare function installedWindowsTaskName(dir: string, deps?: {
|
|
277
|
+
existsSync?: typeof fs.existsSync;
|
|
278
|
+
readFileSync?: typeof fs.readFileSync;
|
|
279
|
+
}): string;
|
|
280
|
+
/** Recorded mesh task name for a home dir (`hq daemon migrate` legacy probe). */
|
|
281
|
+
export declare function recordedWindowsMeshTaskName(home: string, env?: NodeJS.ProcessEnv): string;
|
|
260
282
|
/** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
|
|
261
283
|
export declare function encodeWindowsTaskXml(rendered: string): Buffer;
|
|
262
284
|
/** Status column of `schtasks /Query /FO CSV /NH`: "TaskName","Next Run Time","Status". */
|
|
@@ -137,9 +137,14 @@ function migrateLegacyDaemonUnit(opts) {
|
|
|
137
137
|
});
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
140
|
+
const taskName = installedWindowsTaskName(path.dirname(unitPath), {
|
|
141
|
+
existsSync: opts.existsSync,
|
|
142
|
+
readFileSync: opts.readFileSync,
|
|
143
|
+
});
|
|
144
|
+
paths.windowsTaskName = taskName;
|
|
140
145
|
const rendered = renderWindowsTaskXml(paths);
|
|
141
146
|
replaceUnitFile(unitPath, encodeWindowsTaskXml(rendered), 0o600, () => {
|
|
142
|
-
const result = opts.run("schtasks.exe", ["/Create", "/TN",
|
|
147
|
+
const result = opts.run("schtasks.exe", ["/Create", "/TN", taskName, "/XML", unitPath, "/F"]);
|
|
143
148
|
if (result.status !== 0)
|
|
144
149
|
throw new Error("schtasks /Create failed while repairing the mesh unit");
|
|
145
150
|
});
|
|
@@ -230,9 +235,10 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
230
235
|
const xml = path.join(dir, `${WINDOWS_TASK_NAME}.xml`);
|
|
231
236
|
if (!existsSync(xml))
|
|
232
237
|
return { attempted: false, restarted: false };
|
|
233
|
-
|
|
238
|
+
const taskName = installedWindowsTaskName(dir, { existsSync, readFileSync });
|
|
239
|
+
run("schtasks.exe", ["/End", "/TN", taskName]);
|
|
234
240
|
stopWindowsDaemonProcess(dir, { existsSync, kill: opts.kill });
|
|
235
|
-
const result = run("schtasks.exe", ["/Run", "/TN",
|
|
241
|
+
const result = run("schtasks.exe", ["/Run", "/TN", taskName]);
|
|
236
242
|
return { attempted: true, restarted: result.status === 0 };
|
|
237
243
|
}
|
|
238
244
|
}
|
|
@@ -692,7 +698,7 @@ export function renderWindowsTaskXml(paths) {
|
|
|
692
698
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
693
699
|
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
694
700
|
<RegistrationInfo>
|
|
695
|
-
<URI>\\${WINDOWS_TASK_NAME}</URI>
|
|
701
|
+
<URI>\\${paths.windowsTaskName ?? WINDOWS_TASK_NAME}</URI>
|
|
696
702
|
<Description>HQ Work Mesh Live daemon (spool drainer + MQTT presence)</Description>
|
|
697
703
|
</RegistrationInfo>
|
|
698
704
|
<Triggers>
|
|
@@ -740,6 +746,50 @@ export function renderWindowsTaskXml(paths) {
|
|
|
740
746
|
`;
|
|
741
747
|
}
|
|
742
748
|
const WINDOWS_TASK_LOGON_TYPE = "InteractiveToken";
|
|
749
|
+
/** Sidecar under the daemon dir naming the task this user registered. */
|
|
750
|
+
export const WINDOWS_TASK_NAME_FILE = "windows-task-name";
|
|
751
|
+
/**
|
|
752
|
+
* Per-user task name, e.g. `ai.getindigo.hq-mesh-daemon.alice`. Task names
|
|
753
|
+
* live in one machine-wide namespace: when the shared name already belongs to
|
|
754
|
+
* a task another account registered, a standard user cannot see, replace, or
|
|
755
|
+
* delete it, and `schtasks /Create /F` fails with "Access is denied". The
|
|
756
|
+
* per-user name avoids that collision.
|
|
757
|
+
*/
|
|
758
|
+
export function windowsPerUserTaskName(windowsUser) {
|
|
759
|
+
const account = windowsUser.split("\\").pop() ?? windowsUser;
|
|
760
|
+
const slug = account.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
761
|
+
return `${WINDOWS_TASK_NAME}.${slug || "user"}`;
|
|
762
|
+
}
|
|
763
|
+
function isValidWindowsTaskName(name) {
|
|
764
|
+
return name === WINDOWS_TASK_NAME || /^ai\.getindigo\.hq-mesh-daemon\.[a-z0-9_-]+$/.test(name);
|
|
765
|
+
}
|
|
766
|
+
/** Task name this user's install registered (sidecar), else the shared name. */
|
|
767
|
+
export function installedWindowsTaskName(dir, deps = {}) {
|
|
768
|
+
const existsSync = deps.existsSync ?? fs.existsSync;
|
|
769
|
+
const readFileSync = deps.readFileSync ?? fs.readFileSync;
|
|
770
|
+
const file = path.join(dir, WINDOWS_TASK_NAME_FILE);
|
|
771
|
+
try {
|
|
772
|
+
if (!existsSync(file))
|
|
773
|
+
return WINDOWS_TASK_NAME;
|
|
774
|
+
const raw = readFileSync(file);
|
|
775
|
+
const name = (Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw)).trim();
|
|
776
|
+
return isValidWindowsTaskName(name) ? name : WINDOWS_TASK_NAME;
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return WINDOWS_TASK_NAME;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/** Recorded mesh task name for a home dir (`hq daemon migrate` legacy probe). */
|
|
783
|
+
export function recordedWindowsMeshTaskName(home, env = process.env) {
|
|
784
|
+
return installedWindowsTaskName(daemonDir(undefined, home, env));
|
|
785
|
+
}
|
|
786
|
+
function recordWindowsTaskName(dir, name) {
|
|
787
|
+
const file = path.join(dir, WINDOWS_TASK_NAME_FILE);
|
|
788
|
+
if (name === WINDOWS_TASK_NAME)
|
|
789
|
+
fs.rmSync(file, { force: true });
|
|
790
|
+
else
|
|
791
|
+
atomicWrite(file, `${name}\n`, { mode: 0o600 });
|
|
792
|
+
}
|
|
743
793
|
/** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
|
|
744
794
|
export function encodeWindowsTaskXml(rendered) {
|
|
745
795
|
return Buffer.from(`\uFEFF${rendered}`, "utf16le");
|
|
@@ -1015,21 +1065,52 @@ export function installDaemonService(opts) {
|
|
|
1015
1065
|
}
|
|
1016
1066
|
if (platform === "win32") {
|
|
1017
1067
|
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1068
|
+
const create = (name) => {
|
|
1069
|
+
const rendered = renderWindowsTaskXml({ ...paths, windowsTaskName: name });
|
|
1070
|
+
let error = null;
|
|
1071
|
+
try {
|
|
1072
|
+
replaceUnitFile(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), 0o600, () => {
|
|
1073
|
+
const created = schtasks(["/Create", "/TN", name, "/XML", paths.windowsTaskXmlPath, "/F"]);
|
|
1074
|
+
if (created.status !== 0) {
|
|
1075
|
+
throw new Error(`schtasks /Create failed for ${name} (exit ${created.status ?? "signal"}): ` +
|
|
1076
|
+
`${(created.stderr || created.stdout).trim()}`);
|
|
1077
|
+
}
|
|
1078
|
+
recordWindowsTaskName(paths.daemonDir, name);
|
|
1079
|
+
});
|
|
1026
1080
|
}
|
|
1027
|
-
|
|
1081
|
+
catch (err) {
|
|
1082
|
+
error = err instanceof Error ? err.message : String(err);
|
|
1083
|
+
}
|
|
1084
|
+
return { rendered, error };
|
|
1085
|
+
};
|
|
1086
|
+
// Try the name this user registered before (or the shared name). If the
|
|
1087
|
+
// shared name fails and this user cannot even query it, the task belongs
|
|
1088
|
+
// to another account, which a standard user cannot replace ("Access is
|
|
1089
|
+
// denied"); register a per-user name instead. A shared task this user can
|
|
1090
|
+
// query is their own, so its failure is reported rather than leaving two
|
|
1091
|
+
// logon tasks behind. Both attempts use the same unprivileged XML (logon
|
|
1092
|
+
// trigger and principal scoped to this user, InteractiveToken, LeastPrivilege).
|
|
1093
|
+
const perUser = windowsPerUserTaskName(paths.windowsUser);
|
|
1094
|
+
let taskName = paths.windowsTaskName ?? installedWindowsTaskName(paths.daemonDir);
|
|
1095
|
+
let attempt = create(taskName);
|
|
1096
|
+
const sharedHeldElsewhere = () => taskName === WINDOWS_TASK_NAME
|
|
1097
|
+
&& schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]).status !== 0;
|
|
1098
|
+
if (attempt.error && taskName !== perUser && sharedHeldElsewhere()) {
|
|
1099
|
+
const firstError = attempt.error;
|
|
1100
|
+
taskName = perUser;
|
|
1101
|
+
attempt = create(taskName);
|
|
1102
|
+
if (attempt.error)
|
|
1103
|
+
throw new Error(`${firstError}; per-user fallback: ${attempt.error}`);
|
|
1104
|
+
}
|
|
1105
|
+
else if (attempt.error) {
|
|
1106
|
+
throw new Error(attempt.error);
|
|
1107
|
+
}
|
|
1108
|
+
const rendered = attempt.rendered;
|
|
1028
1109
|
// /F replaced any previous registration without ending its instance; stop
|
|
1029
1110
|
// the recorded daemon so the fresh task instance can take the pid lock.
|
|
1030
|
-
schtasks(["/End", "/TN",
|
|
1111
|
+
schtasks(["/End", "/TN", taskName]);
|
|
1031
1112
|
stopWindowsDaemonProcess(paths.daemonDir, { kill: opts.kill });
|
|
1032
|
-
const started = schtasks(["/Run", "/TN",
|
|
1113
|
+
const started = schtasks(["/Run", "/TN", taskName]);
|
|
1033
1114
|
const running = started.status === 0;
|
|
1034
1115
|
const mode = describeWindowsLaunchMode(paths);
|
|
1035
1116
|
const rootLine = paths.hqRoot
|
|
@@ -1038,13 +1119,13 @@ export function installDaemonService(opts) {
|
|
|
1038
1119
|
return {
|
|
1039
1120
|
platform,
|
|
1040
1121
|
action: "install",
|
|
1041
|
-
paths,
|
|
1122
|
+
paths: { ...paths, windowsTaskName: taskName },
|
|
1042
1123
|
rendered,
|
|
1043
1124
|
dest: paths.windowsTaskXmlPath,
|
|
1044
1125
|
running,
|
|
1045
1126
|
message: running
|
|
1046
|
-
? `${rootLine}Installed Scheduled Task ${
|
|
1047
|
-
: `${rootLine}Installed Scheduled Task ${
|
|
1127
|
+
? `${rootLine}Installed Scheduled Task ${taskName} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
|
|
1128
|
+
: `${rootLine}Installed Scheduled Task ${taskName} → ${paths.windowsTaskXmlPath} (${mode}); ` +
|
|
1048
1129
|
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.${legacyNote}`,
|
|
1049
1130
|
notice: windowsLogonNotice(paths),
|
|
1050
1131
|
};
|
|
@@ -1063,26 +1144,30 @@ export function uninstallDaemonService(opts) {
|
|
|
1063
1144
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
1064
1145
|
if (platform === "win32") {
|
|
1065
1146
|
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
1066
|
-
const
|
|
1147
|
+
const taskName = paths.windowsTaskName ?? installedWindowsTaskName(paths.daemonDir, { existsSync });
|
|
1148
|
+
const registered = schtasks(["/Query", "/TN", taskName, "/FO", "CSV", "/NH"]).status === 0;
|
|
1067
1149
|
if (registered) {
|
|
1068
|
-
schtasks(["/End", "/TN",
|
|
1150
|
+
schtasks(["/End", "/TN", taskName]);
|
|
1069
1151
|
stopWindowsDaemonProcess(paths.daemonDir, { existsSync, kill: opts.kill });
|
|
1070
|
-
const deleted = schtasks(["/Delete", "/TN",
|
|
1152
|
+
const deleted = schtasks(["/Delete", "/TN", taskName, "/F"]);
|
|
1071
1153
|
if (deleted.status !== 0) {
|
|
1072
|
-
throw new Error(`schtasks /Delete failed for ${
|
|
1154
|
+
throw new Error(`schtasks /Delete failed for ${taskName} (exit ${deleted.status ?? "signal"}): ` +
|
|
1073
1155
|
`${(deleted.stderr || deleted.stdout).trim()}`);
|
|
1074
1156
|
}
|
|
1075
1157
|
}
|
|
1076
1158
|
if (existsSync(paths.windowsTaskXmlPath))
|
|
1077
1159
|
unlinkSync(paths.windowsTaskXmlPath);
|
|
1160
|
+
const nameFile = path.join(paths.daemonDir, WINDOWS_TASK_NAME_FILE);
|
|
1161
|
+
if (existsSync(nameFile))
|
|
1162
|
+
unlinkSync(nameFile);
|
|
1078
1163
|
return {
|
|
1079
1164
|
platform,
|
|
1080
1165
|
action: "uninstall",
|
|
1081
1166
|
paths,
|
|
1082
1167
|
dest: paths.windowsTaskXmlPath,
|
|
1083
1168
|
message: registered
|
|
1084
|
-
? `Removed Scheduled Task ${
|
|
1085
|
-
: `Scheduled Task ${
|
|
1169
|
+
? `Removed Scheduled Task ${taskName}`
|
|
1170
|
+
: `Scheduled Task ${taskName} was not registered; removed local task XML if present`,
|
|
1086
1171
|
};
|
|
1087
1172
|
}
|
|
1088
1173
|
if (platform === "darwin") {
|
|
@@ -1156,7 +1241,9 @@ export function daemonServiceStatus(opts) {
|
|
|
1156
1241
|
// localized ("Running" / "Wird ausgeführt" / ...), so the daemon pid lock
|
|
1157
1242
|
// is the source of truth for "running"; the column is shown when it
|
|
1158
1243
|
// happens to carry a value, as extra context only.
|
|
1159
|
-
const
|
|
1244
|
+
const taskName = paths.windowsTaskName
|
|
1245
|
+
?? installedWindowsTaskName(paths.daemonDir, { existsSync, readFileSync: opts.readFileSync });
|
|
1246
|
+
const query = schtasks(["/Query", "/TN", taskName, "/FO", "CSV", "/NH"]);
|
|
1160
1247
|
const installed = query.status === 0;
|
|
1161
1248
|
const taskState = installed ? parseSchtasksCsvStatus(query.stdout) : null;
|
|
1162
1249
|
// The verbose query carries "Last Result": a non-zero, non-informational
|
|
@@ -1164,7 +1251,7 @@ export function daemonServiceStatus(opts) {
|
|
|
1164
1251
|
// --headless), which the pid lock alone would report as merely "not
|
|
1165
1252
|
// running". Surface it so a failed start never looks like a clean idle.
|
|
1166
1253
|
const verbose = installed
|
|
1167
|
-
? schtasks(["/Query", "/TN",
|
|
1254
|
+
? schtasks(["/Query", "/TN", taskName, "/V", "/FO", "CSV"])
|
|
1168
1255
|
: null;
|
|
1169
1256
|
const lastResult = verbose && verbose.status === 0 ? parseSchtasksLastResult(verbose.stdout) : null;
|
|
1170
1257
|
const lastRunFailed = schtasksLastResultFailed(lastResult);
|
|
@@ -1186,14 +1273,14 @@ export function daemonServiceStatus(opts) {
|
|
|
1186
1273
|
let message;
|
|
1187
1274
|
if (!installed) {
|
|
1188
1275
|
message = running
|
|
1189
|
-
? `Scheduled Task ${
|
|
1190
|
-
: `Scheduled Task ${
|
|
1276
|
+
? `Scheduled Task ${taskName}: not installed (daemon running outside the task)`
|
|
1277
|
+
: `Scheduled Task ${taskName}: not installed`;
|
|
1191
1278
|
}
|
|
1192
1279
|
else if (running) {
|
|
1193
|
-
message = `Scheduled Task ${
|
|
1280
|
+
message = `Scheduled Task ${taskName}: running${detail}`;
|
|
1194
1281
|
}
|
|
1195
1282
|
else {
|
|
1196
|
-
message = `Scheduled Task ${
|
|
1283
|
+
message = `Scheduled Task ${taskName}: installed (daemon not running${lock === "stale" ? ", stale pid lock" : ""})${detail}`;
|
|
1197
1284
|
}
|
|
1198
1285
|
return {
|
|
1199
1286
|
platform,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export declare const SIGNALS_ADAPTERS_CATALOG_PATH = "core/settings/signals-adapters.yaml";
|
|
2
|
+
export declare const FLAG_OFF_REASON = "flag-off";
|
|
3
|
+
export interface SignalsAdapterCatalogEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
schedule?: string;
|
|
7
|
+
flag?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AdapterFlag {
|
|
10
|
+
/** hq-flags key. */
|
|
11
|
+
flag: string;
|
|
12
|
+
/**
|
|
13
|
+
* The registered row's `defaultValue`: the value used when the row is
|
|
14
|
+
* absent, archived or unreadable.
|
|
15
|
+
*/
|
|
16
|
+
defaultValue: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* hq-flags keys that gate built-in adapters, with their registered defaults.
|
|
20
|
+
*
|
|
21
|
+
* `hq.hot-channel-slack` and `hq.hot-channel-linear` are default-on kill
|
|
22
|
+
* switches (indigo-feature-flags-default-on-as-fail-safes; owner pick,
|
|
23
|
+
* 2026-11-01): on for every company, and setting the row to false turns the
|
|
24
|
+
* adapter off. An absent or unregistered row keeps the adapter on. An
|
|
25
|
+
* unreadable registry keeps the last value read on this machine, and the
|
|
26
|
+
* adapter on when the flag was never read here.
|
|
27
|
+
*/
|
|
28
|
+
export declare const ADAPTER_FLAGS: Readonly<Record<string, AdapterFlag>>;
|
|
29
|
+
/**
|
|
30
|
+
* Reads one company flag. `defaultValue` is the answer when the registry has
|
|
31
|
+
* no opinion (absent or unregistered row), or cannot be read and no value was
|
|
32
|
+
* read on this machine before.
|
|
33
|
+
*/
|
|
34
|
+
export type AdapterFlagReader = (flag: string, defaultValue: boolean) => boolean | Promise<boolean>;
|
|
35
|
+
/** Where the reader keeps the last values it read: one file per company. */
|
|
36
|
+
export interface AdapterFlagState {
|
|
37
|
+
hqRoot: string;
|
|
38
|
+
/** Company slug: the state file is `workspace/signals/flags/{company}.json`. */
|
|
39
|
+
company: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function adapterFlagStatePath(company: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* The shared hq-flags evaluator for one company, memoized per flag for the
|
|
44
|
+
* life of the reader (one reader per sync run). Each read is bounded by the
|
|
45
|
+
* evaluator's request timeout. With `state`, a successful read is saved. A
|
|
46
|
+
* failed or timed-out read of a kill switch (`defaultValue` true) answers the
|
|
47
|
+
* last value saved for that flag and company, else `defaultValue`. A failed
|
|
48
|
+
* read of a default-off gate answers `defaultValue` (off): it fails closed.
|
|
49
|
+
* Without `state`, a failed read answers `defaultValue`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function companyAdapterFlagReader(companyUid: string, idToken: string, state?: AdapterFlagState): AdapterFlagReader;
|
|
52
|
+
/**
|
|
53
|
+
* Reads the local catalog; null when the file is absent. Malformed entries are
|
|
54
|
+
* dropped. An unreadable file or invalid yaml prints one warning to stderr and
|
|
55
|
+
* also answers null (no yaml gating), so a bad local file cannot break a sync.
|
|
56
|
+
*/
|
|
57
|
+
export declare function readSignalsAdaptersCatalog(hqRoot: string): SignalsAdapterCatalogEntry[] | null;
|
|
58
|
+
export interface AdapterGating {
|
|
59
|
+
/** Registered adapters to run. */
|
|
60
|
+
enabled: string[];
|
|
61
|
+
/** Adapter id -> skip reason. */
|
|
62
|
+
skips: Record<string, string>;
|
|
63
|
+
/** Adapter id -> the flag that turned it off (absent for a yaml `enabled: false`). */
|
|
64
|
+
offFlags: Record<string, string>;
|
|
65
|
+
}
|
|
66
|
+
/** The flag gating one adapter: the yaml's `flag`, else the built-in one. */
|
|
67
|
+
export declare function adapterFlag(id: string, entry: SignalsAdapterCatalogEntry | undefined): AdapterFlag | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Merge the local catalog with the registered adapter ids. An adapter with
|
|
70
|
+
* `enabled: false`, or whose flag `isFlagOn` reads as off, is skipped with
|
|
71
|
+
* reason `flag-off`. Registered adapters the yaml does not list stay enabled
|
|
72
|
+
* unless their built-in flag is off. Yaml entries naming an unregistered
|
|
73
|
+
* adapter are ignored. Without `isFlagOn`, flags are not evaluated here: the
|
|
74
|
+
* sync evaluates them (hq-pro also gates flagged channels server-side).
|
|
75
|
+
*/
|
|
76
|
+
export declare function gateAdapters(registeredIds: readonly string[], catalog: readonly SignalsAdapterCatalogEntry[] | null, isFlagOn?: AdapterFlagReader): Promise<AdapterGating>;
|
|
77
|
+
//# sourceMappingURL=adapters-catalog.d.ts.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local adapter gating for hot-channel syncs (hot-signal-sync US-008, US-009).
|
|
3
|
+
*
|
|
4
|
+
* `core/settings/signals-adapters.yaml` (shipped by hq-core) mirrors the hq-pro
|
|
5
|
+
* hot-channel catalog for local gating. hq-pro stays canonical: this file can
|
|
6
|
+
* only turn a registered adapter off, never add one. When the file is absent
|
|
7
|
+
* every registered adapter is enabled.
|
|
8
|
+
*
|
|
9
|
+
* Company flags are hq-flags rows read through the shared evaluator
|
|
10
|
+
* (`readCompanyFlag`: person, company uid, company slug, default). There is
|
|
11
|
+
* no company list in code. `runCompanyHotChannelSync` applies the gate for
|
|
12
|
+
* every caller (`hq sources sync`, the bot job and `hq signals run`).
|
|
13
|
+
*
|
|
14
|
+
* Read failures (indigo-hq-flags-only-for-feature-gating, US-009 re-review):
|
|
15
|
+
* a default-on flag is an ingest kill switch, so a failed or timed-out read
|
|
16
|
+
* uses the last value successfully read on this machine for that flag and
|
|
17
|
+
* company, kept in `workspace/signals/flags/{company}.json`, and the switch's
|
|
18
|
+
* default (on) when it was never read here (owner pick, 2026-11-01: "On when
|
|
19
|
+
* never read"). A default-off gate such as hq.hot-channel-gmail (owner hold)
|
|
20
|
+
* fails closed: a failed read is off, whatever was read before.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import * as yaml from "js-yaml";
|
|
25
|
+
import { guardedWrite } from "./write-guard.js";
|
|
26
|
+
export const SIGNALS_ADAPTERS_CATALOG_PATH = "core/settings/signals-adapters.yaml";
|
|
27
|
+
export const FLAG_OFF_REASON = "flag-off";
|
|
28
|
+
/**
|
|
29
|
+
* hq-flags keys that gate built-in adapters, with their registered defaults.
|
|
30
|
+
*
|
|
31
|
+
* `hq.hot-channel-slack` and `hq.hot-channel-linear` are default-on kill
|
|
32
|
+
* switches (indigo-feature-flags-default-on-as-fail-safes; owner pick,
|
|
33
|
+
* 2026-11-01): on for every company, and setting the row to false turns the
|
|
34
|
+
* adapter off. An absent or unregistered row keeps the adapter on. An
|
|
35
|
+
* unreadable registry keeps the last value read on this machine, and the
|
|
36
|
+
* adapter on when the flag was never read here.
|
|
37
|
+
*/
|
|
38
|
+
export const ADAPTER_FLAGS = {
|
|
39
|
+
slack: { flag: "hq.hot-channel-slack", defaultValue: true },
|
|
40
|
+
linear: { flag: "hq.hot-channel-linear", defaultValue: true },
|
|
41
|
+
// US-010: registered in hq-pro with defaultValue false under an explicit
|
|
42
|
+
// owner hold (Gmail waits for Google's gmail.readonly verification). Setting
|
|
43
|
+
// the row to true turns Gmail on; an absent or unreadable row keeps it off.
|
|
44
|
+
// The key matches GMAIL_FLAG in hot-channels/adapters/gmail.ts.
|
|
45
|
+
gmail: { flag: "hq.hot-channel-gmail", defaultValue: false },
|
|
46
|
+
};
|
|
47
|
+
export function adapterFlagStatePath(company) {
|
|
48
|
+
return `workspace/signals/flags/${company}.json`;
|
|
49
|
+
}
|
|
50
|
+
function readLastFlags(state, companyUid) {
|
|
51
|
+
let doc;
|
|
52
|
+
try {
|
|
53
|
+
doc = JSON.parse(fs.readFileSync(path.join(state.hqRoot, adapterFlagStatePath(state.company)), "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const d = doc;
|
|
59
|
+
if (!d || d.companyUid !== companyUid || typeof d.flags !== "object" || d.flags === null)
|
|
60
|
+
return null;
|
|
61
|
+
return { version: 1, companyUid, flags: d.flags };
|
|
62
|
+
}
|
|
63
|
+
/** The last value read on this machine for this flag and company, if any. */
|
|
64
|
+
function lastReadValue(state, companyUid, flag) {
|
|
65
|
+
const value = readLastFlags(state, companyUid)?.flags[flag]?.value;
|
|
66
|
+
return typeof value === "boolean" ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record a successfully read value. Read, merge and write are synchronous, so
|
|
70
|
+
* concurrent reads in one process cannot drop each other's values. The write
|
|
71
|
+
* is atomic (temp file, then rename, through the signals write guard).
|
|
72
|
+
*/
|
|
73
|
+
function rememberValue(state, companyUid, flag, value) {
|
|
74
|
+
const doc = readLastFlags(state, companyUid) ?? { version: 1, companyUid, flags: {} };
|
|
75
|
+
if (doc.flags[flag]?.value === value)
|
|
76
|
+
return;
|
|
77
|
+
doc.flags[flag] = { value, readAt: new Date().toISOString() };
|
|
78
|
+
try {
|
|
79
|
+
guardedWrite(state.hqRoot, adapterFlagStatePath(state.company), `${JSON.stringify(doc, null, 2)}\n`, {
|
|
80
|
+
company: state.company,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
85
|
+
console.error(`hq: Could not save the last value of ${flag} (${errorClass}).`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The shared hq-flags evaluator for one company, memoized per flag for the
|
|
90
|
+
* life of the reader (one reader per sync run). Each read is bounded by the
|
|
91
|
+
* evaluator's request timeout. With `state`, a successful read is saved. A
|
|
92
|
+
* failed or timed-out read of a kill switch (`defaultValue` true) answers the
|
|
93
|
+
* last value saved for that flag and company, else `defaultValue`. A failed
|
|
94
|
+
* read of a default-off gate answers `defaultValue` (off): it fails closed.
|
|
95
|
+
* Without `state`, a failed read answers `defaultValue`.
|
|
96
|
+
*/
|
|
97
|
+
export function companyAdapterFlagReader(companyUid, idToken, state) {
|
|
98
|
+
const memo = new Map();
|
|
99
|
+
return (flag, defaultValue) => {
|
|
100
|
+
const key = `${flag}\n${defaultValue}`;
|
|
101
|
+
let hit = memo.get(key);
|
|
102
|
+
if (!hit) {
|
|
103
|
+
// Loaded on first read: the registry module embeds its worker source.
|
|
104
|
+
hit = import("../flag-registry.js").then(async ({ readCompanyFlag }) => {
|
|
105
|
+
const read = await readCompanyFlag(companyUid, idToken, flag, { fallback: defaultValue });
|
|
106
|
+
if (read === null) {
|
|
107
|
+
if (!defaultValue || !state)
|
|
108
|
+
return defaultValue;
|
|
109
|
+
return lastReadValue(state, companyUid, flag) ?? defaultValue;
|
|
110
|
+
}
|
|
111
|
+
// A process-local override is not a registry read: answer it, keep nothing.
|
|
112
|
+
if (state && read.source !== "local-override")
|
|
113
|
+
rememberValue(state, companyUid, flag, read.value);
|
|
114
|
+
return read.value;
|
|
115
|
+
});
|
|
116
|
+
memo.set(key, hit);
|
|
117
|
+
}
|
|
118
|
+
return hit;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reads the local catalog; null when the file is absent. Malformed entries are
|
|
123
|
+
* dropped. An unreadable file or invalid yaml prints one warning to stderr and
|
|
124
|
+
* also answers null (no yaml gating), so a bad local file cannot break a sync.
|
|
125
|
+
*/
|
|
126
|
+
export function readSignalsAdaptersCatalog(hqRoot) {
|
|
127
|
+
let doc;
|
|
128
|
+
try {
|
|
129
|
+
const text = fs.readFileSync(path.join(hqRoot, SIGNALS_ADAPTERS_CATALOG_PATH), "utf8");
|
|
130
|
+
doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err.code === "ENOENT")
|
|
134
|
+
return null;
|
|
135
|
+
const reason = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
136
|
+
process.stderr.write(`hq: warning: ignoring ${SIGNALS_ADAPTERS_CATALOG_PATH} (${reason}); running adapters without yaml gating\n`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const list = Array.isArray(doc?.adapters) ? doc.adapters : [];
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const raw of list) {
|
|
142
|
+
const e = raw;
|
|
143
|
+
if (!e || typeof e.id !== "string" || e.id.length === 0)
|
|
144
|
+
continue;
|
|
145
|
+
out.push({
|
|
146
|
+
id: e.id,
|
|
147
|
+
enabled: e.enabled !== false,
|
|
148
|
+
...(typeof e.schedule === "string" ? { schedule: e.schedule } : {}),
|
|
149
|
+
...(typeof e.flag === "string" && e.flag.length > 0 ? { flag: e.flag } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/** The flag gating one adapter: the yaml's `flag`, else the built-in one. */
|
|
155
|
+
export function adapterFlag(id, entry) {
|
|
156
|
+
const builtin = ADAPTER_FLAGS[id];
|
|
157
|
+
if (entry?.flag === undefined)
|
|
158
|
+
return builtin;
|
|
159
|
+
// A yaml flag keeps the built-in default when it names the built-in key;
|
|
160
|
+
// any other key is read as a kill switch (on unless the row says off).
|
|
161
|
+
return { flag: entry.flag, defaultValue: builtin?.flag === entry.flag ? builtin.defaultValue : true };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Merge the local catalog with the registered adapter ids. An adapter with
|
|
165
|
+
* `enabled: false`, or whose flag `isFlagOn` reads as off, is skipped with
|
|
166
|
+
* reason `flag-off`. Registered adapters the yaml does not list stay enabled
|
|
167
|
+
* unless their built-in flag is off. Yaml entries naming an unregistered
|
|
168
|
+
* adapter are ignored. Without `isFlagOn`, flags are not evaluated here: the
|
|
169
|
+
* sync evaluates them (hq-pro also gates flagged channels server-side).
|
|
170
|
+
*/
|
|
171
|
+
export async function gateAdapters(registeredIds, catalog, isFlagOn) {
|
|
172
|
+
const byId = new Map((catalog ?? []).map((e) => [e.id, e]));
|
|
173
|
+
const decided = await Promise.all(registeredIds.map(async (id) => {
|
|
174
|
+
const entry = byId.get(id);
|
|
175
|
+
if (entry && !entry.enabled)
|
|
176
|
+
return { id, on: false, flag: undefined };
|
|
177
|
+
const flag = adapterFlag(id, entry);
|
|
178
|
+
if (!flag || !isFlagOn)
|
|
179
|
+
return { id, on: true, flag: undefined };
|
|
180
|
+
return { id, on: await isFlagOn(flag.flag, flag.defaultValue), flag: flag.flag };
|
|
181
|
+
}));
|
|
182
|
+
const gating = { enabled: [], skips: {}, offFlags: {} };
|
|
183
|
+
for (const { id, on, flag } of decided) {
|
|
184
|
+
if (on) {
|
|
185
|
+
gating.enabled.push(id);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
gating.skips[id] = FLAG_OFF_REASON;
|
|
189
|
+
if (flag)
|
|
190
|
+
gating.offFlags[id] = flag;
|
|
191
|
+
}
|
|
192
|
+
return gating;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=adapters-catalog.js.map
|