@hyperfixation/core 0.1.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/LICENSE +21 -0
- package/dist/activity.d.ts +73 -0
- package/dist/activity.js +84 -0
- package/dist/define-app.d.ts +171 -0
- package/dist/define-app.js +283 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +19 -0
- package/dist/labels.d.ts +41 -0
- package/dist/labels.js +62 -0
- package/dist/outcomes.d.ts +35 -0
- package/dist/outcomes.js +50 -0
- package/dist/pages.d.ts +11 -0
- package/dist/pages.js +1 -0
- package/dist/pause.d.ts +41 -0
- package/dist/pause.js +52 -0
- package/dist/records.d.ts +67 -0
- package/dist/records.js +111 -0
- package/dist/registry.d.ts +46 -0
- package/dist/registry.js +75 -0
- package/dist/resolution.d.ts +57 -0
- package/dist/resolution.js +221 -0
- package/dist/resolvers.d.ts +23 -0
- package/dist/resolvers.js +9 -0
- package/dist/schedules.d.ts +40 -0
- package/dist/schedules.js +32 -0
- package/dist/scorers.d.ts +17 -0
- package/dist/scorers.js +3 -0
- package/dist/scores.d.ts +80 -0
- package/dist/scores.js +113 -0
- package/dist/sources.d.ts +13 -0
- package/dist/sources.js +3 -0
- package/dist/specs.d.ts +12 -0
- package/dist/specs.js +7 -0
- package/dist/status-route.d.ts +29 -0
- package/dist/status-route.js +61 -0
- package/dist/status-token.d.ts +19 -0
- package/dist/status-token.js +40 -0
- package/dist/status.d.ts +45 -0
- package/dist/status.js +99 -0
- package/dist/step-client.d.ts +8 -0
- package/dist/step-client.js +8 -0
- package/dist/tasks.d.ts +96 -0
- package/dist/tasks.js +151 -0
- package/dist/workspace-views.d.ts +143 -0
- package/dist/workspace-views.js +0 -0
- package/dist/workspace.d.ts +91 -0
- package/dist/workspace.js +136 -0
- package/package.json +52 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import type { StatusOptions, StatusReport } from "./status.js";
|
|
3
|
+
/** The three routes, relative to wherever the app mounts them. */
|
|
4
|
+
export type StatusRoute = "status" | "pause" | "resume";
|
|
5
|
+
/** `hf_app_state.paused_by` and the audit row's actor when the write token did it. */
|
|
6
|
+
export declare const STATUS_TOKEN_ACTOR = "status-token";
|
|
7
|
+
export interface StatusRouteHandlers {
|
|
8
|
+
status(): Promise<StatusReport>;
|
|
9
|
+
pause(): Promise<unknown>;
|
|
10
|
+
resume(): Promise<unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface StatusHandlerOptions extends StatusOptions {
|
|
13
|
+
pool: Pool;
|
|
14
|
+
handlers: StatusRouteHandlers;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Matched on the suffix rather than on a fixed prefix: the app owns where it mounts these, and
|
|
18
|
+
* the template's `app/api/status/route.ts` is only the default.
|
|
19
|
+
*/
|
|
20
|
+
export declare function statusRouteOf(pathname: string): StatusRoute | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* `GET /api/status` under the read token; `POST /api/status/pause` and `/resume` under the
|
|
23
|
+
* write token. A write token reads too — it is strictly the more privileged of the two, and a
|
|
24
|
+
* deploy check that had to carry both would be two secrets where one will do.
|
|
25
|
+
*
|
|
26
|
+
* Every refusal answers the same way whether the token was wrong, missing or unconfigured, and
|
|
27
|
+
* the comparison itself is `timingSafeEqual` against the stored hash.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createStatusHandler(options: StatusHandlerOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { bearerToken, statusTokenMatches } from "./status-token.js";
|
|
2
|
+
/** `hf_app_state.paused_by` and the audit row's actor when the write token did it. */
|
|
3
|
+
export const STATUS_TOKEN_ACTOR = "status-token";
|
|
4
|
+
const TOKEN_HASHES_STATEMENT = "SELECT read_token_hash, write_token_hash FROM hf_app_state WHERE id = 1";
|
|
5
|
+
/**
|
|
6
|
+
* Matched on the suffix rather than on a fixed prefix: the app owns where it mounts these, and
|
|
7
|
+
* the template's `app/api/status/route.ts` is only the default.
|
|
8
|
+
*/
|
|
9
|
+
export function statusRouteOf(pathname) {
|
|
10
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
11
|
+
if (trimmed.endsWith("/status"))
|
|
12
|
+
return "status";
|
|
13
|
+
if (trimmed.endsWith("/status/pause"))
|
|
14
|
+
return "pause";
|
|
15
|
+
if (trimmed.endsWith("/status/resume"))
|
|
16
|
+
return "resume";
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `GET /api/status` under the read token; `POST /api/status/pause` and `/resume` under the
|
|
21
|
+
* write token. A write token reads too — it is strictly the more privileged of the two, and a
|
|
22
|
+
* deploy check that had to carry both would be two secrets where one will do.
|
|
23
|
+
*
|
|
24
|
+
* Every refusal answers the same way whether the token was wrong, missing or unconfigured, and
|
|
25
|
+
* the comparison itself is `timingSafeEqual` against the stored hash.
|
|
26
|
+
*/
|
|
27
|
+
export function createStatusHandler(options) {
|
|
28
|
+
return async (request) => {
|
|
29
|
+
const route = statusRouteOf(new URL(request.url).pathname);
|
|
30
|
+
if (route === undefined)
|
|
31
|
+
return json({ error: "not found" }, 404);
|
|
32
|
+
const method = route === "status" ? "GET" : "POST";
|
|
33
|
+
if (request.method !== method) {
|
|
34
|
+
return json({ error: `${route} is ${method}` }, 405, { allow: method });
|
|
35
|
+
}
|
|
36
|
+
if (!(await authorized(options.pool, request, route))) {
|
|
37
|
+
return json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
|
|
38
|
+
}
|
|
39
|
+
if (route === "status")
|
|
40
|
+
return json(await options.handlers.status(), 200);
|
|
41
|
+
const result = route === "pause" ? await options.handlers.pause() : await options.handlers.resume();
|
|
42
|
+
return json(result, 200);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function authorized(pool, request, route) {
|
|
46
|
+
const presented = bearerToken(request);
|
|
47
|
+
const { rows } = await pool.query(TOKEN_HASHES_STATEMENT);
|
|
48
|
+
const hashes = rows[0];
|
|
49
|
+
// Both comparisons run on a read so that a valid read token and a valid write token take the
|
|
50
|
+
// same path; neither short-circuits on the other's result.
|
|
51
|
+
const write = statusTokenMatches(presented, hashes?.write_token_hash);
|
|
52
|
+
if (route !== "status")
|
|
53
|
+
return write;
|
|
54
|
+
return statusTokenMatches(presented, hashes?.read_token_hash) || write;
|
|
55
|
+
}
|
|
56
|
+
function json(body, status, headers = {}) {
|
|
57
|
+
return new Response(JSON.stringify(body), {
|
|
58
|
+
status,
|
|
59
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", ...headers },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** What `hf_app_state.read_token_hash` and `write_token_hash` hold. */
|
|
2
|
+
export declare const STATUS_TOKEN_DIGEST = "sha256";
|
|
3
|
+
/**
|
|
4
|
+
* The stored form of a status token. Hashing is not only about the database: it also makes the
|
|
5
|
+
* comparison fixed-width, which is what lets `timingSafeEqual` — which throws on a length
|
|
6
|
+
* mismatch — be used on a value an attacker chooses the length of.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hashStatusToken(token: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Constant-time against the stored hash. Every "no" that is not about the bytes — no token
|
|
11
|
+
* presented, no hash configured, a hash that is not a `sha256` digest — is answered before the
|
|
12
|
+
* comparison and is not timing-sensitive: none of them depends on the presented token.
|
|
13
|
+
*
|
|
14
|
+
* An app with no hash configured is refused rather than opened: an unset token is a
|
|
15
|
+
* misconfiguration, and the status endpoint can pause the app.
|
|
16
|
+
*/
|
|
17
|
+
export declare function statusTokenMatches(presented: string | null | undefined, storedHash: string | null | undefined): boolean;
|
|
18
|
+
/** `Authorization: Bearer <token>`, and nothing else — a token in a query string is logged. */
|
|
19
|
+
export declare function bearerToken(request: Request): string | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/** What `hf_app_state.read_token_hash` and `write_token_hash` hold. */
|
|
3
|
+
export const STATUS_TOKEN_DIGEST = "sha256";
|
|
4
|
+
const DIGEST_BYTES = 32;
|
|
5
|
+
/**
|
|
6
|
+
* The stored form of a status token. Hashing is not only about the database: it also makes the
|
|
7
|
+
* comparison fixed-width, which is what lets `timingSafeEqual` — which throws on a length
|
|
8
|
+
* mismatch — be used on a value an attacker chooses the length of.
|
|
9
|
+
*/
|
|
10
|
+
export function hashStatusToken(token) {
|
|
11
|
+
return createHash(STATUS_TOKEN_DIGEST).update(token, "utf8").digest("hex");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Constant-time against the stored hash. Every "no" that is not about the bytes — no token
|
|
15
|
+
* presented, no hash configured, a hash that is not a `sha256` digest — is answered before the
|
|
16
|
+
* comparison and is not timing-sensitive: none of them depends on the presented token.
|
|
17
|
+
*
|
|
18
|
+
* An app with no hash configured is refused rather than opened: an unset token is a
|
|
19
|
+
* misconfiguration, and the status endpoint can pause the app.
|
|
20
|
+
*/
|
|
21
|
+
export function statusTokenMatches(presented, storedHash) {
|
|
22
|
+
if (presented === null || presented === undefined || presented === "")
|
|
23
|
+
return false;
|
|
24
|
+
if (storedHash === null || storedHash === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const stored = Buffer.from(storedHash, "hex");
|
|
27
|
+
// `Buffer.from` truncates at the first non-hex character rather than throwing, so a mangled
|
|
28
|
+
// hash arrives here short; nothing of digest length can be produced that way by accident.
|
|
29
|
+
if (stored.length !== DIGEST_BYTES)
|
|
30
|
+
return false;
|
|
31
|
+
return timingSafeEqual(createHash(STATUS_TOKEN_DIGEST).update(presented, "utf8").digest(), stored);
|
|
32
|
+
}
|
|
33
|
+
/** `Authorization: Bearer <token>`, and nothing else — a token in a query string is logged. */
|
|
34
|
+
export function bearerToken(request) {
|
|
35
|
+
const header = request.headers.get("authorization");
|
|
36
|
+
if (header === null)
|
|
37
|
+
return null;
|
|
38
|
+
const match = /^Bearer (.+)$/i.exec(header.trim());
|
|
39
|
+
return match === null ? null : match[1];
|
|
40
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RunStatus } from "@hyperfixation/db";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
/** This package's own version, which `/api/status` reports as the core the app runs. */
|
|
4
|
+
export declare const CORE_VERSION: string;
|
|
5
|
+
export interface QueueStatus {
|
|
6
|
+
name: string;
|
|
7
|
+
/** `null` when the queue is registered with no global limit. */
|
|
8
|
+
globalConcurrency: number | null;
|
|
9
|
+
enqueued: number;
|
|
10
|
+
running: number;
|
|
11
|
+
}
|
|
12
|
+
export interface PeriodStatus {
|
|
13
|
+
period: string;
|
|
14
|
+
budgetUsd: string;
|
|
15
|
+
spentUsd: string;
|
|
16
|
+
/** `SUM(cost_usd)` of the period's `ok` rows, for the drift below. */
|
|
17
|
+
ledgerUsd: string;
|
|
18
|
+
driftUsd: string;
|
|
19
|
+
}
|
|
20
|
+
export interface StatusReport {
|
|
21
|
+
/** `degraded` when there are anomalies or any period's spend disagrees with its ledger. */
|
|
22
|
+
health: "ok" | "degraded";
|
|
23
|
+
app: string;
|
|
24
|
+
applicationVersion: string | null;
|
|
25
|
+
coreVersion: string;
|
|
26
|
+
paused: boolean;
|
|
27
|
+
pausedBy: string | null;
|
|
28
|
+
runs: Record<RunStatus, number>;
|
|
29
|
+
queues: QueueStatus[];
|
|
30
|
+
approvals: Record<string, number>;
|
|
31
|
+
llmCalls: Record<string, number>;
|
|
32
|
+
actions: Record<string, number>;
|
|
33
|
+
budget: {
|
|
34
|
+
current: PeriodStatus | null;
|
|
35
|
+
previous: PeriodStatus | null;
|
|
36
|
+
};
|
|
37
|
+
anomalies: number;
|
|
38
|
+
at: string;
|
|
39
|
+
}
|
|
40
|
+
export interface StatusOptions {
|
|
41
|
+
app: string;
|
|
42
|
+
applicationVersion: string | null;
|
|
43
|
+
}
|
|
44
|
+
/** What `GET /api/status` answers with, and what `hf doctor` reads. */
|
|
45
|
+
export declare function appStatus(pool: Pool, options: StatusOptions): Promise<StatusReport>;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
/** This package's own version, which `/api/status` reports as the core the app runs. */
|
|
3
|
+
export const CORE_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
4
|
+
/**
|
|
5
|
+
* Every read here is a plain `SELECT` and none of them locks anything — in particular not a
|
|
6
|
+
* budget row, which is the one thing the lock order forbids any of these paths to take.
|
|
7
|
+
*/
|
|
8
|
+
const STATE_STATEMENT = "SELECT paused, paused_by, budget_usd::text AS budget_usd FROM hf_app_state WHERE id = 1";
|
|
9
|
+
const PERIODS_STATEMENT = "SELECT to_char(date_trunc('month', now() AT TIME ZONE 'UTC'), 'YYYY-MM') AS current_period, " +
|
|
10
|
+
"to_char(date_trunc('month', now() AT TIME ZONE 'UTC') - interval '1 month', 'YYYY-MM') " +
|
|
11
|
+
"AS previous_period";
|
|
12
|
+
const RUN_COUNTS_STATEMENT = "SELECT status, count(*)::int AS n FROM hf_run GROUP BY status";
|
|
13
|
+
const APPROVAL_COUNTS_STATEMENT = "SELECT status, count(*)::int AS n FROM hf_approval GROUP BY status";
|
|
14
|
+
const LEDGER_COUNTS_STATEMENT = "SELECT status, count(*)::int AS n FROM hf_llm_call GROUP BY status";
|
|
15
|
+
const ACTION_COUNTS_STATEMENT = "SELECT status, count(*)::int AS n FROM hf_action_log GROUP BY status";
|
|
16
|
+
/**
|
|
17
|
+
* Queue depth from the workflow rows rather than from any counter: `ENQUEUED` is waiting and
|
|
18
|
+
* `PENDING` is dispatched, and a queue whose concurrency is zero shows the backlog a pause is
|
|
19
|
+
* holding back. Joined against `dbos.queues` so a queue with nothing on it still appears.
|
|
20
|
+
*/
|
|
21
|
+
const QUEUES_STATEMENT = "SELECT q.name, COALESCE(q.concurrency, -1)::int AS global_concurrency, " +
|
|
22
|
+
"COALESCE(SUM((w.status = 'ENQUEUED')::int), 0)::int AS enqueued, " +
|
|
23
|
+
"COALESCE(SUM((w.status = 'PENDING')::int), 0)::int AS running " +
|
|
24
|
+
"FROM dbos.queues q LEFT JOIN dbos.workflow_status w ON w.queue_name = q.name " +
|
|
25
|
+
"AND w.status IN ('ENQUEUED', 'PENDING') GROUP BY q.name, q.concurrency ORDER BY q.name";
|
|
26
|
+
/**
|
|
27
|
+
* Per-period spend against budget, with the exact drift `reconcile()` reports and never
|
|
28
|
+
* corrects: a call is billed to the period stamped on its own row, so this comparison is not
|
|
29
|
+
* an estimate.
|
|
30
|
+
*/
|
|
31
|
+
const BUDGET_STATEMENT = "SELECT b.period, b.budget_usd::text AS budget_usd, b.spent_usd::text AS spent_usd, " +
|
|
32
|
+
"COALESCE((SELECT SUM(l.cost_usd) FROM hf_llm_call l " +
|
|
33
|
+
"WHERE l.period = b.period AND l.status = 'ok'), 0)::text AS ledger_usd " +
|
|
34
|
+
"FROM hf_budget_period b WHERE b.period = ANY($1::text[])";
|
|
35
|
+
/**
|
|
36
|
+
* `reconcile()`'s step (1) invariant violation, counted live rather than accumulated: a
|
|
37
|
+
* `running` run with no `dbos.workflow_status` row at all cannot happen, because every path
|
|
38
|
+
* that writes `current_workflow_id` enqueues in the same transaction.
|
|
39
|
+
*/
|
|
40
|
+
const ANOMALIES_STATEMENT = "SELECT count(*)::int AS n FROM hf_run r " +
|
|
41
|
+
"LEFT JOIN dbos.workflow_status w ON w.workflow_uuid = r.current_workflow_id " +
|
|
42
|
+
"WHERE r.status = 'running' AND w.workflow_uuid IS NULL";
|
|
43
|
+
const RUN_STATUSES = ["running", "waiting", "paused", "done", "failed"];
|
|
44
|
+
/** What `GET /api/status` answers with, and what `hf doctor` reads. */
|
|
45
|
+
export async function appStatus(pool, options) {
|
|
46
|
+
const [state, periods, runs, approvals, llmCalls, actions, queues, anomalies] = await Promise.all([
|
|
47
|
+
pool.query(STATE_STATEMENT),
|
|
48
|
+
pool.query(PERIODS_STATEMENT),
|
|
49
|
+
counts(pool, RUN_COUNTS_STATEMENT),
|
|
50
|
+
counts(pool, APPROVAL_COUNTS_STATEMENT),
|
|
51
|
+
counts(pool, LEDGER_COUNTS_STATEMENT),
|
|
52
|
+
counts(pool, ACTION_COUNTS_STATEMENT),
|
|
53
|
+
pool.query(QUEUES_STATEMENT),
|
|
54
|
+
pool.query(ANOMALIES_STATEMENT),
|
|
55
|
+
]);
|
|
56
|
+
const { current_period: current, previous_period: previous } = periods.rows[0];
|
|
57
|
+
const budget = await pool.query(BUDGET_STATEMENT, [[current, previous]]);
|
|
58
|
+
const periodStatus = (period) => {
|
|
59
|
+
const row = budget.rows.find((candidate) => candidate.period === period);
|
|
60
|
+
if (row === undefined)
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
period: row.period,
|
|
64
|
+
budgetUsd: row.budget_usd,
|
|
65
|
+
spentUsd: row.spent_usd,
|
|
66
|
+
ledgerUsd: row.ledger_usd,
|
|
67
|
+
driftUsd: (Number(row.spent_usd) - Number(row.ledger_usd)).toFixed(6),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
const budgetStatus = { current: periodStatus(current), previous: periodStatus(previous) };
|
|
71
|
+
const drifting = [budgetStatus.current, budgetStatus.previous].some((period) => period !== null && Number(period.driftUsd) !== 0);
|
|
72
|
+
const anomalyCount = anomalies.rows[0].n;
|
|
73
|
+
return {
|
|
74
|
+
health: anomalyCount === 0 && !drifting ? "ok" : "degraded",
|
|
75
|
+
app: options.app,
|
|
76
|
+
applicationVersion: options.applicationVersion,
|
|
77
|
+
coreVersion: CORE_VERSION,
|
|
78
|
+
paused: state.rows[0]?.paused ?? false,
|
|
79
|
+
pausedBy: state.rows[0]?.paused_by ?? null,
|
|
80
|
+
runs: Object.fromEntries(RUN_STATUSES.map((status) => [status, runs[status] ?? 0])),
|
|
81
|
+
queues: queues.rows.map((row) => ({
|
|
82
|
+
name: row.name,
|
|
83
|
+
// `-1` is the COALESCE above standing in for a NULL `concurrency`, which is "no limit".
|
|
84
|
+
globalConcurrency: row.global_concurrency < 0 ? null : row.global_concurrency,
|
|
85
|
+
enqueued: row.enqueued,
|
|
86
|
+
running: row.running,
|
|
87
|
+
})),
|
|
88
|
+
approvals,
|
|
89
|
+
llmCalls,
|
|
90
|
+
actions,
|
|
91
|
+
budget: budgetStatus,
|
|
92
|
+
anomalies: anomalyCount,
|
|
93
|
+
at: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function counts(pool, statement) {
|
|
97
|
+
const { rows } = await pool.query(statement);
|
|
98
|
+
return Object.fromEntries(rows.map((row) => [row.status, row.n]));
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StepDatabase } from "@hyperfixation/db";
|
|
2
|
+
import type { ClientBase } from "pg";
|
|
3
|
+
/**
|
|
4
|
+
* The open, fenced client behind a `ctx.tx` handle. `StepDatabase`'s type omits `$client`, so
|
|
5
|
+
* the cast is how the step-side helpers issue the positional-parameter SQL the rest of this
|
|
6
|
+
* package is written in — `loader.ts` reaches for it the same way.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stepClient(db: StepDatabase): ClientBase;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open, fenced client behind a `ctx.tx` handle. `StepDatabase`'s type omits `$client`, so
|
|
3
|
+
* the cast is how the step-side helpers issue the positional-parameter SQL the rest of this
|
|
4
|
+
* package is written in — `loader.ts` reaches for it the same way.
|
|
5
|
+
*/
|
|
6
|
+
export function stepClient(db) {
|
|
7
|
+
return db.$client;
|
|
8
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type RecordTable } from "@hyperfixation/db";
|
|
2
|
+
import type { StepContext } from "@hyperfixation/workflows";
|
|
3
|
+
import type { ClientBase, Pool } from "pg";
|
|
4
|
+
import type { Registry } from "./registry.js";
|
|
5
|
+
export declare const TASK_CREATE_MANUAL_OPERATION = "tasks.createManual";
|
|
6
|
+
export declare const TASK_COMPLETE_OPERATION = "tasks.complete";
|
|
7
|
+
export declare const TASK_CANCEL_OPERATION = "tasks.cancel";
|
|
8
|
+
export declare const TASK_LIST_OPERATION = "tasks.list";
|
|
9
|
+
/** The columns `taskRowOf` maps, shared with the workspace's own `hf_task` reads. */
|
|
10
|
+
export declare const TASK_COLUMNS = "id, record_type, record_id, title, due_at, owner_id, done_at, cancelled_at, origin, created_at";
|
|
11
|
+
export interface TaskTarget {
|
|
12
|
+
recordType?: string;
|
|
13
|
+
recordId?: string | number;
|
|
14
|
+
}
|
|
15
|
+
export interface TaskCreateOptions extends TaskTarget {
|
|
16
|
+
title: string;
|
|
17
|
+
dueAt?: Date;
|
|
18
|
+
ownerId?: string;
|
|
19
|
+
/** Distinguishes two tasks one step opens; defaults to the step's key. */
|
|
20
|
+
key?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface TaskCreateManualOptions extends TaskTarget {
|
|
23
|
+
title: string;
|
|
24
|
+
dueAt?: Date;
|
|
25
|
+
ownerId?: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TaskCreated {
|
|
29
|
+
id: number;
|
|
30
|
+
/** False when a replay found the task its own `origin_ref` had already opened. */
|
|
31
|
+
created: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface TaskCloseOptions {
|
|
34
|
+
id: number;
|
|
35
|
+
userId?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface TaskClosed {
|
|
38
|
+
id: number;
|
|
39
|
+
/** False when the task was already done or cancelled; nothing was written. */
|
|
40
|
+
changed: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface TaskListOptions {
|
|
43
|
+
recordType?: string;
|
|
44
|
+
recordId?: string | number;
|
|
45
|
+
ownerId?: string;
|
|
46
|
+
/** True for tasks neither done nor cancelled, false for the rest, omitted for both. */
|
|
47
|
+
open?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface TaskRow {
|
|
50
|
+
id: number;
|
|
51
|
+
recordType: string | null;
|
|
52
|
+
recordId: string | null;
|
|
53
|
+
title: string;
|
|
54
|
+
dueAt: Date | null;
|
|
55
|
+
ownerId: string | null;
|
|
56
|
+
doneAt: Date | null;
|
|
57
|
+
cancelledAt: Date | null;
|
|
58
|
+
origin: string;
|
|
59
|
+
createdAt: Date;
|
|
60
|
+
}
|
|
61
|
+
export interface TaskQueryRow {
|
|
62
|
+
id: string;
|
|
63
|
+
record_type: string | null;
|
|
64
|
+
record_id: string | null;
|
|
65
|
+
title: string;
|
|
66
|
+
due_at: Date | null;
|
|
67
|
+
owner_id: string | null;
|
|
68
|
+
done_at: Date | null;
|
|
69
|
+
cancelled_at: Date | null;
|
|
70
|
+
origin: string;
|
|
71
|
+
created_at: Date;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The colon keeps a flow task's `origin_ref` disjoint from `actions.perform`'s, which is the bare
|
|
75
|
+
* `hf_action_log` id on the same partial unique index.
|
|
76
|
+
*/
|
|
77
|
+
export declare function flowOriginRef(runId: string, key: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* A task a flow opens, inside `ctx.tx`. Keyed by `(run_id, step key)` through `origin_ref`, so
|
|
80
|
+
* attempt 2 re-running the step finds its own task rather than opening a second one.
|
|
81
|
+
*
|
|
82
|
+
* The record type is checked against the registry here rather than at the next boot: a task
|
|
83
|
+
* carrying a `record_type` no app registers is what E002 refuses.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createTask(ctx: StepContext, records: Registry<RecordTable>, options: TaskCreateOptions): Promise<TaskCreated>;
|
|
86
|
+
/** A task a human opens from the workspace: no run, no `origin_ref`, so no replay to survive. */
|
|
87
|
+
export declare function createManualTask(pool: Pool, records: Registry<RecordTable>, options: TaskCreateManualOptions): Promise<TaskCreated>;
|
|
88
|
+
export declare function completeTask(pool: Pool, options: TaskCloseOptions): Promise<TaskClosed>;
|
|
89
|
+
export declare function cancelTask(pool: Pool, options: TaskCloseOptions): Promise<TaskClosed>;
|
|
90
|
+
export declare function listTasks(pool: Pool, options?: TaskListOptions): Promise<TaskRow[]>;
|
|
91
|
+
export declare function taskRowOf(row: TaskQueryRow): TaskRow;
|
|
92
|
+
/**
|
|
93
|
+
* Every open task on a record, cancelled in one statement. Called from inside
|
|
94
|
+
* `records.archive()`'s transaction, which already holds the record's own row.
|
|
95
|
+
*/
|
|
96
|
+
export declare function cancelOpenTasksForRecord(queryable: Pool | ClientBase, recordType: string, recordId: string): Promise<number[]>;
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { assertNotInWorkflow, controlPlaneTx } from "@hyperfixation/db";
|
|
2
|
+
import { insertActivity } from "./activity.js";
|
|
3
|
+
import { stepClient } from "./step-client.js";
|
|
4
|
+
export const TASK_CREATE_MANUAL_OPERATION = "tasks.createManual";
|
|
5
|
+
export const TASK_COMPLETE_OPERATION = "tasks.complete";
|
|
6
|
+
export const TASK_CANCEL_OPERATION = "tasks.cancel";
|
|
7
|
+
export const TASK_LIST_OPERATION = "tasks.list";
|
|
8
|
+
const INSERT_TASK_STATEMENT = "INSERT INTO hf_task (record_type, record_id, title, due_at, owner_id, origin, origin_ref) " +
|
|
9
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7) " +
|
|
10
|
+
"ON CONFLICT (origin, origin_ref) WHERE origin_ref IS NOT NULL DO NOTHING RETURNING id";
|
|
11
|
+
const EXISTING_TASK_STATEMENT = "SELECT id FROM hf_task WHERE origin = $1 AND origin_ref = $2 ORDER BY id LIMIT 1";
|
|
12
|
+
/** Only an open task closes; a second call changes nothing and writes no activity row. */
|
|
13
|
+
const CLOSE_TASK_STATEMENT = (column) => `UPDATE hf_task SET ${column} = now() WHERE id = $1 AND done_at IS NULL AND cancelled_at IS NULL ` +
|
|
14
|
+
"RETURNING record_type, record_id";
|
|
15
|
+
const CANCEL_OPEN_TASKS_STATEMENT = "UPDATE hf_task SET cancelled_at = now() WHERE record_type = $1 AND record_id = $2 " +
|
|
16
|
+
"AND done_at IS NULL AND cancelled_at IS NULL RETURNING id";
|
|
17
|
+
/** The columns `taskRowOf` maps, shared with the workspace's own `hf_task` reads. */
|
|
18
|
+
export const TASK_COLUMNS = "id, record_type, record_id, title, due_at, owner_id, done_at, cancelled_at, origin, created_at";
|
|
19
|
+
// One statement for every filter combination: a null parameter is "no filter", which keeps the
|
|
20
|
+
// shape of the query — and so its plan — the same however the workspace calls it.
|
|
21
|
+
const LIST_TASKS_STATEMENT = `SELECT ${TASK_COLUMNS} FROM hf_task WHERE ($1::text IS NULL OR record_type = $1) ` +
|
|
22
|
+
"AND ($2::text IS NULL OR record_id = $2) AND ($3::text IS NULL OR owner_id = $3) " +
|
|
23
|
+
"AND ($4::boolean IS NULL OR (done_at IS NULL AND cancelled_at IS NULL) = $4) ORDER BY id";
|
|
24
|
+
/**
|
|
25
|
+
* The colon keeps a flow task's `origin_ref` disjoint from `actions.perform`'s, which is the bare
|
|
26
|
+
* `hf_action_log` id on the same partial unique index.
|
|
27
|
+
*/
|
|
28
|
+
export function flowOriginRef(runId, key) {
|
|
29
|
+
return `${runId}:${key}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A task a flow opens, inside `ctx.tx`. Keyed by `(run_id, step key)` through `origin_ref`, so
|
|
33
|
+
* attempt 2 re-running the step finds its own task rather than opening a second one.
|
|
34
|
+
*
|
|
35
|
+
* The record type is checked against the registry here rather than at the next boot: a task
|
|
36
|
+
* carrying a `record_type` no app registers is what E002 refuses.
|
|
37
|
+
*/
|
|
38
|
+
export async function createTask(ctx, records, options) {
|
|
39
|
+
const target = requireTarget(records, options);
|
|
40
|
+
const key = options.key ?? ctx.key;
|
|
41
|
+
const originRef = flowOriginRef(ctx.runId, key);
|
|
42
|
+
return ctx.tx(async (db) => {
|
|
43
|
+
const client = stepClient(db);
|
|
44
|
+
const task = await insertTask(client, options, "flow", originRef);
|
|
45
|
+
// Written whether or not the insert conflicted: its own key is what makes it once-only.
|
|
46
|
+
await insertActivity(client, {
|
|
47
|
+
...target,
|
|
48
|
+
kind: "task.created",
|
|
49
|
+
runId: ctx.runId,
|
|
50
|
+
key: `${key}:task.created`,
|
|
51
|
+
meta: { taskId: task.id, origin: "flow", originRef, title: options.title },
|
|
52
|
+
});
|
|
53
|
+
return task;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** A task a human opens from the workspace: no run, no `origin_ref`, so no replay to survive. */
|
|
57
|
+
export async function createManualTask(pool, records, options) {
|
|
58
|
+
const target = requireTarget(records, options);
|
|
59
|
+
return controlPlaneTx(pool, { operation: TASK_CREATE_MANUAL_OPERATION }, async (work) => {
|
|
60
|
+
const task = await insertTask(work, options, "manual", null);
|
|
61
|
+
await insertActivity(work, {
|
|
62
|
+
...target,
|
|
63
|
+
kind: "task.created",
|
|
64
|
+
actorId: options.userId ?? null,
|
|
65
|
+
meta: { taskId: task.id, origin: "manual", title: options.title },
|
|
66
|
+
});
|
|
67
|
+
return task;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function completeTask(pool, options) {
|
|
71
|
+
return closeTask(pool, options, TASK_COMPLETE_OPERATION, "done_at", "task.completed");
|
|
72
|
+
}
|
|
73
|
+
export function cancelTask(pool, options) {
|
|
74
|
+
return closeTask(pool, options, TASK_CANCEL_OPERATION, "cancelled_at", "task.cancelled");
|
|
75
|
+
}
|
|
76
|
+
export async function listTasks(pool, options = {}) {
|
|
77
|
+
assertNotInWorkflow(TASK_LIST_OPERATION);
|
|
78
|
+
const { rows } = await pool.query(LIST_TASKS_STATEMENT, [
|
|
79
|
+
options.recordType ?? null,
|
|
80
|
+
options.recordId === undefined ? null : String(options.recordId),
|
|
81
|
+
options.ownerId ?? null,
|
|
82
|
+
options.open ?? null,
|
|
83
|
+
]);
|
|
84
|
+
return rows.map(taskRowOf);
|
|
85
|
+
}
|
|
86
|
+
export function taskRowOf(row) {
|
|
87
|
+
return {
|
|
88
|
+
id: Number(row.id),
|
|
89
|
+
recordType: row.record_type,
|
|
90
|
+
recordId: row.record_id,
|
|
91
|
+
title: row.title,
|
|
92
|
+
dueAt: row.due_at,
|
|
93
|
+
ownerId: row.owner_id,
|
|
94
|
+
doneAt: row.done_at,
|
|
95
|
+
cancelledAt: row.cancelled_at,
|
|
96
|
+
origin: row.origin,
|
|
97
|
+
createdAt: row.created_at,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Every open task on a record, cancelled in one statement. Called from inside
|
|
102
|
+
* `records.archive()`'s transaction, which already holds the record's own row.
|
|
103
|
+
*/
|
|
104
|
+
export async function cancelOpenTasksForRecord(queryable, recordType, recordId) {
|
|
105
|
+
const { rows } = await queryable.query(CANCEL_OPEN_TASKS_STATEMENT, [
|
|
106
|
+
recordType,
|
|
107
|
+
recordId,
|
|
108
|
+
]);
|
|
109
|
+
return rows.map((row) => Number(row.id));
|
|
110
|
+
}
|
|
111
|
+
async function closeTask(pool, options, operation, column, kind) {
|
|
112
|
+
return controlPlaneTx(pool, { operation }, async (work) => {
|
|
113
|
+
const closed = await work.query(CLOSE_TASK_STATEMENT(column), [options.id]);
|
|
114
|
+
const row = closed.rows[0];
|
|
115
|
+
if (row === undefined)
|
|
116
|
+
return { id: options.id, changed: false };
|
|
117
|
+
await insertActivity(work, {
|
|
118
|
+
recordType: row.record_type,
|
|
119
|
+
recordId: row.record_id,
|
|
120
|
+
kind,
|
|
121
|
+
actorId: options.userId ?? null,
|
|
122
|
+
meta: { taskId: options.id },
|
|
123
|
+
});
|
|
124
|
+
return { id: options.id, changed: true };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function insertTask(queryable, options, origin, originRef) {
|
|
128
|
+
const inserted = await queryable.query(INSERT_TASK_STATEMENT, [
|
|
129
|
+
options.recordType ?? null,
|
|
130
|
+
options.recordId === undefined ? null : String(options.recordId),
|
|
131
|
+
options.title,
|
|
132
|
+
options.dueAt ?? null,
|
|
133
|
+
options.ownerId ?? null,
|
|
134
|
+
origin,
|
|
135
|
+
originRef,
|
|
136
|
+
]);
|
|
137
|
+
if (inserted.rows[0] !== undefined)
|
|
138
|
+
return { id: Number(inserted.rows[0].id), created: true };
|
|
139
|
+
const found = await queryable.query(EXISTING_TASK_STATEMENT, [origin, originRef]);
|
|
140
|
+
return { id: Number(found.rows[0].id), created: false };
|
|
141
|
+
}
|
|
142
|
+
/** A task may carry no record; one that names a type must name a registered one. */
|
|
143
|
+
function requireTarget(records, options) {
|
|
144
|
+
if (options.recordType === undefined)
|
|
145
|
+
return {};
|
|
146
|
+
records.require(options.recordType);
|
|
147
|
+
return {
|
|
148
|
+
recordType: options.recordType,
|
|
149
|
+
...(options.recordId === undefined ? {} : { recordId: String(options.recordId) }),
|
|
150
|
+
};
|
|
151
|
+
}
|