@sleep2agi/commhub-server 0.9.0-preview.27 → 0.9.0-preview.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bin/commhub.ts +4 -0
  2. package/package.json +4 -4
  3. package/src/ack-create-request-transport.test.ts +159 -0
  4. package/src/ack-create-request.test.ts +5 -5
  5. package/src/admin-networks-http.test.ts +84 -0
  6. package/src/api-host-supervisors-fallback.test.ts +1 -6
  7. package/src/auth.ts +88 -14
  8. package/src/dashboard-slash-routing-http.test.ts +103 -0
  9. package/src/db-adapter-guard.test.ts +85 -0
  10. package/src/db-adapter.ts +64 -23
  11. package/src/db.ts +279 -32
  12. package/src/ext-token-shared.test.ts +60 -0
  13. package/src/ext-validation-regression.test.ts +10 -17
  14. package/src/external-schedule-edits-http.test.ts +316 -0
  15. package/src/external-schedule-edits.ts +314 -0
  16. package/src/file-download-authz-dev-open.test.ts +1 -1
  17. package/src/file-download-authz.test.ts +21 -1
  18. package/src/file-download-date-bucket.test.ts +1 -1
  19. package/src/file-network-scope-dev-open.test.ts +1 -1
  20. package/src/file-network-scope.test.ts +1 -1
  21. package/src/health-redaction.test.ts +12 -0
  22. package/src/index.ts +6 -2
  23. package/src/network-name-http.test.ts +71 -0
  24. package/src/network-scope.ts +20 -0
  25. package/src/node-attrs.test.ts +1 -1
  26. package/src/peer-reply-atomic.test.ts +455 -0
  27. package/src/peer-reply-capability.test.ts +70 -0
  28. package/src/require-explicit-test-db.ts +9 -0
  29. package/src/rest-explicit-columns-http.test.ts +149 -0
  30. package/src/rest-identity.test.ts +120 -0
  31. package/src/rest-identity.ts +93 -0
  32. package/src/rest-projections.ts +67 -0
  33. package/src/scheduled-run-terminal.test.ts +268 -0
  34. package/src/scheduled-tasks-http.test.ts +460 -0
  35. package/src/scheduled-tasks.ts +538 -0
  36. package/src/send-reply-agent-warning.test.ts +38 -38
  37. package/src/server.ts +315 -60
  38. package/src/shared/external-schedule-contract.ts +60 -0
  39. package/src/skillhub-http.test.ts +88 -0
  40. package/src/task-auth-origin-http.test.ts +87 -0
  41. package/src/task-auth-origin.test.ts +24 -0
  42. package/src/task-auth-origin.ts +7 -0
  43. package/src/task-consumption.test.ts +329 -0
  44. package/src/task-diagnostic-http.test.ts +145 -0
  45. package/src/task-diagnostic.test.ts +82 -0
  46. package/src/task-diagnostic.ts +89 -0
  47. package/src/task-idempotency-mcp.test.ts +137 -0
  48. package/src/task-idempotency.test.ts +60 -0
  49. package/src/task-idempotency.ts +75 -0
  50. package/src/task-lifecycle-watcher.test.ts +160 -0
  51. package/src/task-lifecycle-watcher.ts +49 -0
  52. package/src/task-network-resolution-http.test.ts +137 -0
  53. package/src/tasks-pagination-http.test.ts +137 -0
  54. package/src/tools.ts +635 -107
  55. package/src/uploads-http.test.ts +1 -3
  56. package/src/uploads.ts +14 -3
