@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +431 -0
  2. package/assets/bot-workers/setup/worker.yaml +91 -34
  3. package/dist/command-catalog.generated.d.ts +145 -19
  4. package/dist/command-catalog.generated.js +184 -21
  5. package/dist/commands/bot-jobs.d.ts +21 -18
  6. package/dist/commands/bot-jobs.js +127 -39
  7. package/dist/commands/bot.d.ts +4 -3
  8. package/dist/commands/bot.js +265 -140
  9. package/dist/commands/core-jobs-schedule.js +10 -0
  10. package/dist/commands/daemon.js +37 -0
  11. package/dist/commands/files.d.ts +41 -6
  12. package/dist/commands/files.js +108 -27
  13. package/dist/commands/pack-start-bot.d.ts +4 -6
  14. package/dist/commands/pack-start-bot.js +4 -8
  15. package/dist/commands/signals-run.d.ts +46 -0
  16. package/dist/commands/signals-run.js +180 -0
  17. package/dist/commands/signals.d.ts +1 -0
  18. package/dist/commands/signals.js +3 -0
  19. package/dist/commands/sources.d.ts +58 -0
  20. package/dist/commands/sources.js +294 -26
  21. package/dist/commands/staff/reset-test-account.d.ts +10 -0
  22. package/dist/commands/staff/reset-test-account.js +18 -2
  23. package/dist/commands/whoami.d.ts +7 -0
  24. package/dist/commands/whoami.js +35 -2
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/lib/bot/api.d.ts +3 -2
  28. package/dist/lib/bot/config.d.ts +2 -0
  29. package/dist/lib/bot/fleet-cron.d.ts +121 -0
  30. package/dist/lib/bot/fleet-cron.js +319 -0
  31. package/dist/lib/bot/fleet-local.d.ts +93 -1
  32. package/dist/lib/bot/fleet-local.js +546 -34
  33. package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
  34. package/dist/lib/bot/fleet-owner-companies.js +30 -0
  35. package/dist/lib/bot/fleet-watch.d.ts +25 -0
  36. package/dist/lib/bot/fleet-watch.js +51 -0
  37. package/dist/lib/bot/index.d.ts +1 -1
  38. package/dist/lib/bot/index.js +1 -1
  39. package/dist/lib/bot/job-run.d.ts +5 -4
  40. package/dist/lib/bot/job-run.js +5 -4
  41. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
  42. package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
  43. package/dist/lib/bot/local-jobs.d.ts +13 -2
  44. package/dist/lib/bot/local-jobs.js +17 -4
  45. package/dist/lib/bot/progress.d.ts +14 -0
  46. package/dist/lib/bot/progress.js +47 -1
  47. package/dist/lib/bot/run.d.ts +55 -2
  48. package/dist/lib/bot/run.js +192 -10
  49. package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
  50. package/dist/lib/bot/setup-tool-offer.js +16 -0
  51. package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
  52. package/dist/lib/bot/signals-engine-flag.js +30 -0
  53. package/dist/lib/daemon/catalog.d.ts +4 -0
  54. package/dist/lib/daemon/catalog.js +29 -9
  55. package/dist/lib/daemon/legacy.d.ts +6 -0
  56. package/dist/lib/daemon/legacy.js +16 -10
  57. package/dist/lib/daemon/run.d.ts +1 -0
  58. package/dist/lib/daemon/run.js +10 -0
  59. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  60. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  61. package/dist/lib/flag-registry.d.ts +13 -1
  62. package/dist/lib/flag-registry.js +46 -0
  63. package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
  64. package/dist/lib/hot-channels/adapters/attio.js +367 -29
  65. package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
  66. package/dist/lib/hot-channels/adapters/gmail.js +59 -2
  67. package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
  68. package/dist/lib/hot-channels/adapters/linear.js +71 -1
  69. package/dist/lib/hot-channels/adapters/meetings.js +4 -1
  70. package/dist/lib/hot-channels/adapters/slack.js +7 -3
  71. package/dist/lib/hot-channels/alert.js +1 -1
  72. package/dist/lib/hot-channels/contract.d.ts +11 -0
  73. package/dist/lib/hot-channels/contract.js +1 -0
  74. package/dist/lib/hot-channels/cursors.js +1 -1
  75. package/dist/lib/hot-channels/landing.d.ts +6 -2
  76. package/dist/lib/hot-channels/landing.js +13 -6
  77. package/dist/lib/hot-channels/ledger.js +2 -0
  78. package/dist/lib/hot-channels/loop.d.ts +6 -0
  79. package/dist/lib/hot-channels/loop.js +19 -4
  80. package/dist/lib/hot-channels/registry.js +9 -1
  81. package/dist/lib/hot-channels/run.d.ts +18 -0
  82. package/dist/lib/hot-channels/run.js +15 -4
  83. package/dist/lib/hot-channels/signals-pass.d.ts +34 -2
  84. package/dist/lib/hot-channels/signals-pass.js +149 -38
  85. package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
  86. package/dist/lib/hot-channels/signals-redo.js +207 -0
  87. package/dist/lib/hot-channels/sync-lock.js +22 -6
  88. package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
  89. package/dist/lib/mesh/live/daemon/install.js +117 -30
  90. package/dist/lib/signals/adapters-catalog.d.ts +77 -0
  91. package/dist/lib/signals/adapters-catalog.js +194 -0
  92. package/dist/lib/signals/engine.d.ts +202 -0
  93. package/dist/lib/signals/engine.js +504 -0
  94. package/dist/lib/signals/failure-alert.d.ts +89 -0
  95. package/dist/lib/signals/failure-alert.js +175 -0
  96. package/dist/lib/signals/local-date.d.ts +3 -0
  97. package/dist/lib/signals/local-date.js +6 -0
  98. package/dist/lib/signals/projection.d.ts +42 -0
  99. package/dist/lib/signals/projection.js +67 -0
  100. package/dist/lib/signals/reply.d.ts +112 -0
  101. package/dist/lib/signals/reply.js +368 -0
  102. package/dist/lib/signals/run-lock.d.ts +36 -0
  103. package/dist/lib/signals/run-lock.js +148 -0
  104. package/dist/lib/signals/run-record.d.ts +24 -0
  105. package/dist/lib/signals/run-record.js +82 -0
  106. package/dist/lib/signals/sanitize.d.ts +105 -0
  107. package/dist/lib/signals/sanitize.js +439 -0
  108. package/dist/lib/signals/settings.d.ts +10 -0
  109. package/dist/lib/signals/settings.js +48 -0
  110. package/dist/lib/signals/steps/adapters.d.ts +14 -0
  111. package/dist/lib/signals/steps/adapters.js +156 -0
  112. package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
  113. package/dist/lib/signals/steps/entity-similarity.js +47 -0
  114. package/dist/lib/signals/steps/extract.d.ts +118 -0
  115. package/dist/lib/signals/steps/extract.js +793 -0
  116. package/dist/lib/signals/steps/garden.d.ts +24 -0
  117. package/dist/lib/signals/steps/garden.js +250 -0
  118. package/dist/lib/signals/steps/types.d.ts +117 -0
  119. package/dist/lib/signals/steps/types.js +4 -0
  120. package/dist/lib/signals/steps/upload.d.ts +43 -0
  121. package/dist/lib/signals/steps/upload.js +324 -0
  122. package/dist/lib/signals/write-guard.d.ts +63 -0
  123. package/dist/lib/signals/write-guard.js +287 -0
  124. package/dist/utils/resident-package-preload.js +16 -0
  125. package/dist/utils/vault-api.d.ts +1 -0
  126. package/dist/utils/vault-api.js +1 -1
  127. package/package.json +9 -9
  128. package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
  129. package/dist/lib/bot/fleet-default-flag.js +0 -127
