@indigoai-us/hq-cli 5.345.32 → 5.345.34
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 +15 -0
- package/dist/command-catalog.generated.d.ts +92 -3
- package/dist/command-catalog.generated.js +111 -3
- package/dist/commands/cloud-provision.d.ts +2 -0
- package/dist/commands/db-dump.d.ts +8 -0
- package/dist/commands/db-dump.js +50 -0
- package/dist/commands/db-provision.d.ts +3 -10
- package/dist/commands/db-provision.js +26 -47
- package/dist/commands/db-remote-deps.d.ts +18 -0
- package/dist/commands/db-remote-deps.js +59 -0
- package/dist/commands/db-sql.d.ts +2 -1
- package/dist/commands/db-sql.js +30 -5
- package/dist/commands/db-usage.d.ts +7 -0
- package/dist/commands/db-usage.js +28 -0
- package/dist/commands/db.js +4 -0
- package/dist/commands/files.js +33 -5
- package/dist/commands/lanes-message.js +40 -8
- package/dist/commands/lanes-questions.d.ts +6 -1
- package/dist/commands/lanes-questions.js +19 -7
- package/dist/commands/lanes-resume.js +17 -6
- package/dist/commands/lanes-shared.d.ts +6 -1
- package/dist/commands/lanes.js +92 -15
- package/dist/commands/mesh.js +150 -26
- package/dist/commands/reindex.js +11 -8
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +68 -604
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/daemon/remote-control.js +62 -3
- package/dist/lib/db/control-plane.d.ts +32 -0
- package/dist/lib/db/control-plane.js +50 -0
- package/dist/lib/db/redact.d.ts +4 -0
- package/dist/lib/db/redact.js +17 -0
- package/dist/lib/db/remote-engine.d.ts +67 -0
- package/dist/lib/db/remote-engine.js +165 -0
- package/dist/lib/db/usage-format.d.ts +4 -0
- package/dist/lib/db/usage-format.js +41 -0
- package/dist/lib/index-render/shared.d.ts +3 -1
- package/dist/lib/index-render/shared.js +5 -2
- package/dist/lib/lanes/depth-cap.d.ts +7 -0
- package/dist/lib/lanes/depth-cap.js +41 -0
- package/dist/lib/lanes/keep-alive.d.ts +12 -0
- package/dist/lib/lanes/keep-alive.js +25 -6
- package/dist/lib/lanes/reconcile.d.ts +1 -1
- package/dist/lib/lanes/reconcile.js +16 -5
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/install.d.ts +7 -4
- package/dist/lib/mesh/live/daemon/install.js +144 -47
- package/dist/lib/mesh/project-board.d.ts +17 -0
- package/dist/lib/mesh/project-board.js +28 -0
- package/dist/lib/mesh/story-status.d.ts +16 -0
- package/dist/lib/mesh/story-status.js +32 -7
- package/dist/utils/self-update.d.ts +2 -0
- package/dist/utils/self-update.js +61 -23
- package/dist/utils/version-check.d.ts +1 -1
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +21 -14
- package/dist/utils/version-gate.js +119 -28
- package/package.json +4 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.33","bundleSha256":"
|
|
1
|
+
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.33","bundleSha256":"75c61dfadcd4d28795d082e0cc867b09f2b825baa3db95f662c51e9dce299412","formatVersion":1}
|
|
@@ -24,6 +24,7 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
const TMUX_SOCKET = "hq-daemon";
|
|
27
|
+
const TMUX_CREATOR_OPTION = "@hq-remote-control-creator";
|
|
27
28
|
export const TMUX_SESSION = "remote-control";
|
|
28
29
|
export const CODEX_TMUX_SESSION = "codex-remote-control";
|
|
29
30
|
export function remoteControlArgs(opts) {
|
|
@@ -204,7 +205,56 @@ function safePaneTail(output) {
|
|
|
204
205
|
}
|
|
205
206
|
export async function runRemoteControlHost(deps) {
|
|
206
207
|
const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
|
|
207
|
-
const
|
|
208
|
+
const invokerPid = process.pid;
|
|
209
|
+
const killSession = () => {
|
|
210
|
+
if (tmux("has-session", "-t", deps.session).status !== 0)
|
|
211
|
+
return true;
|
|
212
|
+
const rawCreator = tmux("show-option", "-qv", "-t", deps.session, TMUX_CREATOR_OPTION);
|
|
213
|
+
let creator = null;
|
|
214
|
+
let legacyRecordReason = "no creator record";
|
|
215
|
+
if (rawCreator.status === 0 && rawCreator.stdout.trim() !== "") {
|
|
216
|
+
try {
|
|
217
|
+
const value = JSON.parse(rawCreator.stdout);
|
|
218
|
+
if (Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.cwd === "string") {
|
|
219
|
+
creator = { pid: value.pid, cwd: value.cwd };
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
legacyRecordReason = "malformed creator record";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
legacyRecordReason = "malformed creator record";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (rawCreator.status !== 0) {
|
|
230
|
+
legacyRecordReason = "malformed creator record";
|
|
231
|
+
}
|
|
232
|
+
if (!creator) {
|
|
233
|
+
deps.log(`remote-control tmux session has ${legacyRecordReason}; using bridge-owner liveness fallback; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
234
|
+
const owner = foreignClaudeOwner(deps);
|
|
235
|
+
if (owner !== null) {
|
|
236
|
+
deps.log(`remote-control refused to kill legacy tmux session with live Claude bridge owner pid=${owner}; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const panePid = tmux("display-message", "-p", "-t", deps.session, "#{pane_pid}");
|
|
240
|
+
const paneCwd = tmux("display-message", "-p", "-t", deps.session, "#{pane_current_path}");
|
|
241
|
+
const victimPid = panePid.status === 0 && /^\d+$/.test(panePid.stdout.trim())
|
|
242
|
+
? panePid.stdout.trim()
|
|
243
|
+
: "unknown";
|
|
244
|
+
const victimCwd = paneCwd.status === 0 && paneCwd.stdout.trim() !== ""
|
|
245
|
+
? paneCwd.stdout.trim()
|
|
246
|
+
: "unknown";
|
|
247
|
+
deps.log(`remote-control kill tmux session victim pid=${victimPid} cwd=${JSON.stringify(victimCwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
248
|
+
return tmux("kill-session", "-t", deps.session).status === 0;
|
|
249
|
+
}
|
|
250
|
+
const sameInvoker = creator.pid === invokerPid && creator.cwd === deps.cwd;
|
|
251
|
+
if (!sameInvoker && (deps.pidAlive ?? processAlive)(creator.pid)) {
|
|
252
|
+
deps.log(`remote-control refused to kill live tmux session creator pid=${creator.pid} cwd=${JSON.stringify(creator.cwd)}; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
deps.log(`remote-control kill tmux session victim pid=${creator.pid} cwd=${JSON.stringify(creator.cwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
256
|
+
return tmux("kill-session", "-t", deps.session).status === 0;
|
|
257
|
+
};
|
|
208
258
|
/** When the session last printed anything, in ms; null when tmux cannot say. */
|
|
209
259
|
const lastActivity = () => {
|
|
210
260
|
const r = tmux("display-message", "-p", "-t", deps.session, "#{window_activity}");
|
|
@@ -213,7 +263,8 @@ export async function runRemoteControlHost(deps) {
|
|
|
213
263
|
};
|
|
214
264
|
// Remove this daemon's leftover private session before checking the process
|
|
215
265
|
// pointer, so its previous command is not mistaken for a foreign owner.
|
|
216
|
-
killSession()
|
|
266
|
+
if (!killSession())
|
|
267
|
+
return 1;
|
|
217
268
|
let waitingFor = null;
|
|
218
269
|
for (;;) {
|
|
219
270
|
if (deps.signal.aborted)
|
|
@@ -233,6 +284,14 @@ export async function runRemoteControlHost(deps) {
|
|
|
233
284
|
deps.log(`could not create the tmux session: ${created.stderr.trim() || `tmux exited ${created.status}`}`);
|
|
234
285
|
return 1;
|
|
235
286
|
}
|
|
287
|
+
const creatorRecord = JSON.stringify({ pid: invokerPid, cwd: deps.cwd });
|
|
288
|
+
const recorded = tmux("set-option", "-t", deps.session, TMUX_CREATOR_OPTION, creatorRecord);
|
|
289
|
+
if (recorded.status !== 0) {
|
|
290
|
+
deps.log(`remote-control could not record session creator; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
291
|
+
deps.log(`remote-control kill tmux session victim pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
292
|
+
tmux("kill-session", "-t", deps.session);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
236
295
|
const retained = tmux("set-window-option", "-t", deps.session, "remain-on-exit", "on");
|
|
237
296
|
const started = retained.status === 0
|
|
238
297
|
? tmux("respawn-pane", "-k", "-t", deps.session, ...deps.argv)
|
|
@@ -242,7 +301,7 @@ export async function runRemoteControlHost(deps) {
|
|
|
242
301
|
deps.log(`could not start the tmux session: ${started.stderr.trim() || `tmux exited ${started.status}`}`);
|
|
243
302
|
return 1;
|
|
244
303
|
}
|
|
245
|
-
deps.log(`${deps.label} started in ${deps.cwd}`);
|
|
304
|
+
deps.log(`${deps.label} started in ${JSON.stringify(deps.cwd)} (creator pid ${invokerPid})`);
|
|
246
305
|
const startedWith = deps.binaryFingerprint();
|
|
247
306
|
let updatedAt = null;
|
|
248
307
|
for (;;) {
|
|
@@ -24,6 +24,33 @@ export interface RemoteStatusResponse {
|
|
|
24
24
|
secretRef: string;
|
|
25
25
|
} | null;
|
|
26
26
|
}
|
|
27
|
+
export interface RemoteUsageResponse {
|
|
28
|
+
companyUid: string;
|
|
29
|
+
dpuUsed: number;
|
|
30
|
+
dpuCap: number;
|
|
31
|
+
storageBytes: number;
|
|
32
|
+
storageCap: number;
|
|
33
|
+
pctDpu: number;
|
|
34
|
+
pctStorage: number;
|
|
35
|
+
period: string;
|
|
36
|
+
measuredAt: string | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Scoped temporary AWS credentials for one app's database role. The control
|
|
40
|
+
* plane would issue these from `POST /v1/db/apps/{appId}/session`.
|
|
41
|
+
*/
|
|
42
|
+
export interface RemoteAppSession {
|
|
43
|
+
endpoint: string;
|
|
44
|
+
region: string;
|
|
45
|
+
role: string;
|
|
46
|
+
schema: string;
|
|
47
|
+
credentials: {
|
|
48
|
+
accessKeyId: string;
|
|
49
|
+
secretAccessKey: string;
|
|
50
|
+
sessionToken: string;
|
|
51
|
+
expiration?: string;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
27
54
|
export interface ControlPlaneClientOptions {
|
|
28
55
|
baseUrl: string;
|
|
29
56
|
/** Bearer access token */
|
|
@@ -41,5 +68,10 @@ export declare class ControlPlaneDbClient {
|
|
|
41
68
|
region?: string;
|
|
42
69
|
}): Promise<RemoteProvisionResponse>;
|
|
43
70
|
status(companyUid: string): Promise<RemoteStatusResponse>;
|
|
71
|
+
usage(companyUid: string): Promise<RemoteUsageResponse>;
|
|
72
|
+
appSession(input: {
|
|
73
|
+
companyUid: string;
|
|
74
|
+
appId: string;
|
|
75
|
+
}): Promise<RemoteAppSession>;
|
|
44
76
|
}
|
|
45
77
|
//# sourceMappingURL=control-plane.d.ts.map
|
|
@@ -78,5 +78,55 @@ export class ControlPlaneDbClient {
|
|
|
78
78
|
}
|
|
79
79
|
return JSON.parse(text);
|
|
80
80
|
}
|
|
81
|
+
async usage(companyUid) {
|
|
82
|
+
const token = await this.getAccessToken();
|
|
83
|
+
const url = `${this.baseUrl}/v1/db/usage?companyUid=${encodeURIComponent(companyUid)}`;
|
|
84
|
+
const res = await this.fetchImpl(url, {
|
|
85
|
+
method: "GET",
|
|
86
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
87
|
+
signal: AbortSignal.timeout(DB_STATUS_TIMEOUT_MS),
|
|
88
|
+
});
|
|
89
|
+
const text = await res.text();
|
|
90
|
+
assertNoPostgresUrl("usage", text);
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
throw httpError("usage", res.status, text);
|
|
93
|
+
return JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
async appSession(input) {
|
|
96
|
+
const token = await this.getAccessToken();
|
|
97
|
+
const url = `${this.baseUrl}/v1/db/apps/${encodeURIComponent(input.appId)}/session`;
|
|
98
|
+
const res = await this.fetchImpl(url, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ companyUid: input.companyUid }),
|
|
105
|
+
signal: AbortSignal.timeout(DB_STATUS_TIMEOUT_MS),
|
|
106
|
+
});
|
|
107
|
+
const text = await res.text();
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw httpError("session", res.status, text);
|
|
110
|
+
return JSON.parse(text);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function httpError(label, status, text) {
|
|
114
|
+
let msg = `${label} failed (${status})`;
|
|
115
|
+
let code;
|
|
116
|
+
try {
|
|
117
|
+
const j = JSON.parse(text);
|
|
118
|
+
if (typeof j.error === "string")
|
|
119
|
+
msg = j.error;
|
|
120
|
+
if (typeof j.code === "string")
|
|
121
|
+
code = j.code;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* keep */
|
|
125
|
+
}
|
|
126
|
+
const err = new Error(msg);
|
|
127
|
+
err.status = status;
|
|
128
|
+
if (code)
|
|
129
|
+
err.code = code;
|
|
130
|
+
return err;
|
|
81
131
|
}
|
|
82
132
|
//# sourceMappingURL=control-plane.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
/postgres(?:ql)?:\/\/\S*/gi,
|
|
3
|
+
/X-Amz-[A-Za-z-]+=?[^\s&"']*/gi,
|
|
4
|
+
/Security-Token=?[^\s&"']*/gi,
|
|
5
|
+
/\b[A-Za-z0-9+/_=-]{40,}\b/g,
|
|
6
|
+
];
|
|
7
|
+
/** Strip connection strings, signed-URL parameters, and token-like strings. */
|
|
8
|
+
export function redactSecrets(text) {
|
|
9
|
+
let out = text;
|
|
10
|
+
for (const pattern of SECRET_PATTERNS)
|
|
11
|
+
out = out.replace(pattern, "[redacted]");
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
export function safeErrorMessage(error) {
|
|
15
|
+
return redactSecrets(error instanceof Error ? error.message : "Unknown error");
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ControlPlaneDbClient, RemoteAppSession } from "./control-plane.js";
|
|
2
|
+
/**
|
|
3
|
+
* Source of short-lived, app-scoped database credentials. The control plane
|
|
4
|
+
* route behind it, `POST /v1/db/apps/{appId}/session`, does not exist yet.
|
|
5
|
+
*/
|
|
6
|
+
export interface DbCredentialProvider {
|
|
7
|
+
getSession(input: {
|
|
8
|
+
companyUid: string;
|
|
9
|
+
appId: string;
|
|
10
|
+
}): Promise<RemoteAppSession>;
|
|
11
|
+
}
|
|
12
|
+
export declare function controlPlaneCredentialProvider(client: Pick<ControlPlaneDbClient, "appSession">): DbCredentialProvider;
|
|
13
|
+
export interface RemoteQueryResult {
|
|
14
|
+
rowCount: number | null;
|
|
15
|
+
fields: {
|
|
16
|
+
name: string;
|
|
17
|
+
}[];
|
|
18
|
+
rows: Record<string, unknown>[];
|
|
19
|
+
}
|
|
20
|
+
export interface RemoteClient {
|
|
21
|
+
connect(): Promise<void>;
|
|
22
|
+
query(sql: string, params?: unknown[]): Promise<RemoteQueryResult | RemoteQueryResult[]>;
|
|
23
|
+
end(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface RemoteClientConfig {
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
user: string;
|
|
29
|
+
password: string;
|
|
30
|
+
database: string;
|
|
31
|
+
ssl: {
|
|
32
|
+
rejectUnauthorized: boolean;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface RemoteEngineDeps {
|
|
36
|
+
provider: DbCredentialProvider;
|
|
37
|
+
tokenFactory?: (session: RemoteAppSession) => Promise<string>;
|
|
38
|
+
clientFactory?: (config: RemoteClientConfig) => RemoteClient;
|
|
39
|
+
}
|
|
40
|
+
export declare function quoteIdent(name: string): string;
|
|
41
|
+
export interface RemoteAppConnection {
|
|
42
|
+
schema: string;
|
|
43
|
+
query(sql: string, params?: unknown[]): Promise<RemoteQueryResult[]>;
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/** Open a connection as the app role with `search_path` set to the app schema. */
|
|
47
|
+
export declare function openRemoteAppConnection(deps: RemoteEngineDeps, input: {
|
|
48
|
+
companyUid: string;
|
|
49
|
+
appId: string;
|
|
50
|
+
}): Promise<RemoteAppConnection>;
|
|
51
|
+
export interface RemoteSqlOutcome {
|
|
52
|
+
results: RemoteQueryResult[];
|
|
53
|
+
readonly: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Run SQL in the app schema. Without `write`, the SQL runs inside BEGIN READ ONLY and is rolled back. */
|
|
56
|
+
export declare function runRemoteSql(conn: RemoteAppConnection, sql: string, write: boolean): Promise<RemoteSqlOutcome>;
|
|
57
|
+
export declare function formatRemoteResults(outcome: RemoteSqlOutcome): string;
|
|
58
|
+
export interface DumpSummary {
|
|
59
|
+
tables: number;
|
|
60
|
+
rows: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Stream a schema dump (DDL then INSERTs) to `write`. Reads data in chunks
|
|
64
|
+
* of DUMP_CHUNK_ROWS rows ordered by primary key.
|
|
65
|
+
*/
|
|
66
|
+
export declare function dumpRemoteSchema(conn: RemoteAppConnection, write: (text: string) => void): Promise<DumpSummary>;
|
|
67
|
+
//# sourceMappingURL=remote-engine.d.ts.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { DsqlSigner } from "@aws-sdk/dsql-signer";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
const SCHEMA_PATTERN = /^app_[a-z0-9_]+$/;
|
|
4
|
+
const DUMP_CHUNK_ROWS = 500;
|
|
5
|
+
export function controlPlaneCredentialProvider(client) {
|
|
6
|
+
return { getSession: (input) => client.appSession(input) };
|
|
7
|
+
}
|
|
8
|
+
async function defaultToken(session) {
|
|
9
|
+
const signer = new DsqlSigner({
|
|
10
|
+
hostname: session.endpoint,
|
|
11
|
+
region: session.region,
|
|
12
|
+
credentials: {
|
|
13
|
+
accessKeyId: session.credentials.accessKeyId,
|
|
14
|
+
secretAccessKey: session.credentials.secretAccessKey,
|
|
15
|
+
sessionToken: session.credentials.sessionToken,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
return signer.getDbConnectAuthToken();
|
|
19
|
+
}
|
|
20
|
+
function defaultClient(config) {
|
|
21
|
+
return new pg.Client(config);
|
|
22
|
+
}
|
|
23
|
+
export function quoteIdent(name) {
|
|
24
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
25
|
+
}
|
|
26
|
+
/** Open a connection as the app role with `search_path` set to the app schema. */
|
|
27
|
+
export async function openRemoteAppConnection(deps, input) {
|
|
28
|
+
const session = await deps.provider.getSession(input);
|
|
29
|
+
if (!SCHEMA_PATTERN.test(session.schema)) {
|
|
30
|
+
throw new Error("Control plane returned an unexpected schema name");
|
|
31
|
+
}
|
|
32
|
+
const password = await (deps.tokenFactory ?? defaultToken)(session);
|
|
33
|
+
const client = (deps.clientFactory ?? defaultClient)({
|
|
34
|
+
host: session.endpoint,
|
|
35
|
+
port: 5432,
|
|
36
|
+
user: session.role,
|
|
37
|
+
password,
|
|
38
|
+
database: "postgres",
|
|
39
|
+
ssl: { rejectUnauthorized: true },
|
|
40
|
+
});
|
|
41
|
+
await client.connect();
|
|
42
|
+
try {
|
|
43
|
+
await client.query(`SET search_path TO ${quoteIdent(session.schema)}`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
await client.end().catch(() => undefined);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
schema: session.schema,
|
|
51
|
+
async query(sql, params) {
|
|
52
|
+
const res = await client.query(sql, params);
|
|
53
|
+
return Array.isArray(res) ? res : [res];
|
|
54
|
+
},
|
|
55
|
+
close: () => client.end(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Run SQL in the app schema. Without `write`, the SQL runs inside BEGIN READ ONLY and is rolled back. */
|
|
59
|
+
export async function runRemoteSql(conn, sql, write) {
|
|
60
|
+
await conn.query(write ? "BEGIN" : "BEGIN READ ONLY");
|
|
61
|
+
try {
|
|
62
|
+
const results = await conn.query(sql);
|
|
63
|
+
await conn.query(write ? "COMMIT" : "ROLLBACK");
|
|
64
|
+
return { results, readonly: !write };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await conn.query("ROLLBACK").catch(() => undefined);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function formatRemoteResults(outcome) {
|
|
72
|
+
const blocks = outcome.results.map((r) => {
|
|
73
|
+
if (r.fields.length === 0)
|
|
74
|
+
return JSON.stringify({ rows: r.rowCount ?? 0 });
|
|
75
|
+
if (r.rows.length === 0) {
|
|
76
|
+
return JSON.stringify({ columns: r.fields.map((f) => f.name), rows: 0 });
|
|
77
|
+
}
|
|
78
|
+
return r.rows.map((row) => JSON.stringify(row)).join("\n");
|
|
79
|
+
});
|
|
80
|
+
return blocks.join("\n");
|
|
81
|
+
}
|
|
82
|
+
function sqlLiteral(value) {
|
|
83
|
+
if (value === null || value === undefined)
|
|
84
|
+
return "NULL";
|
|
85
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
86
|
+
return String(value);
|
|
87
|
+
if (typeof value === "boolean")
|
|
88
|
+
return value ? "TRUE" : "FALSE";
|
|
89
|
+
if (value instanceof Date)
|
|
90
|
+
return `'${value.toISOString()}'`;
|
|
91
|
+
if (Buffer.isBuffer(value))
|
|
92
|
+
return `'\\x${value.toString("hex")}'`;
|
|
93
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
94
|
+
return `'${text.replace(/'/g, "''")}'`;
|
|
95
|
+
}
|
|
96
|
+
function columnDdl(c) {
|
|
97
|
+
const parts = [quoteIdent(c.column_name), c.data_type];
|
|
98
|
+
if (c.column_default !== null)
|
|
99
|
+
parts.push(`DEFAULT ${c.column_default}`);
|
|
100
|
+
if (c.is_nullable === "NO")
|
|
101
|
+
parts.push("NOT NULL");
|
|
102
|
+
return parts.join(" ");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Stream a schema dump (DDL then INSERTs) to `write`. Reads data in chunks
|
|
106
|
+
* of DUMP_CHUNK_ROWS rows ordered by primary key.
|
|
107
|
+
*/
|
|
108
|
+
export async function dumpRemoteSchema(conn, write) {
|
|
109
|
+
const [cols] = await conn.query(`SELECT table_name, column_name, data_type, is_nullable, column_default
|
|
110
|
+
FROM information_schema.columns
|
|
111
|
+
WHERE table_schema = $1
|
|
112
|
+
ORDER BY table_name, ordinal_position`, [conn.schema]);
|
|
113
|
+
const [pks] = await conn.query(`SELECT kcu.table_name, kcu.column_name
|
|
114
|
+
FROM information_schema.table_constraints tc
|
|
115
|
+
JOIN information_schema.key_column_usage kcu
|
|
116
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
117
|
+
WHERE tc.table_schema = $1 AND tc.constraint_type = 'PRIMARY KEY'
|
|
118
|
+
ORDER BY kcu.table_name, kcu.ordinal_position`, [conn.schema]);
|
|
119
|
+
const [indexes] = await conn.query(`SELECT tablename, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`, [conn.schema]);
|
|
120
|
+
const byTable = new Map();
|
|
121
|
+
for (const row of cols.rows) {
|
|
122
|
+
const list = byTable.get(row.table_name) ?? [];
|
|
123
|
+
list.push(row);
|
|
124
|
+
byTable.set(row.table_name, list);
|
|
125
|
+
}
|
|
126
|
+
const pkByTable = new Map();
|
|
127
|
+
for (const row of pks.rows) {
|
|
128
|
+
const list = pkByTable.get(row.table_name) ?? [];
|
|
129
|
+
list.push(row.column_name);
|
|
130
|
+
pkByTable.set(row.table_name, list);
|
|
131
|
+
}
|
|
132
|
+
write(`-- hq db dump for schema ${conn.schema}\n\n`);
|
|
133
|
+
let totalRows = 0;
|
|
134
|
+
for (const [table, columns] of byTable) {
|
|
135
|
+
const pk = pkByTable.get(table) ?? [];
|
|
136
|
+
const defs = columns.map(columnDdl);
|
|
137
|
+
if (pk.length > 0)
|
|
138
|
+
defs.push(`PRIMARY KEY (${pk.map(quoteIdent).join(", ")})`);
|
|
139
|
+
write(`CREATE TABLE ${quoteIdent(table)} (\n ${defs.join(",\n ")}\n);\n\n`);
|
|
140
|
+
}
|
|
141
|
+
for (const row of indexes.rows) {
|
|
142
|
+
if (/_pkey\b/.test(row.indexdef))
|
|
143
|
+
continue;
|
|
144
|
+
write(`${row.indexdef.replace(/^CREATE (UNIQUE )?INDEX /, "CREATE $1INDEX ASYNC ")};\n`);
|
|
145
|
+
}
|
|
146
|
+
for (const [table, columns] of byTable) {
|
|
147
|
+
const pk = pkByTable.get(table) ?? [];
|
|
148
|
+
const order = (pk.length > 0 ? pk : columns.map((c) => c.column_name))
|
|
149
|
+
.map(quoteIdent)
|
|
150
|
+
.join(", ");
|
|
151
|
+
const names = columns.map((c) => quoteIdent(c.column_name)).join(", ");
|
|
152
|
+
for (let offset = 0;; offset += DUMP_CHUNK_ROWS) {
|
|
153
|
+
const [page] = await conn.query(`SELECT * FROM ${quoteIdent(table)} ORDER BY ${order} LIMIT ${DUMP_CHUNK_ROWS} OFFSET ${offset}`);
|
|
154
|
+
for (const row of page.rows) {
|
|
155
|
+
const values = columns.map((c) => sqlLiteral(row[c.column_name])).join(", ");
|
|
156
|
+
write(`INSERT INTO ${quoteIdent(table)} (${names}) VALUES (${values});\n`);
|
|
157
|
+
}
|
|
158
|
+
totalRows += page.rows.length;
|
|
159
|
+
if (page.rows.length < DUMP_CHUNK_ROWS)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { tables: byTable.size, rows: totalRows };
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=remote-engine.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const UNITS = ["bytes", "KB", "MB", "GB", "TB"];
|
|
2
|
+
export function formatBytes(bytes) {
|
|
3
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
4
|
+
return "0 bytes";
|
|
5
|
+
let value = bytes;
|
|
6
|
+
let unit = 0;
|
|
7
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
8
|
+
value /= 1024;
|
|
9
|
+
unit += 1;
|
|
10
|
+
}
|
|
11
|
+
const rounded = unit === 0 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 0 : 1);
|
|
12
|
+
return `${rounded} ${UNITS[unit]}`;
|
|
13
|
+
}
|
|
14
|
+
function percent(pct) {
|
|
15
|
+
if (!Number.isFinite(pct))
|
|
16
|
+
return "0%";
|
|
17
|
+
return `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}%`;
|
|
18
|
+
}
|
|
19
|
+
export function formatUsage(usage) {
|
|
20
|
+
const lines = [
|
|
21
|
+
`Database usage for ${usage.period}`,
|
|
22
|
+
`Compute: ${usage.dpuUsed.toLocaleString("en-US")} of ${usage.dpuCap.toLocaleString("en-US")} units used (${percent(usage.pctDpu)})`,
|
|
23
|
+
`Storage: ${formatBytes(usage.storageBytes)} of ${formatBytes(usage.storageCap)} used (${percent(usage.pctStorage)})`,
|
|
24
|
+
];
|
|
25
|
+
if (usage.measuredAt === null) {
|
|
26
|
+
lines.push("Not measured yet, so usage shows as 0. Check again in about 15 minutes.");
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
lines.push(`Last measured ${usage.measuredAt}. Numbers can lag by a few minutes.`);
|
|
30
|
+
}
|
|
31
|
+
const over = [
|
|
32
|
+
usage.pctDpu >= 100 ? "compute" : null,
|
|
33
|
+
usage.pctStorage >= 100 ? "storage" : null,
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
if (over.length > 0)
|
|
36
|
+
lines.push(`Over the cap for ${over.join(" and ")}.`);
|
|
37
|
+
else if (usage.pctDpu >= 80 || usage.pctStorage >= 80)
|
|
38
|
+
lines.push("Close to a cap (80% or more).");
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=usage-format.js.map
|
|
@@ -28,7 +28,9 @@ export declare function titleize(slug: string): string;
|
|
|
28
28
|
export declare function date(now?: Date): string;
|
|
29
29
|
export declare function timestamp(now?: Date): string;
|
|
30
30
|
/** Write via a same-directory temporary file, then atomically replace the destination. */
|
|
31
|
-
export declare function atomicWrite(file: string, content: string
|
|
31
|
+
export declare function atomicWrite(file: string, content: string | Buffer, options?: {
|
|
32
|
+
mode?: number;
|
|
33
|
+
}): void;
|
|
32
34
|
export declare function write(context: RenderContext, relative: string, content: string): string;
|
|
33
35
|
/** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
|
|
34
36
|
export declare function generatedFingerprint(content: string): string;
|
|
@@ -78,11 +78,14 @@ export function titleize(slug) {
|
|
|
78
78
|
export function date(now = new Date()) { return now.toISOString().slice(0, 10); }
|
|
79
79
|
export function timestamp(now = new Date()) { return now.toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
80
80
|
/** Write via a same-directory temporary file, then atomically replace the destination. */
|
|
81
|
-
export function atomicWrite(file, content) {
|
|
81
|
+
export function atomicWrite(file, content, options = {}) {
|
|
82
82
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
83
|
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
84
84
|
try {
|
|
85
|
-
|
|
85
|
+
if (typeof content === "string")
|
|
86
|
+
fs.writeFileSync(temporary, content, { encoding: "utf8", mode: options.mode });
|
|
87
|
+
else
|
|
88
|
+
fs.writeFileSync(temporary, content, { mode: options.mode });
|
|
86
89
|
fs.renameSync(temporary, file);
|
|
87
90
|
}
|
|
88
91
|
catch (error) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface LaneDepthCap {
|
|
2
|
+
max: number;
|
|
3
|
+
source: "default" | "company";
|
|
4
|
+
}
|
|
5
|
+
/** Read the optional maxLaneDepth from companies/<slug>/.hq/config.json. */
|
|
6
|
+
export declare function laneDepthCap(hqRoot: string, companySlug?: string | null): LaneDepthCap;
|
|
7
|
+
//# sourceMappingURL=depth-cap.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { LanesError } from "./errors.js";
|
|
4
|
+
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
5
|
+
/** Read the optional maxLaneDepth from companies/<slug>/.hq/config.json. */
|
|
6
|
+
export function laneDepthCap(hqRoot, companySlug) {
|
|
7
|
+
if (!companySlug)
|
|
8
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
9
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(companySlug)) {
|
|
10
|
+
throw new LanesError("lane_depth_config_invalid", `Invalid company slug for lane depth config: ${JSON.stringify(companySlug)}`, { company: companySlug });
|
|
11
|
+
}
|
|
12
|
+
const file = path.join(hqRoot, "companies", companySlug, ".hq", "config.json");
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = fs.readFileSync(file, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err.code === "ENOENT")
|
|
19
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
20
|
+
throw new LanesError("lane_depth_config_unreadable", `Failed to read company lane depth config ${file}: ${err instanceof Error ? err.message : String(err)}`, { company: companySlug, path: file });
|
|
21
|
+
}
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
throw new LanesError("lane_depth_config_invalid", `Invalid JSON in company config ${file}: ${err instanceof Error ? err.message : String(err)}`, { company: companySlug, path: file });
|
|
28
|
+
}
|
|
29
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
30
|
+
throw new LanesError("lane_depth_config_invalid", `Company config ${file} must be a JSON object`, { company: companySlug, path: file });
|
|
31
|
+
}
|
|
32
|
+
const value = parsed.maxLaneDepth;
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
35
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 8) {
|
|
36
|
+
process.stderr.write(`hq lanes: lane_depth_cap_invalid company=${companySlug} maxLaneDepth=${JSON.stringify(value)}; using default ${MAX_LANE_DEPTH}\n`);
|
|
37
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
38
|
+
}
|
|
39
|
+
return { max: value, source: "company" };
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=depth-cap.js.map
|
|
@@ -58,6 +58,18 @@ export declare function hasPendingKeepAliveWork(hqRoot: string, lane: Pick<LaneR
|
|
|
58
58
|
export declare function undeliveredAnsweredQuestions(lane: Pick<LaneRecord, "lane_id" | "company">, questions: readonly QuestionRecord[]): QuestionRecord[];
|
|
59
59
|
/** Missing registry means no retired lanes; every present invalid file fails closed. */
|
|
60
60
|
export declare function loadRetiredLaneIds(hqRoot: string): ReadonlySet<string>;
|
|
61
|
+
export interface RetiredLaneEntry {
|
|
62
|
+
lane: string;
|
|
63
|
+
index: number;
|
|
64
|
+
retired_at?: string;
|
|
65
|
+
}
|
|
66
|
+
/** Preserve registry metadata for diagnostics while validating the same shape as the eligibility reader. */
|
|
67
|
+
export declare function loadRetiredLaneEntries(hqRoot: string): RetiredLaneEntry[];
|
|
68
|
+
/** Return the matching registry row using the same bare/host-suffixed ID semantics as keepAliveExclusion. */
|
|
69
|
+
export declare function findRetiredLaneEntry(hqRoot: string, laneId: string): (RetiredLaneEntry & {
|
|
70
|
+
path: string;
|
|
71
|
+
}) | undefined;
|
|
72
|
+
export declare function matchesRetiredLaneEntry(entry: RetiredLaneEntry, laneId: string): boolean;
|
|
61
73
|
export declare function keepAliveSupersededBy<T extends KeepAliveLane>(lane: T, lanes: readonly T[], lineageAware?: boolean, companyForLane?: (lane: T) => T["company"]): T | undefined;
|
|
62
74
|
export declare function keepAliveExclusion<T extends KeepAliveLane>(lane: T, lanes: readonly T[], retiredLaneIds: ReadonlySet<string>, lineageAware?: boolean, companyForLane?: (lane: T) => T["company"]): "retired" | "superseded" | undefined;
|
|
63
75
|
export declare function leadLaneKeepAliveBackoffMs(attempt: number): number;
|