@indigoai-us/hq-cli 5.128.0 → 5.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/command-catalog.generated.d.ts +169 -0
- package/dist/command-catalog.generated.js +221 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/core.js +7 -1
- package/dist/commands/report.d.ts +16 -0
- package/dist/commands/report.js +310 -0
- package/dist/lib/core-utils/sentry-report.d.ts +27 -7
- package/dist/lib/core-utils/sentry-report.js +428 -28
- package/dist/lib/report-publish.d.ts +173 -0
- package/dist/lib/report-publish.js +381 -0
- package/dist/lib/report-run.d.ts +61 -0
- package/dist/lib/report-run.js +123 -0
- package/dist/lib/report-templates.d.ts +10 -0
- package/dist/lib/report-templates.js +24 -0
- package/package.json +2 -1
- package/templates/reports/blank.md +3 -0
- package/templates/reports/daily-digest.md +11 -0
- package/templates/runbooks/update-report.md +21 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Mirrors the deploy skill's static-report guardrails. */
|
|
2
|
+
export declare const REPORT_SOURCE_MAX_BYTES: number;
|
|
3
|
+
export declare const REPORT_BUNDLE_MAX_BYTES: number;
|
|
4
|
+
export declare const REPORT_SOURCE_MAX_FILES = 100;
|
|
5
|
+
export declare const REPORT_CSP = "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; form-action 'none'";
|
|
6
|
+
export type ReportLastRun = {
|
|
7
|
+
at: string;
|
|
8
|
+
status: "running" | "succeeded" | "failed";
|
|
9
|
+
reason?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
};
|
|
12
|
+
export type ReportSchedule = {
|
|
13
|
+
kind: "daily" | "weekly" | "manual";
|
|
14
|
+
time: string;
|
|
15
|
+
timezone: string;
|
|
16
|
+
weekday?: number;
|
|
17
|
+
};
|
|
18
|
+
export type ChannelReport = {
|
|
19
|
+
reportId: string;
|
|
20
|
+
channelId: string;
|
|
21
|
+
companyUid?: string;
|
|
22
|
+
createdBy: string;
|
|
23
|
+
ownerAgentUid: string;
|
|
24
|
+
deploySlug: string;
|
|
25
|
+
state: "draft-pending" | "draft-ready" | "live" | "decommissioned";
|
|
26
|
+
instructions: string;
|
|
27
|
+
revision: number;
|
|
28
|
+
deployAppId?: string;
|
|
29
|
+
url?: string;
|
|
30
|
+
draft?: {
|
|
31
|
+
url: string;
|
|
32
|
+
appId: string;
|
|
33
|
+
generatedAt: string;
|
|
34
|
+
note?: string;
|
|
35
|
+
};
|
|
36
|
+
lastRun?: ReportLastRun;
|
|
37
|
+
jobId?: string;
|
|
38
|
+
jobState?: "pending" | "adopted" | "failed";
|
|
39
|
+
jobStateReason?: string;
|
|
40
|
+
schedule?: ReportSchedule;
|
|
41
|
+
};
|
|
42
|
+
export type ReportPatch = Partial<Pick<ChannelReport, "deployAppId" | "url" | "state" | "lastRun">> & {
|
|
43
|
+
previewImage?: string;
|
|
44
|
+
previewTitle?: string;
|
|
45
|
+
jobState?: "pending" | "adopted" | "failed";
|
|
46
|
+
jobStateReason?: string | null;
|
|
47
|
+
/** Null clears the live draft pointer or the adopted job id. */
|
|
48
|
+
draft?: ChannelReport["draft"] | null;
|
|
49
|
+
jobId?: string | null;
|
|
50
|
+
};
|
|
51
|
+
export type DeployOrgContext = {
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
org?: string;
|
|
54
|
+
};
|
|
55
|
+
export type StaticFile = {
|
|
56
|
+
path: string;
|
|
57
|
+
content: string;
|
|
58
|
+
encoding?: "utf8" | "base64";
|
|
59
|
+
};
|
|
60
|
+
export type ReportSource = {
|
|
61
|
+
files: StaticFile[];
|
|
62
|
+
previewTitle?: string;
|
|
63
|
+
previewImage?: string;
|
|
64
|
+
};
|
|
65
|
+
export type DeployAccessPolicy = {
|
|
66
|
+
mode: "company" | "selected" | "password" | "public";
|
|
67
|
+
companyUid: string;
|
|
68
|
+
users: Array<{
|
|
69
|
+
id: string;
|
|
70
|
+
}>;
|
|
71
|
+
groups: Array<{
|
|
72
|
+
id: string;
|
|
73
|
+
}>;
|
|
74
|
+
password?: string;
|
|
75
|
+
};
|
|
76
|
+
export type ReportDeployment = {
|
|
77
|
+
appId: string;
|
|
78
|
+
url: string;
|
|
79
|
+
deployId: string;
|
|
80
|
+
};
|
|
81
|
+
export interface ReportPublishDependencies {
|
|
82
|
+
patch(report: ChannelReport, patch: ReportPatch): Promise<ChannelReport>;
|
|
83
|
+
deploy(input: {
|
|
84
|
+
slug: string;
|
|
85
|
+
files: StaticFile[];
|
|
86
|
+
access: DeployAccessPolicy;
|
|
87
|
+
}): Promise<ReportDeployment>;
|
|
88
|
+
now(): string;
|
|
89
|
+
/** Owner DM for a failed run. Omitted by callers that only record lastRun. */
|
|
90
|
+
notifyOwner?(notice: {
|
|
91
|
+
to: string;
|
|
92
|
+
body: string;
|
|
93
|
+
}): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Plain owner DM after a failed run. Suppressed for the publish lock
|
|
97
|
+
* ("already running") so a second run does not page the owner.
|
|
98
|
+
*/
|
|
99
|
+
export declare function ownerFailureNotice(input: {
|
|
100
|
+
code?: string | null;
|
|
101
|
+
reason: string;
|
|
102
|
+
reportUrl?: string | null;
|
|
103
|
+
ownerPersonUid: string;
|
|
104
|
+
}): {
|
|
105
|
+
to: string;
|
|
106
|
+
body: string;
|
|
107
|
+
} | null;
|
|
108
|
+
export interface ReportAdoptDependencies {
|
|
109
|
+
createJob(input: {
|
|
110
|
+
prompt: string;
|
|
111
|
+
schedule: {
|
|
112
|
+
kind: "recurring";
|
|
113
|
+
cron: string;
|
|
114
|
+
timezone: string;
|
|
115
|
+
};
|
|
116
|
+
context: {
|
|
117
|
+
reportId: string;
|
|
118
|
+
channelId: string;
|
|
119
|
+
};
|
|
120
|
+
}): Promise<{
|
|
121
|
+
jobId: string;
|
|
122
|
+
}>;
|
|
123
|
+
cancelJob(jobId: string): Promise<void>;
|
|
124
|
+
patch(report: ChannelReport, patch: ReportPatch): Promise<ChannelReport>;
|
|
125
|
+
}
|
|
126
|
+
export declare class ReportPublishError extends Error {
|
|
127
|
+
readonly code: "already-running" | "source-invalid" | "deploy-failed" | "job-failed";
|
|
128
|
+
constructor(message: string, code?: "already-running" | "source-invalid" | "deploy-failed" | "job-failed");
|
|
129
|
+
}
|
|
130
|
+
/** A deliberately boring, token-safe failure reason stored on the report. */
|
|
131
|
+
export declare function plainFailureReason(error: unknown): string;
|
|
132
|
+
export declare function injectReportCsp(html: string): string;
|
|
133
|
+
/** Read a deployable static report and inject its non-negotiable CSP. */
|
|
134
|
+
export declare function loadReportSource(directory: string): ReportSource;
|
|
135
|
+
/** Access policy defaults deliberately keep personal reports owner-only. */
|
|
136
|
+
export declare function defaultAccessPolicy(report: ChannelReport): DeployAccessPolicy;
|
|
137
|
+
export declare function reportDeploySlug(report: ChannelReport, draft: boolean): string;
|
|
138
|
+
/** Source folder convention: `<bot workerDir>/reports/<reportId>/`. */
|
|
139
|
+
export declare function conventionReportDir(reportId: string, workerDir?: string): string;
|
|
140
|
+
/** Company deploys send X-Org-Slug; personal-channel reports have no company to gate on. */
|
|
141
|
+
export declare function deployOrgContext(report: ChannelReport, companySlug?: string): DeployOrgContext;
|
|
142
|
+
/**
|
|
143
|
+
* EventBridge Scheduler cron for the agent jobs API.
|
|
144
|
+
* Daily uses `?` on the weekday field; weekly pins the weekday and `?` on the day-of-month.
|
|
145
|
+
*/
|
|
146
|
+
export declare function reportJobCron(schedule: ReportSchedule): string | null;
|
|
147
|
+
/** Job prompt is the stored update-report runbook with this report id filled in. */
|
|
148
|
+
export declare function reportJobPrompt(reportId: string): string;
|
|
149
|
+
/**
|
|
150
|
+
* Install, replace, or delete this bot's own scheduled job for a report.
|
|
151
|
+
* Jobs-API failures are recorded as jobState=failed with a plain reason.
|
|
152
|
+
*/
|
|
153
|
+
export declare function adoptReport(report: ChannelReport, deps: ReportAdoptDependencies): Promise<ChannelReport>;
|
|
154
|
+
/**
|
|
155
|
+
* Lock, deploy, then persist a report run. Every known failure after lock
|
|
156
|
+
* acquisition writes a terminal lastRun so the 15-minute server TTL is a
|
|
157
|
+
* crash-only fallback rather than normal control flow.
|
|
158
|
+
*/
|
|
159
|
+
export declare function publishReport(report: ChannelReport, directory: string, draft: boolean, deps: ReportPublishDependencies): Promise<ChannelReport>;
|
|
160
|
+
type BuiltTarball = {
|
|
161
|
+
bytes: Buffer;
|
|
162
|
+
sha256: string;
|
|
163
|
+
sizeBytes: number;
|
|
164
|
+
};
|
|
165
|
+
/** hq-deploy static payload format: index.html stays at the archive root. */
|
|
166
|
+
export declare function buildReportTarball(files: StaticFile[]): BuiltTarball;
|
|
167
|
+
/** Isolated test helper for creating a compliant report source. */
|
|
168
|
+
export declare function makeReportFixture(html: string): {
|
|
169
|
+
directory: string;
|
|
170
|
+
cleanup: () => void;
|
|
171
|
+
};
|
|
172
|
+
export {};
|
|
173
|
+
//# sourceMappingURL=report-publish.d.ts.map
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel report publishing primitives.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately stops at the hq-deploy HTTP API. Report pages are
|
|
5
|
+
* uploaded only to its short-lived presigned URL; the CLI never receives AWS
|
|
6
|
+
* credentials and never talks to S3 directly.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { gzipSync } from "node:zlib";
|
|
13
|
+
import { loadUpdateReportRunbook } from "./report-templates.js";
|
|
14
|
+
/** Mirrors the deploy skill's static-report guardrails. */
|
|
15
|
+
export const REPORT_SOURCE_MAX_BYTES = 25 * 1024 * 1024;
|
|
16
|
+
export const REPORT_BUNDLE_MAX_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
export const REPORT_SOURCE_MAX_FILES = 100;
|
|
18
|
+
export const REPORT_CSP = "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; form-action 'none'";
|
|
19
|
+
/**
|
|
20
|
+
* Plain owner DM after a failed run. Suppressed for the publish lock
|
|
21
|
+
* ("already running") so a second run does not page the owner.
|
|
22
|
+
*/
|
|
23
|
+
export function ownerFailureNotice(input) {
|
|
24
|
+
if (input.code === "already-running")
|
|
25
|
+
return null;
|
|
26
|
+
const to = input.ownerPersonUid.trim();
|
|
27
|
+
if (!to)
|
|
28
|
+
return null;
|
|
29
|
+
const reason = input.reason.replace(/\s+/g, " ").trim();
|
|
30
|
+
const link = (input.reportUrl ?? "").trim();
|
|
31
|
+
return { to, body: link ? `${reason} ${link}` : reason };
|
|
32
|
+
}
|
|
33
|
+
export class ReportPublishError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
constructor(message, code = "deploy-failed") {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "ReportPublishError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** A deliberately boring, token-safe failure reason stored on the report. */
|
|
42
|
+
export function plainFailureReason(error) {
|
|
43
|
+
if (error instanceof ReportPublishError) {
|
|
44
|
+
if (error.code === "source-invalid" || error.code === "job-failed")
|
|
45
|
+
return error.message.slice(0, 240);
|
|
46
|
+
if (error.code === "already-running")
|
|
47
|
+
return "already running";
|
|
48
|
+
}
|
|
49
|
+
return "Report publish failed. Check the report source and deploy service, then try again.";
|
|
50
|
+
}
|
|
51
|
+
function textOf(file) {
|
|
52
|
+
return file.encoding === "base64" ? Buffer.from(file.content, "base64") : Buffer.from(file.content, "utf8");
|
|
53
|
+
}
|
|
54
|
+
function isExternalUrl(value) {
|
|
55
|
+
return /^(?:https?:)?\/\//i.test(value.trim());
|
|
56
|
+
}
|
|
57
|
+
function cspMetaPresent(html) {
|
|
58
|
+
return /<meta\b[^>]*http-equiv\s*=\s*(?:"content-security-policy"|'content-security-policy'|content-security-policy)[^>]*>/i.test(html);
|
|
59
|
+
}
|
|
60
|
+
function externalScriptPresent(html) {
|
|
61
|
+
const tags = html.match(/<script\b[^>]*>/gi) ?? [];
|
|
62
|
+
return tags.some((tag) => {
|
|
63
|
+
const src = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
64
|
+
return !!src && isExternalUrl(src[1] ?? src[2] ?? src[3] ?? "");
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function crossOriginFetchPresent(html) {
|
|
68
|
+
// The static report convention permits only same-origin relative requests.
|
|
69
|
+
// Scan literals rather than attempting to execute untrusted report JS.
|
|
70
|
+
const calls = html.match(/\bfetch\s*\(\s*(?:"[^"]*"|'[^']*'|`[^`]*`)/gi) ?? [];
|
|
71
|
+
return calls.some((call) => /\bfetch\s*\(\s*(?:["'`]\s*(?:https?:)?\/\/)/i.test(call));
|
|
72
|
+
}
|
|
73
|
+
function activeContentBeforePolicy(html) {
|
|
74
|
+
// Meta CSP only applies to content parsed after the tag. Anything that can
|
|
75
|
+
// load or navigate before that tag is rejected, not merely moved.
|
|
76
|
+
return /<script/i.test(html)
|
|
77
|
+
|| /<link/i.test(html)
|
|
78
|
+
|| /<iframe/i.test(html)
|
|
79
|
+
|| /<object/i.test(html)
|
|
80
|
+
|| /<meta\b[^>]*http-equiv\s*=\s*(?:"refresh"|'refresh'|refresh\b)/i.test(html);
|
|
81
|
+
}
|
|
82
|
+
export function injectReportCsp(html) {
|
|
83
|
+
if (cspMetaPresent(html)) {
|
|
84
|
+
throw new ReportPublishError("Report index.html already declares a Content-Security-Policy.", "source-invalid");
|
|
85
|
+
}
|
|
86
|
+
if (externalScriptPresent(html)) {
|
|
87
|
+
throw new ReportPublishError("Report index.html references an external script.", "source-invalid");
|
|
88
|
+
}
|
|
89
|
+
if (crossOriginFetchPresent(html)) {
|
|
90
|
+
throw new ReportPublishError("Report index.html fetches another origin.", "source-invalid");
|
|
91
|
+
}
|
|
92
|
+
const meta = `<meta http-equiv="Content-Security-Policy" content="${REPORT_CSP}">`;
|
|
93
|
+
const headOpen = /<head\b[^>]*>/i.exec(html);
|
|
94
|
+
if (headOpen?.index !== undefined) {
|
|
95
|
+
const at = headOpen.index + headOpen[0].length;
|
|
96
|
+
if (activeContentBeforePolicy(html.slice(0, at))) {
|
|
97
|
+
throw new ReportPublishError("Report index.html has active content before the Content-Security-Policy.", "source-invalid");
|
|
98
|
+
}
|
|
99
|
+
return `${html.slice(0, at)}${meta}${html.slice(at)}`;
|
|
100
|
+
}
|
|
101
|
+
const htmlOpen = /<html\b[^>]*>/i.exec(html);
|
|
102
|
+
if (htmlOpen?.index !== undefined) {
|
|
103
|
+
const at = htmlOpen.index + htmlOpen[0].length;
|
|
104
|
+
if (activeContentBeforePolicy(html.slice(0, at))) {
|
|
105
|
+
throw new ReportPublishError("Report index.html has active content before the Content-Security-Policy.", "source-invalid");
|
|
106
|
+
}
|
|
107
|
+
return `${html.slice(0, at)}<head>${meta}</head>${html.slice(at)}`;
|
|
108
|
+
}
|
|
109
|
+
return `<!doctype html><html><head>${meta}</head><body>${html}</body></html>`;
|
|
110
|
+
}
|
|
111
|
+
function safeRelativeFile(root, absolute) {
|
|
112
|
+
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
113
|
+
if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
114
|
+
throw new ReportPublishError("Report source contains a path outside its directory.", "source-invalid");
|
|
115
|
+
}
|
|
116
|
+
return relative;
|
|
117
|
+
}
|
|
118
|
+
function readTree(root, cursor = root, found = [], total = { bytes: 0 }) {
|
|
119
|
+
for (const entry of fs.readdirSync(cursor, { withFileTypes: true })) {
|
|
120
|
+
if (entry.name === ".DS_Store" || entry.name === ".git")
|
|
121
|
+
continue;
|
|
122
|
+
const absolute = path.join(cursor, entry.name);
|
|
123
|
+
if (entry.isSymbolicLink())
|
|
124
|
+
throw new ReportPublishError("Report source cannot include symlinks.", "source-invalid");
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
readTree(root, absolute, found, total);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!entry.isFile())
|
|
130
|
+
continue;
|
|
131
|
+
if (found.length >= REPORT_SOURCE_MAX_FILES) {
|
|
132
|
+
throw new ReportPublishError(`Report source exceeds ${REPORT_SOURCE_MAX_FILES} files.`, "source-invalid");
|
|
133
|
+
}
|
|
134
|
+
const bytes = fs.readFileSync(absolute);
|
|
135
|
+
total.bytes += bytes.byteLength;
|
|
136
|
+
if (total.bytes > REPORT_SOURCE_MAX_BYTES) {
|
|
137
|
+
throw new ReportPublishError("Report source exceeds the 25 MB staging limit.", "source-invalid");
|
|
138
|
+
}
|
|
139
|
+
found.push({ path: safeRelativeFile(root, absolute), content: bytes.toString("base64"), encoding: "base64" });
|
|
140
|
+
}
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
143
|
+
function htmlTitle(html) {
|
|
144
|
+
const match = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
145
|
+
const title = match?.[1]?.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
146
|
+
return title || undefined;
|
|
147
|
+
}
|
|
148
|
+
function localPreviewImage(html) {
|
|
149
|
+
const meta = /<meta\b[^>]*(?:property|name)\s*=\s*(?:"og:image"|'og:image'|og:image)[^>]*content\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(html)
|
|
150
|
+
?? /<meta\b[^>]*content\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*(?:property|name)\s*=\s*(?:"og:image"|'og:image'|og:image)/i.exec(html);
|
|
151
|
+
const value = meta?.[1] ?? meta?.[2] ?? meta?.[3];
|
|
152
|
+
return value && !isExternalUrl(value) ? value : undefined;
|
|
153
|
+
}
|
|
154
|
+
/** Read a deployable static report and inject its non-negotiable CSP. */
|
|
155
|
+
export function loadReportSource(directory) {
|
|
156
|
+
const root = path.resolve(directory);
|
|
157
|
+
let stat;
|
|
158
|
+
try {
|
|
159
|
+
stat = fs.statSync(root);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new ReportPublishError("Report source directory does not exist.", "source-invalid");
|
|
163
|
+
}
|
|
164
|
+
if (!stat.isDirectory())
|
|
165
|
+
throw new ReportPublishError("Report source must be a directory.", "source-invalid");
|
|
166
|
+
const files = readTree(root);
|
|
167
|
+
const index = files.find((file) => file.path === "index.html");
|
|
168
|
+
if (!index)
|
|
169
|
+
throw new ReportPublishError("Report source must include index.html at its root.", "source-invalid");
|
|
170
|
+
const rawHtml = textOf(index).toString("utf8");
|
|
171
|
+
const protectedHtml = injectReportCsp(rawHtml);
|
|
172
|
+
index.content = protectedHtml;
|
|
173
|
+
index.encoding = "utf8";
|
|
174
|
+
return { files, previewTitle: htmlTitle(protectedHtml), previewImage: localPreviewImage(protectedHtml) };
|
|
175
|
+
}
|
|
176
|
+
/** Access policy defaults deliberately keep personal reports owner-only. */
|
|
177
|
+
export function defaultAccessPolicy(report) {
|
|
178
|
+
if (report.companyUid) {
|
|
179
|
+
return { mode: "company", companyUid: report.companyUid, users: [], groups: [] };
|
|
180
|
+
}
|
|
181
|
+
return { mode: "selected", companyUid: "personal", users: [{ id: report.createdBy }], groups: [] };
|
|
182
|
+
}
|
|
183
|
+
export function reportDeploySlug(report, draft) {
|
|
184
|
+
return draft ? `${report.deploySlug}-draft` : report.deploySlug;
|
|
185
|
+
}
|
|
186
|
+
/** Source folder convention: `<bot workerDir>/reports/<reportId>/`. */
|
|
187
|
+
export function conventionReportDir(reportId, workerDir = process.env.HQ_BOT_WORKER_DIR ?? process.cwd()) {
|
|
188
|
+
return path.join(workerDir, "reports", reportId);
|
|
189
|
+
}
|
|
190
|
+
/** Company deploys send X-Org-Slug; personal-channel reports have no company to gate on. */
|
|
191
|
+
export function deployOrgContext(report, companySlug) {
|
|
192
|
+
if (!report.companyUid) {
|
|
193
|
+
return { headers: { "X-HQ-Deploy-Scope": "personal" } };
|
|
194
|
+
}
|
|
195
|
+
if (!companySlug) {
|
|
196
|
+
throw new ReportPublishError("Could not resolve the report's company for deploy.");
|
|
197
|
+
}
|
|
198
|
+
return { headers: { "X-Org-Slug": companySlug }, org: companySlug };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* EventBridge Scheduler cron for the agent jobs API.
|
|
202
|
+
* Daily uses `?` on the weekday field; weekly pins the weekday and `?` on the day-of-month.
|
|
203
|
+
*/
|
|
204
|
+
export function reportJobCron(schedule) {
|
|
205
|
+
if (schedule.kind === "manual")
|
|
206
|
+
return null;
|
|
207
|
+
const [hour, minute] = schedule.time.split(":");
|
|
208
|
+
if (!hour || !minute)
|
|
209
|
+
throw new ReportPublishError("Report schedule is invalid.", "source-invalid");
|
|
210
|
+
if (schedule.kind === "daily")
|
|
211
|
+
return `cron(${minute} ${hour} * * ? *)`;
|
|
212
|
+
if (schedule.weekday === undefined)
|
|
213
|
+
throw new ReportPublishError("Report schedule is invalid.", "source-invalid");
|
|
214
|
+
return `cron(${minute} ${hour} ? * ${schedule.weekday} *)`;
|
|
215
|
+
}
|
|
216
|
+
/** Job prompt is the stored update-report runbook with this report id filled in. */
|
|
217
|
+
export function reportJobPrompt(reportId) {
|
|
218
|
+
return loadUpdateReportRunbook().split("<reportId>").join(reportId);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Install, replace, or delete this bot's own scheduled job for a report.
|
|
222
|
+
* Jobs-API failures are recorded as jobState=failed with a plain reason.
|
|
223
|
+
*/
|
|
224
|
+
export async function adoptReport(report, deps) {
|
|
225
|
+
const schedule = report.schedule;
|
|
226
|
+
if (!schedule) {
|
|
227
|
+
await deps.patch(report, {
|
|
228
|
+
jobState: "failed",
|
|
229
|
+
jobStateReason: "Report schedule is missing.",
|
|
230
|
+
});
|
|
231
|
+
throw new ReportPublishError("Report schedule is missing.", "source-invalid");
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
if (report.jobId)
|
|
235
|
+
await deps.cancelJob(report.jobId);
|
|
236
|
+
const cron = reportJobCron(schedule);
|
|
237
|
+
if (!cron) {
|
|
238
|
+
return deps.patch(report, { jobId: null, jobState: "adopted", jobStateReason: null });
|
|
239
|
+
}
|
|
240
|
+
const created = await deps.createJob({
|
|
241
|
+
prompt: reportJobPrompt(report.reportId),
|
|
242
|
+
schedule: { kind: "recurring", cron, timezone: schedule.timezone },
|
|
243
|
+
context: { reportId: report.reportId, channelId: report.channelId },
|
|
244
|
+
});
|
|
245
|
+
return deps.patch(report, { jobId: created.jobId, jobState: "adopted", jobStateReason: null });
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const wrapped = error instanceof ReportPublishError ? error : new ReportPublishError("Could not install the report job.", "job-failed");
|
|
249
|
+
try {
|
|
250
|
+
await deps.patch(report, { jobState: "failed", jobStateReason: plainFailureReason(wrapped) });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// The jobs-API failure is the one the operator needs; TTL/retry covers a
|
|
254
|
+
// missing jobState write.
|
|
255
|
+
}
|
|
256
|
+
throw wrapped;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Lock, deploy, then persist a report run. Every known failure after lock
|
|
261
|
+
* acquisition writes a terminal lastRun so the 15-minute server TTL is a
|
|
262
|
+
* crash-only fallback rather than normal control flow.
|
|
263
|
+
*/
|
|
264
|
+
export async function publishReport(report, directory, draft, deps) {
|
|
265
|
+
const startedAt = deps.now();
|
|
266
|
+
let locked = false;
|
|
267
|
+
try {
|
|
268
|
+
try {
|
|
269
|
+
await deps.patch(report, { lastRun: { at: startedAt, status: "running" } });
|
|
270
|
+
locked = true;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error instanceof ReportPublishError && error.code === "already-running")
|
|
274
|
+
throw error;
|
|
275
|
+
throw new ReportPublishError("Could not take the report run lock.");
|
|
276
|
+
}
|
|
277
|
+
const source = loadReportSource(directory);
|
|
278
|
+
const deployment = await deps.deploy({
|
|
279
|
+
slug: reportDeploySlug(report, draft),
|
|
280
|
+
files: source.files,
|
|
281
|
+
access: defaultAccessPolicy(report),
|
|
282
|
+
});
|
|
283
|
+
const completedAt = deps.now();
|
|
284
|
+
const patch = {
|
|
285
|
+
lastRun: { at: completedAt, status: "succeeded", version: deployment.deployId },
|
|
286
|
+
...(source.previewTitle ? { previewTitle: source.previewTitle } : {}),
|
|
287
|
+
...(source.previewImage ? { previewImage: source.previewImage } : {}),
|
|
288
|
+
};
|
|
289
|
+
if (draft) {
|
|
290
|
+
patch.state = "draft-ready";
|
|
291
|
+
patch.draft = { url: deployment.url, appId: deployment.appId, generatedAt: completedAt };
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
patch.state = "live";
|
|
295
|
+
patch.url = deployment.url;
|
|
296
|
+
patch.deployAppId = deployment.appId;
|
|
297
|
+
}
|
|
298
|
+
return await deps.patch(report, patch);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (locked) {
|
|
302
|
+
const reason = plainFailureReason(error);
|
|
303
|
+
try {
|
|
304
|
+
await deps.patch(report, {
|
|
305
|
+
lastRun: { at: deps.now(), status: "failed", reason },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// The original failure is more actionable. The server's lock TTL is the
|
|
310
|
+
// intended recovery path for an unavailable status PATCH.
|
|
311
|
+
}
|
|
312
|
+
const notice = ownerFailureNotice({
|
|
313
|
+
code: error instanceof ReportPublishError ? error.code : undefined,
|
|
314
|
+
reason,
|
|
315
|
+
reportUrl: report.url,
|
|
316
|
+
ownerPersonUid: report.createdBy,
|
|
317
|
+
});
|
|
318
|
+
if (notice && deps.notifyOwner) {
|
|
319
|
+
try {
|
|
320
|
+
await deps.notifyOwner(notice);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// The failed lastRun is the durable record. A missed DM does not
|
|
324
|
+
// replace that error.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (error instanceof ReportPublishError)
|
|
329
|
+
throw error;
|
|
330
|
+
throw new ReportPublishError(plainFailureReason(error));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** hq-deploy static payload format: index.html stays at the archive root. */
|
|
334
|
+
export function buildReportTarball(files) {
|
|
335
|
+
const chunks = [];
|
|
336
|
+
for (const file of files) {
|
|
337
|
+
const bytes = textOf(file);
|
|
338
|
+
chunks.push(tarHeader(file.path, bytes.byteLength), bytes);
|
|
339
|
+
const padding = bytes.byteLength % 512;
|
|
340
|
+
if (padding)
|
|
341
|
+
chunks.push(Buffer.alloc(512 - padding));
|
|
342
|
+
}
|
|
343
|
+
chunks.push(Buffer.alloc(1024));
|
|
344
|
+
const bytes = gzipSync(Buffer.concat(chunks));
|
|
345
|
+
if (bytes.byteLength > REPORT_BUNDLE_MAX_BYTES) {
|
|
346
|
+
throw new ReportPublishError("Report bundle exceeds the 10 MB deploy limit.", "source-invalid");
|
|
347
|
+
}
|
|
348
|
+
return { bytes, sizeBytes: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
349
|
+
}
|
|
350
|
+
function tarHeader(filePath, size) {
|
|
351
|
+
if (!filePath || filePath.startsWith("/") || filePath.includes("..")) {
|
|
352
|
+
throw new ReportPublishError("Report source contains an unsafe archive path.", "source-invalid");
|
|
353
|
+
}
|
|
354
|
+
const header = Buffer.alloc(512, 0);
|
|
355
|
+
if (Buffer.byteLength(filePath, "utf8") > 100)
|
|
356
|
+
throw new ReportPublishError("Report source path is too long for deploy.", "source-invalid");
|
|
357
|
+
header.write(filePath, 0, 100, "utf8");
|
|
358
|
+
writeOctal(header, 0o644, 100, 8);
|
|
359
|
+
writeOctal(header, size, 124, 12);
|
|
360
|
+
writeOctal(header, Math.floor(Date.now() / 1000), 136, 12);
|
|
361
|
+
header.fill(0x20, 148, 156);
|
|
362
|
+
header.write("0", 156, 1);
|
|
363
|
+
header.write("ustar\0", 257, 6, "binary");
|
|
364
|
+
header.write("00", 263, 2);
|
|
365
|
+
let sum = 0;
|
|
366
|
+
for (const byte of header)
|
|
367
|
+
sum += byte;
|
|
368
|
+
writeOctal(header, sum, 148, 8);
|
|
369
|
+
return header;
|
|
370
|
+
}
|
|
371
|
+
function writeOctal(buffer, value, offset, length) {
|
|
372
|
+
buffer.write(value.toString(8).padStart(length - 1, "0").slice(-length + 1), offset, length - 1, "ascii");
|
|
373
|
+
buffer[offset + length - 1] = 0;
|
|
374
|
+
}
|
|
375
|
+
/** Isolated test helper for creating a compliant report source. */
|
|
376
|
+
export function makeReportFixture(html) {
|
|
377
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "hq-report-test-"));
|
|
378
|
+
fs.writeFileSync(path.join(directory, "index.html"), html);
|
|
379
|
+
return { directory, cleanup: () => fs.rmSync(directory, { recursive: true, force: true }) };
|
|
380
|
+
}
|
|
381
|
+
//# sourceMappingURL=report-publish.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type DigestTask = {
|
|
2
|
+
title: string;
|
|
3
|
+
project: string;
|
|
4
|
+
channelHref?: string;
|
|
5
|
+
open?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type DigestSignal = {
|
|
8
|
+
kind: string;
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
export type FocusItem = {
|
|
12
|
+
title: string;
|
|
13
|
+
project: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
export type ReportRunRecord = {
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
inputs: {
|
|
19
|
+
template: string;
|
|
20
|
+
tasks: string[];
|
|
21
|
+
signals: string[];
|
|
22
|
+
projects: string[];
|
|
23
|
+
};
|
|
24
|
+
counts: {
|
|
25
|
+
tasks: number;
|
|
26
|
+
projects: number;
|
|
27
|
+
signals: number;
|
|
28
|
+
focus: number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type DigestPage = {
|
|
32
|
+
indexHtml: string;
|
|
33
|
+
data: {
|
|
34
|
+
template: "daily-digest";
|
|
35
|
+
person: string;
|
|
36
|
+
projects: {
|
|
37
|
+
name: string;
|
|
38
|
+
count: number;
|
|
39
|
+
href: string;
|
|
40
|
+
}[];
|
|
41
|
+
signals: {
|
|
42
|
+
kind: string;
|
|
43
|
+
text: string;
|
|
44
|
+
}[];
|
|
45
|
+
focus: FocusItem[];
|
|
46
|
+
};
|
|
47
|
+
reportRun: ReportRunRecord;
|
|
48
|
+
};
|
|
49
|
+
export declare function escapeReportText(value: string): string;
|
|
50
|
+
/** Map a structured inbox prompt onto the one hq report verb it may call. */
|
|
51
|
+
export declare function reportLaneCommand(kind: string): "adopt" | "publish" | "decommission" | null;
|
|
52
|
+
export declare function safeChannelHref(value: string | undefined, name: string): string;
|
|
53
|
+
export declare function renderDailyDigest(input: {
|
|
54
|
+
person: string;
|
|
55
|
+
tasks: DigestTask[];
|
|
56
|
+
signals: DigestSignal[];
|
|
57
|
+
operatorInstructions: string;
|
|
58
|
+
generatedAt?: string;
|
|
59
|
+
}): DigestPage;
|
|
60
|
+
export declare function writeDigestPage(directory: string, page: DigestPage): void;
|
|
61
|
+
//# sourceMappingURL=report-run.d.ts.map
|