@@ -0,0 +1,324 @@
1
+ /**
2
+ * upload step (Hot-Signal Sync US-005).
3
+ *
4
+ * The step changes nothing about vault sync. `hq sync` and the background
5
+ * runner push and pull exactly what they did before Hot-Signal Sync (owner
6
+ * decision 2026-11-01). Promoted signals and company sources/ stay local
7
+ * because hq-cloud refuses client uploads of signals/** and sources/**;
8
+ * sharing promoted signals through audience ACLs is a later, separate
9
+ * hq-cloud change.
10
+ *
11
+ * 1. Member projection: pulls the caller's own
12
+ * `ontology/projections/members/{memberUid}/v1/` prefix into
13
+ * workspace/signals/projections/{company}/{memberUid}/v1/ and records its
14
+ * state. hq-pro has no export trigger route yet (triggerProjectionExport).
15
+ * 2. Contributions: every signal promoted since the upload watermark
16
+ * (workspace/signals/upload/{company}/{memberUid}.json) is sanitized
17
+ * (src/lib/signals/sanitize.ts) and posted, one request each, to
18
+ * POST /v1/files/contributions/company/{slug}/meetings
19
+ * { destination: "member-signals/{memberUid}/observations/meetings/@{aud}/{type}/{id}.md",
20
+ * contribution: sanitized-contribution.v1 }
21
+ * The server writes only under the caller's own member-signals prefix.
22
+ */
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { refreshMemberProjection, triggerProjectionExport, memberProjectionPrefix } from "../projection.js";
26
+ import { sanitizeSignal, MEETING_SOURCE_RE, splitFrontmatter } from "../sanitize.js";
27
+ const MAX_TRACKED_SHAS = 5000;
28
+ export function contributionRoute(company) {
29
+ return `/v1/files/contributions/company/${encodeURIComponent(company)}/meetings`;
30
+ }
31
+ export function watermarkRel(company, memberUid) {
32
+ return `workspace/signals/upload/${company}/${memberUid}.json`;
33
+ }
34
+ export function readWatermark(hqRoot, company, memberUid) {
35
+ try {
36
+ const raw = JSON.parse(fs.readFileSync(path.join(hqRoot, watermarkRel(company, memberUid)), "utf8"));
37
+ return {
38
+ lastUploadedAt: typeof raw.lastUploadedAt === "string" ? raw.lastUploadedAt : null,
39
+ uploaded: Array.isArray(raw.uploaded) ? raw.uploaded.filter((s) => typeof s === "string") : [],
40
+ rejected: raw.rejected && typeof raw.rejected === "object" ? raw.rejected : {},
41
+ };
42
+ }
43
+ catch {
44
+ return { lastUploadedAt: null, uploaded: [], rejected: {} };
45
+ }
46
+ }
47
+ /** Promoted signals for a company not yet uploaded or rejected, newer than the watermark. */
48
+ export function listSignalCandidates(hqRoot, company, wm) {
49
+ const companyRoot = path.join(hqRoot, "companies", company);
50
+ const signalsRoot = path.join(companyRoot, "signals");
51
+ const since = wm.lastUploadedAt ? Date.parse(wm.lastUploadedAt) : -Infinity;
52
+ const done = new Set([...wm.uploaded, ...Object.keys(wm.rejected)]);
53
+ const out = [];
54
+ const walk = (dir, depth) => {
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ for (const e of entries) {
63
+ if (e.name.startsWith("_") || e.name.startsWith("."))
64
+ continue;
65
+ const abs = path.join(dir, e.name);
66
+ if (e.isDirectory() && depth < 2)
67
+ walk(abs, depth + 1);
68
+ else if (e.isFile() && e.name.endsWith(".md")) {
69
+ const sha = e.name.slice(0, -3);
70
+ if (done.has(sha))
71
+ continue;
72
+ const mtimeMs = fs.statSync(abs).mtimeMs;
73
+ if (mtimeMs <= since)
74
+ continue;
75
+ out.push({ rel: path.relative(companyRoot, abs).split(path.sep).join("/"), sha, mtimeMs });
76
+ }
77
+ }
78
+ };
79
+ walk(signalsRoot, 0);
80
+ return out.sort((a, b) => a.mtimeMs - b.mtimeMs || a.rel.localeCompare(b.rel));
81
+ }
82
+ /** A 422 rejection that can pass on a later run (the source or receipt is not in the vault yet). */
83
+ function isRetryableRejection(parsed) {
84
+ return Array.isArray(parsed.reasons) && parsed.reasons.length > 0 && parsed.reasons.every((r) => r === "source_unavailable");
85
+ }
86
+ function parseBody(text) {
87
+ try {
88
+ const v = JSON.parse(text);
89
+ return v && typeof v === "object" ? v : {};
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
95
+ export const defaultUploadDeps = {
96
+ async getSession(company) {
97
+ const { ensureCognitoToken, DEFAULT_VAULT_API_URL } = await import("../../../utils/cognito-session.js");
98
+ const { resolveCallerPersonUid, getCompanyUid } = await import("../../../utils/vault-api.js");
99
+ let token;
100
+ try {
101
+ token = await ensureCognitoToken({ interactive: false });
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ const memberUid = await resolveCallerPersonUid(token, undefined, { command: "hq signals run" });
107
+ const companyUid = await getCompanyUid(token, company);
108
+ return { token, memberUid, companyUid, baseUrl: DEFAULT_VAULT_API_URL };
109
+ },
110
+ fetch: (url, init) => fetch(url, init),
111
+ async readVaultObject(session, company, key) {
112
+ const { createCompanyPresignClient } = await import("../../../commands/files-browse.js");
113
+ const { GetObjectCommand } = await import("@aws-sdk/client-s3");
114
+ const client = createCompanyPresignClient({ token: session.token, companyUid: session.companyUid, companySlug: company });
115
+ try {
116
+ const resp = (await client.send(new GetObjectCommand({ Bucket: session.companyUid, Key: key })));
117
+ if (!resp.Body)
118
+ return null;
119
+ const chunks = [];
120
+ for await (const c of resp.Body)
121
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
122
+ return Buffer.concat(chunks).toString("utf8");
123
+ }
124
+ catch (err) {
125
+ const e = err;
126
+ if (e.name === "NoSuchKey" || e.name === "NotFound" || e.status === 404 || e.$metadata?.httpStatusCode === 404)
127
+ return null;
128
+ throw err;
129
+ }
130
+ },
131
+ };
132
+ function base() {
133
+ return { status: "skipped", itemsIn: 0, itemsOut: 0, uploaded: { count: 0, bytes: 0 } };
134
+ }
135
+ async function planDryRun(ctx) {
136
+ // No network under --dry-run: the member is not resolved, so the watermark
137
+ // is read only when exactly one member has one for this company.
138
+ const wmDir = path.join(ctx.hqRoot, "workspace", "signals", "upload", ctx.company);
139
+ let memberUid = "<memberUid>";
140
+ try {
141
+ const files = fs.readdirSync(wmDir).filter((f) => f.endsWith(".json"));
142
+ if (files.length === 1)
143
+ memberUid = files[0].slice(0, -5);
144
+ }
145
+ catch {
146
+ // no watermark yet
147
+ }
148
+ const wm = readWatermark(ctx.hqRoot, ctx.company, memberUid);
149
+ const candidates = listSignalCandidates(ctx.hqRoot, ctx.company, wm);
150
+ const plan = [];
151
+ plan.push(`would pull ontology/projections/members/${memberUid}/v1/ into workspace/signals/projections/${ctx.company}/${memberUid}/v1/`);
152
+ let eligible = 0;
153
+ for (const c of candidates) {
154
+ let sourceRef = "";
155
+ try {
156
+ const fm = splitFrontmatter(fs.readFileSync(path.join(ctx.hqRoot, "companies", ctx.company, c.rel), "utf8")).frontmatter;
157
+ sourceRef = typeof fm.source_ref === "string" ? fm.source_ref : "";
158
+ }
159
+ catch {
160
+ // unreadable; reported below
161
+ }
162
+ if (!MEETING_SOURCE_RE.test(sourceRef)) {
163
+ plan.push(`skip ${c.rel}: non-meeting-source`);
164
+ continue;
165
+ }
166
+ eligible += 1;
167
+ plan.push(`would POST ${contributionRoute(ctx.company)} ${c.rel} -> member-signals/${memberUid}/observations/meetings/...`);
168
+ }
169
+ plan.push(`would upload up to ${eligible} sanitized observation(s); nothing uploaded (dry run)`);
170
+ return { ...base(), itemsIn: candidates.length, plan };
171
+ }
172
+ export function createUploadStep(overrides = {}) {
173
+ return {
174
+ name: "upload",
175
+ dryRun: planDryRun,
176
+ async run(ctx) {
177
+ const deps = { ...defaultUploadDeps, ...overrides, ...(ctx.uploadDeps ?? {}) };
178
+ if (ctx.dryRun)
179
+ return planDryRun(ctx);
180
+ const session = await deps.getSession(ctx.company);
181
+ if (!session) {
182
+ return { ...base(), skippedReason: "no-login", errorKind: "signed-out" };
183
+ }
184
+ let projection;
185
+ try {
186
+ memberProjectionPrefix(session.memberUid);
187
+ projection = await refreshMemberProjection({
188
+ company: ctx.company,
189
+ memberUid: session.memberUid,
190
+ read: (key) => deps.readVaultObject(session, ctx.company, key),
191
+ write: ctx.write,
192
+ now: ctx.now,
193
+ });
194
+ }
195
+ catch (err) {
196
+ projection = { state: "missing", watermark: "none", entityCount: 0, error: err instanceof Error ? err.message : String(err) };
197
+ }
198
+ const exportTrigger = await triggerProjectionExport({ company: ctx.company, memberUid: session.memberUid });
199
+ const detail = {
200
+ projection: { state: projection.state, watermark: projection.watermark, entityCount: projection.entityCount, ...(projection.error ? { error: projection.error.slice(0, 200) } : {}) },
201
+ projectionExport: exportTrigger,
202
+ };
203
+ const wm = readWatermark(ctx.hqRoot, ctx.company, session.memberUid);
204
+ const candidates = listSignalCandidates(ctx.hqRoot, ctx.company, wm);
205
+ const companyRoot = path.join(ctx.hqRoot, "companies", ctx.company);
206
+ const url = `${session.baseUrl.replace(/\/+$/, "")}${contributionRoute(ctx.company)}`;
207
+ const skipped = {};
208
+ let count = 0;
209
+ let bytes = 0;
210
+ let transient = 0;
211
+ let rejected = 0;
212
+ let earliestRetryMs = Infinity;
213
+ let stop = null;
214
+ for (const c of candidates) {
215
+ const s = sanitizeSignal({
216
+ companyRoot,
217
+ signalRel: c.rel,
218
+ memberUid: session.memberUid,
219
+ companyUid: session.companyUid,
220
+ projectionState: projection.state,
221
+ projectionWatermark: projection.watermark,
222
+ });
223
+ if (!s.ok) {
224
+ skipped[s.reason] = (skipped[s.reason] ?? 0) + 1;
225
+ continue;
226
+ }
227
+ if (!s.destination.startsWith(`member-signals/${session.memberUid}/`)) {
228
+ throw new Error("upload: destination outside the caller's member-signals prefix");
229
+ }
230
+ const body = JSON.stringify({ destination: s.destination, contribution: s.contribution });
231
+ let status;
232
+ let parsed;
233
+ try {
234
+ const resp = await deps.fetch(url, {
235
+ method: "POST",
236
+ headers: {
237
+ "Content-Type": "application/json",
238
+ Authorization: `Bearer ${session.token}`,
239
+ "x-hq-client-name": "@indigoai-us/hq-cli",
240
+ },
241
+ body,
242
+ });
243
+ status = resp.status;
244
+ parsed = parseBody(await resp.text());
245
+ }
246
+ catch {
247
+ transient += 1;
248
+ earliestRetryMs = Math.min(earliestRetryMs, c.mtimeMs);
249
+ continue;
250
+ }
251
+ if (status >= 200 && status < 300) {
252
+ count += 1;
253
+ bytes += Buffer.byteLength(body);
254
+ wm.uploaded.push(c.sha);
255
+ continue;
256
+ }
257
+ if (status === 401) {
258
+ stop = { ...base(), skippedReason: "no-login", errorKind: "signed-out", error: "hq-pro returned 401" };
259
+ break;
260
+ }
261
+ if (status === 403 && parsed.code === "CONTRIBUTION_DISABLED") {
262
+ stop = { ...base(), skippedReason: "flag-off" };
263
+ break;
264
+ }
265
+ // Company-wide answers: one request is enough, and the watermark holds
266
+ // so every signal goes once the company is reachable.
267
+ if (status === 404 && parsed.code === "WRITE_COMPANY_NOT_FOUND") {
268
+ stop = { ...base(), status: "failed", error: "hq-pro has no company with this slug (WRITE_COMPANY_NOT_FOUND)", errorKind: "config" };
269
+ break;
270
+ }
271
+ if (parsed.code === "WRITE_BUCKET_NOT_PROVISIONED") {
272
+ stop = { ...base(), status: "failed", error: "the company vault is not provisioned yet (WRITE_BUCKET_NOT_PROVISIONED); will retry", errorKind: "transient" };
273
+ break;
274
+ }
275
+ if ((status === 422 || status === 409 || status === 400) && !isRetryableRejection(parsed)) {
276
+ rejected += 1;
277
+ wm.rejected[c.sha] = parsed.code ?? `http-${status}`;
278
+ continue;
279
+ }
280
+ if (status === 403) {
281
+ stop = { ...base(), status: "failed", error: `hq-pro refused the contribution: ${parsed.code ?? "403"}`, errorKind: "other" };
282
+ break;
283
+ }
284
+ transient += 1;
285
+ earliestRetryMs = Math.min(earliestRetryMs, c.mtimeMs);
286
+ }
287
+ if (stop && count === 0) {
288
+ // Keep the projection pull but write no watermark change.
289
+ return { ...stop, itemsIn: candidates.length, detail };
290
+ }
291
+ // Advance the watermark to the run start, except past a signal that
292
+ // must be retried.
293
+ const runStart = ctx.now().getTime();
294
+ const next = Number.isFinite(earliestRetryMs) ? Math.min(runStart, earliestRetryMs - 1) : runStart;
295
+ const prior = wm.lastUploadedAt ? Date.parse(wm.lastUploadedAt) : -Infinity;
296
+ if (!stop)
297
+ wm.lastUploadedAt = new Date(Math.max(prior, next)).toISOString();
298
+ wm.uploaded = wm.uploaded.slice(-MAX_TRACKED_SHAS);
299
+ ctx.write(watermarkRel(ctx.company, session.memberUid), `${JSON.stringify(wm, null, 2)}\n`);
300
+ const result = {
301
+ status: "ok",
302
+ itemsIn: candidates.length,
303
+ itemsOut: count,
304
+ uploaded: { count, bytes },
305
+ detail: { ...detail, rejected, skipped },
306
+ };
307
+ if (stop) {
308
+ return { ...stop, status: "partial", itemsIn: candidates.length, itemsOut: count, uploaded: { count, bytes }, detail: result.detail };
309
+ }
310
+ if (transient > 0) {
311
+ result.status = count > 0 ? "partial" : "failed";
312
+ result.error = `${transient} contribution(s) failed; will retry`;
313
+ result.errorKind = "transient";
314
+ }
315
+ else if (candidates.length === 0) {
316
+ result.status = "skipped";
317
+ result.skippedReason = "nothing-new";
318
+ }
319
+ return result;
320
+ },
321
+ };
322
+ }
323
+ export const uploadStep = createUploadStep();
324
+ //# sourceMappingURL=upload.js.map
@@ -0,0 +1,63 @@
1
+ import type { WriteGuard } from "../hot-channels/loop.js";
2
+ export declare class WriteGuardError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface WriteGuardScope {
6
+ /** Limit the companies/{co}/ roots to this company. */
7
+ company?: string;
8
+ }
9
+ /** True when anything (a dangling symlink included) exists at p. */
10
+ export declare function lexists(p: string): boolean;
11
+ /** Returns the resolved absolute target path or throws WriteGuardError. */
12
+ export declare function assertAllowedPath(root: string, relPath: string, scope?: WriteGuardScope): string;
13
+ export declare function guardedWrite(root: string, relPath: string, data: string, scope?: WriteGuardScope): void;
14
+ /**
15
+ * One guard for both the signals engine and the hot-channel loop. It
16
+ * satisfies the WriteGuard interface in src/lib/hot-channels/loop.ts
17
+ * (beforeWrite) and adds the path-checked writer.
18
+ */
19
+ export interface SignalsWriteGuard extends WriteGuard {
20
+ assertAllowed: (relPath: string) => string;
21
+ write: (relPath: string, data: string) => void;
22
+ }
23
+ export declare function createSignalsWriteGuard(root: string, beforeWrite?: () => Promise<void>, scope?: WriteGuardScope): SignalsWriteGuard;
24
+ /**
25
+ * Delete one file through the same checks as a write: the path must sit in an
26
+ * allowed root (and, scoped, in that company's folder) after links are
27
+ * followed, and may not itself be a link. Throws WriteGuardError otherwise.
28
+ */
29
+ export declare function guardedRemove(root: string, relPath: string, scope?: WriteGuardScope): void;
30
+ /** Remove an empty directory through the guard. A link is refused, never removed or followed. */
31
+ export declare function guardedRmdir(root: string, relPath: string, scope?: WriteGuardScope): void;
32
+ /**
33
+ * The HQ-relative path an absolute path really points at, with every link
34
+ * followed (the file's own link included), or null when it does not exist or
35
+ * resolves outside the HQ root. Company ownership for a delete is decided on
36
+ * this path, never on the lexical one.
37
+ */
38
+ export declare function realRelPath(root: string, absPath: string): string | null;
39
+ /**
40
+ * Refuse, before anything writes there, a tree under relDir that holds a link
41
+ * leaving relDir's own allowed root (with scope.company, that company's
42
+ * folder). The walk uses lstat and does not descend through links. It covers
43
+ * writers that take no HQ-relative path (the hot-channel loop, the candidate
44
+ * script); it is a preflight, so a link created after it is not seen.
45
+ */
46
+ export declare function assertNoEscapingLinks(root: string, relDir: string, scope?: WriteGuardScope): void;
47
+ /**
48
+ * Guard for writers that hold an absolute vault root and an absolute file
49
+ * (the hot-channel loop: landing, cursors, ledger, sync lock, signals pass,
50
+ * alert state, push buffer cleanup). Call it before every write, rename,
51
+ * link or delete.
52
+ *
53
+ * When the vault root is `{hq}/companies/{co}`, the file goes through
54
+ * assertAllowedPath scoped to that company, the same check the signals engine
55
+ * uses: it must sit under the company's sources/, signals/ or ontology/ folder
56
+ * and stay there after links are followed. Any other vault root (a test
57
+ * folder, a person vault) gets the same rule against the vault itself: the
58
+ * file must be inside it lexically and after links are followed, the vault
59
+ * folder may not be a link, and the file may not be a link. Throws
60
+ * WriteGuardError otherwise.
61
+ */
62
+ export declare function assertVaultPath(vaultRoot: string, file: string): string;
63
+ //# sourceMappingURL=write-guard.d.ts.map
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Guarded writer for the signals engine. Refuses any path outside:
3
+ * workspace/signals/, workspace/vault-staging/, personal/signals/,
4
+ * companies/{co}/ontology/, companies/{co}/signals/, companies/{co}/sources/
5
+ * Rejects absolute paths, `..` segments, symlinked parents that resolve
6
+ * outside the HQ root or outside an allowed root, and symlinks (dangling ones
7
+ * too) at the target. Writes are atomic: a temp file under a random name
8
+ * opened with O_EXCL ("wx"), then a rename. With `scope.company` set, the
9
+ * companies/ roots are limited to that one company.
10
+ *
11
+ * A path must also resolve inside the same allowed root it names: after
12
+ * symlinks are followed, `workspace/signals/x` has to land under
13
+ * workspace/signals/, and `companies/{co}/signals/x` under
14
+ * companies/{co}/signals/. A link that moves a write into another root (most
15
+ * importantly into another company's folder) is refused, scoped or not.
16
+ */
17
+ import { randomBytes } from "node:crypto";
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ export class WriteGuardError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "WriteGuardError";
24
+ }
25
+ }
26
+ const ALLOWED = [
27
+ /^workspace\/signals\/.+/,
28
+ /^workspace\/vault-staging\/.+/,
29
+ /^personal\/signals\/.+/,
30
+ /^companies\/[^/]+\/ontology\/.+/,
31
+ /^companies\/[^/]+\/signals\/.+/,
32
+ /^companies\/[^/]+\/sources\/.+/,
33
+ ];
34
+ function allowedRoot(rel, scope) {
35
+ const root = ALLOWED.findIndex((re) => re.test(rel));
36
+ if (root < 0)
37
+ return null;
38
+ if (!rel.startsWith("companies/"))
39
+ return { root };
40
+ const company = rel.split("/")[1];
41
+ // Exact, case-sensitive match: on a case-insensitive volume two spellings
42
+ // of a slug are one folder, so anything but the exact slug fails closed.
43
+ if (scope.company !== undefined && company !== scope.company)
44
+ return null;
45
+ return { root, company };
46
+ }
47
+ function sameRoot(a, b) {
48
+ return a.root === b.root && a.company === b.company;
49
+ }
50
+ /** True when anything (a dangling symlink included) exists at p. */
51
+ export function lexists(p) {
52
+ try {
53
+ fs.lstatSync(p);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ /** The first HQ-relative prefix of rel that is a symlink, for error messages. */
61
+ function firstLinkedSegment(realRoot, rel) {
62
+ const parts = rel.split("/");
63
+ for (let i = 1; i <= parts.length; i += 1) {
64
+ const prefix = parts.slice(0, i).join("/");
65
+ try {
66
+ if (fs.lstatSync(path.join(realRoot, prefix)).isSymbolicLink())
67
+ return prefix;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /** Returns the resolved absolute target path or throws WriteGuardError. */
76
+ export function assertAllowedPath(root, relPath, scope = {}) {
77
+ if (!relPath || path.isAbsolute(relPath) || /^[a-zA-Z]:/.test(relPath)) {
78
+ throw new WriteGuardError(`write-guard: path must be HQ-root relative: ${relPath}`);
79
+ }
80
+ const segments = relPath.split(/[\\/]+/);
81
+ if (segments.includes("..")) {
82
+ throw new WriteGuardError(`write-guard: path traversal rejected: ${relPath}`);
83
+ }
84
+ const rel = segments.filter((s) => s && s !== ".").join("/");
85
+ const lexicalRoot = allowedRoot(rel, scope);
86
+ if (!lexicalRoot) {
87
+ throw new WriteGuardError(`write-guard: path outside allowed roots: ${relPath}`);
88
+ }
89
+ const realRoot = fs.realpathSync(root);
90
+ const target = path.join(realRoot, rel);
91
+ // Resolve the nearest existing ancestor so a symlinked parent cannot escape.
92
+ // lexists, not existsSync: existsSync follows links, so a dangling link
93
+ // would read as absent and skip the check.
94
+ let ancestor = path.dirname(target);
95
+ const rest = [path.basename(target)];
96
+ while (!lexists(ancestor)) {
97
+ rest.unshift(path.basename(ancestor));
98
+ ancestor = path.dirname(ancestor);
99
+ }
100
+ let realAncestor;
101
+ try {
102
+ realAncestor = fs.realpathSync(ancestor);
103
+ }
104
+ catch {
105
+ throw new WriteGuardError(`write-guard: dangling symlink in path: ${relPath}`);
106
+ }
107
+ const resolved = path.join(realAncestor, ...rest);
108
+ const resolvedRel = path.relative(realRoot, resolved).split(path.sep).join("/");
109
+ if (resolvedRel.startsWith("..") || path.isAbsolute(resolvedRel)) {
110
+ throw new WriteGuardError(`write-guard: symlink escape rejected: ${relPath}`);
111
+ }
112
+ // Unscoped here so the error can name the root it landed in; sameRoot below
113
+ // ties it back to the lexical root, which already passed the scope check.
114
+ const resolvedRoot = allowedRoot(resolvedRel, {});
115
+ if (!resolvedRoot) {
116
+ throw new WriteGuardError(`write-guard: symlink escape rejected: ${relPath}`);
117
+ }
118
+ // A scoped write outside companies/ (workspace/signals/runs/{date}/{co}.json,
119
+ // watermarks/{co}/, projections/{co}/ and so on) names its company somewhere
120
+ // below the root, where the root check cannot see it. Hold it to its exact
121
+ // path: no link at all below the HQ root, so another company's state there
122
+ // cannot be reached through one.
123
+ const linkedWithinScopedWorkspace = scope.company !== undefined && lexicalRoot.company === undefined && resolvedRel !== rel;
124
+ if (linkedWithinScopedWorkspace && sameRoot(lexicalRoot, resolvedRoot)) {
125
+ throw new WriteGuardError(`write-guard: ${relPath} passes through a link at ${firstLinkedSegment(realRoot, rel) ?? "an unknown segment"}; ` +
126
+ "a company-scoped signals write may not pass through a link inside the HQ root");
127
+ }
128
+ if (!sameRoot(lexicalRoot, resolvedRoot) || linkedWithinScopedWorkspace) {
129
+ const where = resolvedRoot.company !== undefined ? `company ${resolvedRoot.company}'s folder` : resolvedRel;
130
+ throw new WriteGuardError(`write-guard: ${relPath} resolves through a link into ${where}; refusing a write that leaves its own root`);
131
+ }
132
+ if (lexists(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
133
+ throw new WriteGuardError(`write-guard: refusing to write through symlink: ${relPath}`);
134
+ }
135
+ return resolved;
136
+ }
137
+ export function guardedWrite(root, relPath, data, scope = {}) {
138
+ const abs = assertAllowedPath(root, relPath, scope);
139
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
140
+ const tmp = `${abs}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
141
+ // "wx" is O_CREAT|O_EXCL: it fails on any existing entry, symlinks included.
142
+ fs.writeFileSync(tmp, data, { flag: "wx" });
143
+ try {
144
+ fs.renameSync(tmp, abs);
145
+ }
146
+ catch (err) {
147
+ fs.rmSync(tmp, { force: true });
148
+ throw err;
149
+ }
150
+ }
151
+ export function createSignalsWriteGuard(root, beforeWrite, scope = {}) {
152
+ return {
153
+ beforeWrite: beforeWrite ?? (async () => { }),
154
+ assertAllowed: (relPath) => assertAllowedPath(root, relPath, scope),
155
+ write: (relPath, data) => {
156
+ guardedWrite(root, relPath, data, scope);
157
+ },
158
+ };
159
+ }
160
+ /**
161
+ * Delete one file through the same checks as a write: the path must sit in an
162
+ * allowed root (and, scoped, in that company's folder) after links are
163
+ * followed, and may not itself be a link. Throws WriteGuardError otherwise.
164
+ */
165
+ export function guardedRemove(root, relPath, scope = {}) {
166
+ const abs = assertAllowedPath(root, relPath, scope);
167
+ fs.rmSync(abs, { force: true });
168
+ }
169
+ /** Remove an empty directory through the guard. A link is refused, never removed or followed. */
170
+ export function guardedRmdir(root, relPath, scope = {}) {
171
+ const abs = assertAllowedPath(root, relPath, scope);
172
+ fs.rmdirSync(abs);
173
+ }
174
+ /**
175
+ * The HQ-relative path an absolute path really points at, with every link
176
+ * followed (the file's own link included), or null when it does not exist or
177
+ * resolves outside the HQ root. Company ownership for a delete is decided on
178
+ * this path, never on the lexical one.
179
+ */
180
+ export function realRelPath(root, absPath) {
181
+ let real;
182
+ try {
183
+ real = fs.realpathSync(absPath);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ const rel = path.relative(fs.realpathSync(root), real);
189
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
190
+ return null;
191
+ return rel.split(path.sep).join("/");
192
+ }
193
+ /**
194
+ * Refuse, before anything writes there, a tree under relDir that holds a link
195
+ * leaving relDir's own allowed root (with scope.company, that company's
196
+ * folder). The walk uses lstat and does not descend through links. It covers
197
+ * writers that take no HQ-relative path (the hot-channel loop, the candidate
198
+ * script); it is a preflight, so a link created after it is not seen.
199
+ */
200
+ export function assertNoEscapingLinks(root, relDir, scope = {}) {
201
+ const probe = (rel) => {
202
+ assertAllowedPath(root, `${rel}/.write-guard-probe`, scope);
203
+ };
204
+ probe(relDir);
205
+ const realRoot = fs.realpathSync(root);
206
+ const walk = (rel) => {
207
+ let entries;
208
+ try {
209
+ entries = fs.readdirSync(path.join(realRoot, rel), { withFileTypes: true });
210
+ }
211
+ catch {
212
+ return;
213
+ }
214
+ for (const e of entries) {
215
+ const child = `${rel}/${e.name}`;
216
+ if (e.isSymbolicLink())
217
+ probe(child);
218
+ else if (e.isDirectory())
219
+ walk(child);
220
+ }
221
+ };
222
+ walk(relDir);
223
+ }
224
+ /**
225
+ * realpath of the nearest existing ancestor of an absolute path, with the
226
+ * missing tail appended. A dangling link on the way fails closed.
227
+ */
228
+ function resolveNearest(abs, label) {
229
+ let ancestor = abs;
230
+ const rest = [];
231
+ while (!lexists(ancestor)) {
232
+ const parent = path.dirname(ancestor);
233
+ if (parent === ancestor)
234
+ break;
235
+ rest.unshift(path.basename(ancestor));
236
+ ancestor = parent;
237
+ }
238
+ try {
239
+ return path.join(fs.realpathSync(ancestor), ...rest);
240
+ }
241
+ catch {
242
+ throw new WriteGuardError(`write-guard: dangling symlink in path: ${label}`);
243
+ }
244
+ }
245
+ /**
246
+ * Guard for writers that hold an absolute vault root and an absolute file
247
+ * (the hot-channel loop: landing, cursors, ledger, sync lock, signals pass,
248
+ * alert state, push buffer cleanup). Call it before every write, rename,
249
+ * link or delete.
250
+ *
251
+ * When the vault root is `{hq}/companies/{co}`, the file goes through
252
+ * assertAllowedPath scoped to that company, the same check the signals engine
253
+ * uses: it must sit under the company's sources/, signals/ or ontology/ folder
254
+ * and stay there after links are followed. Any other vault root (a test
255
+ * folder, a person vault) gets the same rule against the vault itself: the
256
+ * file must be inside it lexically and after links are followed, the vault
257
+ * folder may not be a link, and the file may not be a link. Throws
258
+ * WriteGuardError otherwise.
259
+ */
260
+ export function assertVaultPath(vaultRoot, file) {
261
+ const absVault = path.resolve(vaultRoot);
262
+ const absFile = path.resolve(absVault, file);
263
+ const rel = path.relative(absVault, absFile);
264
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
265
+ throw new WriteGuardError(`write-guard: ${file} is outside the vault ${vaultRoot}`);
266
+ }
267
+ const relPosix = rel.split(path.sep).join("/");
268
+ const company = path.basename(absVault);
269
+ if (path.basename(path.dirname(absVault)) === "companies" && company) {
270
+ const hqRoot = path.dirname(path.dirname(absVault));
271
+ return assertAllowedPath(hqRoot, ["companies", company, relPosix].join("/"), { company });
272
+ }
273
+ if (lexists(absVault) && fs.lstatSync(absVault).isSymbolicLink()) {
274
+ throw new WriteGuardError(`write-guard: vault root ${vaultRoot} is a link`);
275
+ }
276
+ const realVault = resolveNearest(absVault, vaultRoot);
277
+ const resolved = resolveNearest(absFile, file);
278
+ const inside = path.relative(realVault, resolved);
279
+ if (!inside || inside.startsWith("..") || path.isAbsolute(inside)) {
280
+ throw new WriteGuardError(`write-guard: ${file} resolves through a link outside the vault ${vaultRoot}`);
281
+ }
282
+ if (lexists(absFile) && fs.lstatSync(absFile).isSymbolicLink()) {
283
+ throw new WriteGuardError(`write-guard: refusing to write through symlink: ${file}`);
284
+ }
285
+ return resolved;
286
+ }
287
+ //# sourceMappingURL=write-guard.js.map