@@ -0,0 +1,149 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ AUDIT_LOG_REST_COLUMNS,
7
+ COMPLETION_REST_COLUMNS,
8
+ NETWORK_REST_COLUMNS,
9
+ SESSION_REST_COLUMNS,
10
+ TASK_EVENT_REST_COLUMNS,
11
+ TASK_REST_COLUMNS,
12
+ SCHEDULED_TASK_STORAGE_COLUMNS,
13
+ } from "./rest-projections.js";
14
+
15
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-rest-columns-"));
16
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
17
+
18
+ let db: typeof import("./db.js").db;
19
+ let server: ReturnType<typeof Bun.serve>;
20
+ let base = "";
21
+ let token = "";
22
+ let networkId = "";
23
+
24
+ const INTERNAL_SENTINEL = "future_internal_only";
25
+ const sortedKeys = (value: Record<string, unknown>): string[] => Object.keys(value).sort();
26
+ const sorted = (values: readonly string[]): string[] => [...values].sort();
27
+
28
+ async function api(path: string): Promise<any> {
29
+ const response = await fetch(`${base}${path}`, {
30
+ headers: { Authorization: `Bearer ${token}` },
31
+ });
32
+ expect(response.status).toBe(200);
33
+ return response.json();
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ ({ db } = await import("./db.js"));
38
+ const { register } = await import("./auth.js");
39
+ const auth = register(`rest_shape_${Date.now()}`, "RestShape-Strong-1!", undefined, "rest-shape");
40
+ expect(auth.ok).toBe(true);
41
+ token = auth.token!;
42
+ networkId = auth.network_id!;
43
+
44
+ // Simulate future migrations. Any REST SELECT * silently broadcasts these
45
+ // columns; every endpoint below must keep them private without needing a
46
+ // code change when the table grows.
47
+ for (const table of ["networks", "sessions", "audit_log", "task_events", "tasks", "completions", "scheduled_tasks"]) {
48
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${INTERNAL_SENTINEL} TEXT`);
49
+ }
50
+
51
+ db.run(
52
+ `INSERT INTO sessions (resume_id, alias, status, agent, model, network_id, external_schedules)
53
+ VALUES ('resume-rest-shape', 'rest-shape-node', 'idle', 'agent-node:codex', 'gpt-shape', ?1, ?2)`,
54
+ [networkId, JSON.stringify({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] })],
55
+ );
56
+ db.run(
57
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, priority, network_id, parent_task_id, meta_json)
58
+ VALUES ('task-rest-shape', 'sender', 'rest-shape-node', 'delivered', 'shape', 'normal', ?1, 'parent-shape', '{"attachment":true}')`,
59
+ [networkId],
60
+ );
61
+ db.run(
62
+ `INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
63
+ VALUES ('task-rest-shape', 'created', 'delivered', 'shape-test', 'shape', ?1)`,
64
+ [networkId],
65
+ );
66
+ db.run(
67
+ `INSERT INTO completions (id, session_name, task, result, network_id)
68
+ VALUES ('completion-rest-shape', 'rest-shape-node', 'shape', 'done', ?1)`,
69
+ [networkId],
70
+ );
71
+ db.run(
72
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id)
73
+ VALUES ((SELECT owner_id FROM networks WHERE network_id = ?1), 'shape-user', 'shape_action', 'shape', 'shape-id', 'shape', '127.0.0.1', ?1)`,
74
+ [networkId],
75
+ );
76
+ db.run(
77
+ `INSERT INTO scheduled_tasks
78
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias,
79
+ task_content, schedule_type, schedule_json, timezone, next_run_at)
80
+ VALUES ('schedule-rest-shape', ?1, 'private-user-id', 'shape schedule',
81
+ 'node-rest-shape', 'rest-shape-node', 'shape task', 'interval',
82
+ '{"type":"interval","every_seconds":3600}', 'UTC', '2099-01-01T00:00:00.000Z')`,
83
+ [networkId],
84
+ );
85
+
86
+ const mod = await import("./server.js");
87
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
88
+ base = `http://127.0.0.1:${server.port}`;
89
+ });
90
+
91
+ afterAll(() => {
92
+ try { server?.stop(true); } catch {}
93
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
94
+ });
95
+
96
+ describe("#311 REST response projections are stable across future ALTER TABLE", () => {
97
+ test("network list and detail preserve their existing public keys", async () => {
98
+ const list = await api("/api/networks");
99
+ const row = list.networks.find((item: any) => item.network_id === networkId);
100
+ expect(sortedKeys(row)).toEqual(sorted([...NETWORK_REST_COLUMNS, "member_role", "name"]));
101
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
102
+
103
+ const detail = await api(`/api/networks/${networkId}`);
104
+ expect(sortedKeys(detail.network)).toEqual(sorted(NETWORK_REST_COLUMNS));
105
+ expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
106
+ });
107
+
108
+ test("full status preserves telemetry compatibility without storage drift", async () => {
109
+ const body = await api(`/api/status?network_id=${networkId}`);
110
+ const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
111
+ expect(sortedKeys(row)).toEqual(sorted([...SESSION_REST_COLUMNS, "runtime", "host", "process_telemetry"]));
112
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
113
+ expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
114
+ });
115
+
116
+ test("task list and task detail expose the same explicit contract", async () => {
117
+ const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
118
+ expect(sortedKeys(list.tasks[0])).toEqual(sorted(TASK_REST_COLUMNS));
119
+ expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
120
+
121
+ const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
122
+ expect(sortedKeys(detail.task)).toEqual(sorted(TASK_REST_COLUMNS));
123
+ expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
124
+ });
125
+
126
+ test("audit, task-event, and completion rows are explicit", async () => {
127
+ const audit = await api("/api/audit-log?action=shape_action");
128
+ expect(sortedKeys(audit.logs[0])).toEqual(sorted(AUDIT_LOG_REST_COLUMNS));
129
+ expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
130
+
131
+ const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
132
+ expect(sortedKeys(events.events[0])).toEqual(sorted(TASK_EVENT_REST_COLUMNS));
133
+ expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
134
+
135
+ const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
136
+ expect(sortedKeys(completions.completions[0])).toEqual(sorted(COMPLETION_REST_COLUMNS));
137
+ expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
138
+ });
139
+
140
+ test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
141
+ const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
142
+ const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
143
+ const publicColumns = SCHEDULED_TASK_STORAGE_COLUMNS.filter((key) => key !== "created_by" && key !== "schedule_json");
144
+ expect(sortedKeys(row)).toEqual(sorted([...publicColumns, "schedule"]));
145
+ expect(row.created_by).toBeUndefined();
146
+ expect(row.schedule_json).toBeUndefined();
147
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
148
+ });
149
+ });
@@ -0,0 +1,120 @@
1
+ // REST from_session identity binding.
2
+ //
3
+ // Run: COMMHUB_DB=/tmp/rest-identity-test.db bun test src/rest-identity.test.ts
4
+ // (COMMHUB_DB must be set explicitly — unset defaults to the production DB.)
5
+ //
6
+ // Assertion discipline: results are compared with toEqual against the whole
7
+ // object, so a widened accepted-set fails instead of silently passing.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { nodeAliasForToken, resolveRestFromSession } from "./rest-identity.js";
11
+
12
+ const NTOK = "ntok_abc123";
13
+ const UTOK = "utok_abc123";
14
+
15
+ describe("REST from_session identity binding", () => {
16
+ test("node token claiming another node's alias is refused", () => {
17
+ expect(resolveRestFromSession({
18
+ token: NTOK,
19
+ tokenName: "node:通信IM牛",
20
+ requestedFrom: "通信龙",
21
+ })).toEqual({
22
+ ok: false,
23
+ error: "from_session_identity_mismatch",
24
+ message: "network token from_session does not match token-bound node alias",
25
+ tokenAlias: "通信IM牛",
26
+ requestedFromSession: "通信龙",
27
+ });
28
+ });
29
+
30
+ test("node token omitting from is bound to its own alias, not 'api'", () => {
31
+ expect(resolveRestFromSession({
32
+ token: NTOK,
33
+ tokenName: "node:通信IM牛",
34
+ requestedFrom: undefined,
35
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
36
+ });
37
+
38
+ test("node token claiming its own alias is accepted", () => {
39
+ expect(resolveRestFromSession({
40
+ token: NTOK,
41
+ tokenName: "node:通信IM牛",
42
+ requestedFrom: "通信IM牛",
43
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
44
+ });
45
+
46
+ test("whitespace padding does not defeat the comparison", () => {
47
+ expect(resolveRestFromSession({
48
+ token: NTOK,
49
+ tokenName: "node:通信IM牛",
50
+ requestedFrom: " 通信IM牛 ",
51
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
52
+ });
53
+
54
+ // The Dashboard posts as its logged-in user; this path must not change.
55
+ test("user token keeps the previous behaviour", () => {
56
+ expect(resolveRestFromSession({
57
+ token: UTOK,
58
+ tokenName: "dashboard",
59
+ requestedFrom: "admin",
60
+ })).toEqual({ ok: true, fromSession: "admin" });
61
+ });
62
+
63
+ test("user token with no from still defaults to 'api'", () => {
64
+ expect(resolveRestFromSession({
65
+ token: UTOK,
66
+ tokenName: null,
67
+ requestedFrom: undefined,
68
+ })).toEqual({ ok: true, fromSession: "api" });
69
+ });
70
+
71
+ // A non-string `from` must not be coerced into an identity.
72
+ test("non-string from is not treated as a claim", () => {
73
+ expect(resolveRestFromSession({
74
+ token: NTOK,
75
+ tokenName: "node:通信IM牛",
76
+ requestedFrom: { toString: () => "通信龙" },
77
+ })).toEqual({ ok: true, fromSession: "通信IM牛" });
78
+ });
79
+
80
+ // A network-bound token we cannot resolve to an alias must not be able to
81
+ // call itself anything. Found by independent review of this change.
82
+ describe("ntok_ with an unresolvable alias fails closed", () => {
83
+ for (const tokenName of ["node:", "node: ", "dashboard", null, undefined]) {
84
+ test(`claim is refused when tokenName is ${JSON.stringify(tokenName)}`, () => {
85
+ expect(resolveRestFromSession({
86
+ token: NTOK,
87
+ tokenName,
88
+ requestedFrom: "通信龙",
89
+ })).toEqual({
90
+ ok: false,
91
+ error: "from_session_identity_mismatch",
92
+ message: "network token has no resolvable node alias and may not claim a from_session",
93
+ tokenAlias: "",
94
+ requestedFromSession: "通信龙",
95
+ });
96
+ });
97
+ }
98
+
99
+ test("no claim at all still resolves to 'api'", () => {
100
+ expect(resolveRestFromSession({
101
+ token: NTOK,
102
+ tokenName: "node:",
103
+ requestedFrom: undefined,
104
+ })).toEqual({ ok: true, fromSession: "api" });
105
+ });
106
+ });
107
+
108
+ describe("nodeAliasForToken", () => {
109
+ test("only ntok_ + node: prefix yields an alias", () => {
110
+ expect(nodeAliasForToken(NTOK, "node:X")).toBe("X");
111
+ // A user token whose name merely looks node-shaped must not bind.
112
+ expect(nodeAliasForToken(UTOK, "node:X")).toBeNull();
113
+ // A node token whose name is not node-scoped must not bind.
114
+ expect(nodeAliasForToken(NTOK, "dashboard")).toBeNull();
115
+ expect(nodeAliasForToken(NTOK, null)).toBeNull();
116
+ // `node:` with nothing after it is not an identity.
117
+ expect(nodeAliasForToken(NTOK, "node: ")).toBeNull();
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,93 @@
1
+ // REST from_session identity binding.
2
+ //
3
+ // Both transports resolve the same `api_tokens` row through resolveToken, and
4
+ // auth.ts already states the invariant:
5
+ //
6
+ // "Network-bound node tokens are an identity boundary: they must not spoof
7
+ // another node via from_session."
8
+ //
9
+ // tools.ts enforced it for MCP (defaultFrom + fromIdentityMismatchReply).
10
+ // POST /api/task did not — it took `body.from` verbatim, so any node holding
11
+ // its own ntok_ could claim another node's alias. That value is stored on the
12
+ // task row and, once sender labels reach the copresence TUIs, rendered to a
13
+ // human as an attribution.
14
+ //
15
+ // This is the same decision expressed as a pure function so every credential
16
+ // kind is a directly constructible test input rather than a hand-assembled
17
+ // Request (the #503 convention in this codebase).
18
+
19
+ export interface RestIdentityInput {
20
+ /** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
21
+ token: string;
22
+ /** `api_tokens.name`; for node tokens it is `node:<alias>`. */
23
+ tokenName: string | null | undefined;
24
+ /** Caller-supplied `body.from`. */
25
+ requestedFrom: unknown;
26
+ }
27
+
28
+ export type RestIdentityResult =
29
+ | { ok: true; fromSession: string }
30
+ | {
31
+ ok: false;
32
+ error: "from_session_identity_mismatch";
33
+ message: string;
34
+ tokenAlias: string;
35
+ requestedFromSession: string;
36
+ };
37
+
38
+ /** Alias a token is bound to, or null when it is not node-scoped. */
39
+ export function nodeAliasForToken(token: string, tokenName: string | null | undefined): string | null {
40
+ if (!token.startsWith("ntok_")) return null;
41
+ if (typeof tokenName !== "string" || !tokenName.startsWith("node:")) return null;
42
+ const alias = tokenName.slice("node:".length).trim();
43
+ return alias || null;
44
+ }
45
+
46
+ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
47
+ const alias = nodeAliasForToken(input.token, input.tokenName);
48
+ const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
49
+
50
+ // A network-bound token whose name we cannot resolve to an alias (`node:`,
51
+ // `node: `, or a name that was never node-scoped) is the degenerate case.
52
+ // We know it IS a node credential; we just cannot tell WHICH node. Letting it
53
+ // claim anything is the worst available option, so refuse the claim outright
54
+ // rather than falling through to the unbound path.
55
+ //
56
+ // Production always writes `node:${nodeName}`, so this costs nothing real —
57
+ // but "we couldn't identify you, so you may call yourself anything" is
58
+ // exactly the fail-open shape this change exists to remove.
59
+ if (!alias && input.token.startsWith("ntok_")) {
60
+ if (requested) {
61
+ return {
62
+ ok: false,
63
+ error: "from_session_identity_mismatch",
64
+ message: "network token has no resolvable node alias and may not claim a from_session",
65
+ tokenAlias: "",
66
+ requestedFromSession: requested,
67
+ };
68
+ }
69
+ return { ok: true, fromSession: "api" };
70
+ }
71
+
72
+ // Not a node token: unchanged behaviour. The Dashboard legitimately posts as
73
+ // its logged-in user; tightening that is a separate change with its own
74
+ // compatibility surface.
75
+ if (!alias) {
76
+ return { ok: true, fromSession: requested || "api" };
77
+ }
78
+
79
+ // Node token claiming somebody else — refuse rather than silently rewrite,
80
+ // so a misconfigured caller is visible instead of quietly relabelled.
81
+ if (requested && requested !== alias) {
82
+ return {
83
+ ok: false,
84
+ error: "from_session_identity_mismatch",
85
+ message: "network token from_session does not match token-bound node alias",
86
+ tokenAlias: alias,
87
+ requestedFromSession: requested,
88
+ };
89
+ }
90
+
91
+ // Node token, no claim or a matching one: bind to the token's own alias.
92
+ return { ok: true, fromSession: alias };
93
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Stable REST row projections.
3
+ *
4
+ * These lists deliberately mirror the fields already exposed by the public
5
+ * endpoints before #311. Keeping them in one place makes the wire contract
6
+ * independent from future ALTER TABLE migrations: adding a storage-only
7
+ * column must never silently add a REST response key.
8
+ */
9
+ export const NETWORK_REST_COLUMNS = [
10
+ "network_id", "network_name", "owner_id", "description", "settings",
11
+ "created_at", "updated_at", "visibility", "max_members",
12
+ ] as const;
13
+
14
+ export const SESSION_REST_COLUMNS = [
15
+ "resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
16
+ "project_dir", "version", "status", "task", "output", "progress", "score",
17
+ "cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
18
+ "disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
19
+ "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
20
+ "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
+ "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
+ "external_schedules",
23
+ ] as const;
24
+
25
+ export const AUDIT_LOG_REST_COLUMNS = [
26
+ "id", "user_id", "username", "action", "target_type", "target_id", "detail",
27
+ "ip", "network_id", "created_at",
28
+ ] as const;
29
+
30
+ export const TASK_EVENT_REST_COLUMNS = [
31
+ "id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
32
+ "created_at", "network_id",
33
+ ] as const;
34
+
35
+ export const TASK_REST_COLUMNS = [
36
+ "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
37
+ "status", "content", "result", "in_reply_to", "requires_response", "scope",
38
+ "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
39
+ "network_id", "parent_task_id", "meta_json",
40
+ ] as const;
41
+
42
+ export const COMPLETION_REST_COLUMNS = [
43
+ "id", "session_name", "task", "result", "artifacts", "score",
44
+ "duration_minutes", "network_id", "completed_at",
45
+ ] as const;
46
+
47
+ // Storage projection for scheduled-task rows. `created_by` and
48
+ // `schedule_json` are intentionally selected for server-side auth/decoding,
49
+ // then removed by decodeRow before the REST response is serialized.
50
+ export const SCHEDULED_TASK_STORAGE_COLUMNS = [
51
+ "schedule_id", "network_id", "created_by", "name", "target_node_id",
52
+ "target_alias", "task_content", "priority", "schedule_type", "schedule_json",
53
+ "timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
54
+ "last_run_at", "revision", "created_at", "updated_at",
55
+ ] as const;
56
+
57
+ export function sqlColumns(columns: readonly string[], qualifier?: string): string {
58
+ return columns.map((column) => qualifier ? `${qualifier}.${column}` : column).join(", ");
59
+ }
60
+
61
+ export const NETWORK_REST_SELECT = sqlColumns(NETWORK_REST_COLUMNS);
62
+ export const SESSION_REST_SELECT = sqlColumns(SESSION_REST_COLUMNS);
63
+ export const AUDIT_LOG_REST_SELECT = sqlColumns(AUDIT_LOG_REST_COLUMNS);
64
+ export const TASK_EVENT_REST_SELECT = sqlColumns(TASK_EVENT_REST_COLUMNS);
65
+ export const TASK_REST_SELECT = sqlColumns(TASK_REST_COLUMNS);
66
+ export const COMPLETION_REST_SELECT = sqlColumns(COMPLETION_REST_COLUMNS);
67
+ export const SCHEDULED_TASK_STORAGE_SELECT = sqlColumns(SCHEDULED_TASK_STORAGE_COLUMNS);