@sleep2agi/commhub-server 0.9.0-preview.109 → 0.9.0-preview.111
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/package.json +1 -1
- package/src/auth.ts +18 -14
- package/src/create-node-unicode-name.test.ts +118 -0
- package/src/create-node-validate.test.ts +19 -5
- package/src/create-node-validate.ts +14 -4
- package/src/create-node.ts +13 -6
- package/src/daemon-token-owner-http.test.ts +226 -0
- package/src/db.ts +3 -0
- package/src/node-lifecycle-read-http.test.ts +203 -0
- package/src/node-lifecycle-read.ts +99 -0
- package/src/node-token-ownership.ts +36 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/requirement-events-http.test.ts +12 -1
- package/src/requirements-last-event-http.test.ts +6 -1
- package/src/scheduled-tasks.ts +5 -3
- package/src/server.ts +28 -5
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/tools.ts +49 -13
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { beforeAll, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "lifecycle-read-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
8
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "lifecycle-read-master-fixture";
|
|
9
|
+
let hub: ReturnType<typeof Bun.serve>, base: string;
|
|
10
|
+
let db: any, a: any, b: any, limited: any, daemonToken: string;
|
|
11
|
+
const ids = { active: "n_read_active", created: "node_read_created", manual: "node_read_manual", daemon: "n_read_daemon" };
|
|
12
|
+
const get = async (path: string, token = a.token) => {
|
|
13
|
+
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
|
|
14
|
+
return { status: res.status, body: await res.json() as any };
|
|
15
|
+
};
|
|
16
|
+
const query = (kind: string, selector: string) => `/api/node-lifecycle-requests?kind=${kind}&${selector}`;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
({ db } = await import("./db.js"));
|
|
20
|
+
const { register } = await import("./auth.js");
|
|
21
|
+
const suffix = Date.now();
|
|
22
|
+
a = register(`read_owner_${suffix}`, "ReadFixture-Strong1!");
|
|
23
|
+
b = register(`read_other_${suffix}`, "ReadFixture-Strong1!");
|
|
24
|
+
limited = register(`read_limited_${suffix}`, "ReadFixture-Strong1!");
|
|
25
|
+
expect(a.ok && b.ok && limited.ok).toBe(true);
|
|
26
|
+
for (const user of [a, b, limited]) db.run("UPDATE users SET role='user' WHERE user_id=?1", [user.user.user_id]);
|
|
27
|
+
db.run("INSERT INTO network_members(network_id,user_id,role,agent_access) VALUES(?1,?2,'viewer','granted')", [a.network_id, limited.user.user_id]);
|
|
28
|
+
for (const [key, id] of Object.entries(ids)) db.run(
|
|
29
|
+
"INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host',?5)",
|
|
30
|
+
[id, `read-${key}`, a.network_id, a.user.user_id, key === "daemon" ? JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }) : "{}"]);
|
|
31
|
+
for (const status of ["active", "pending", "refused", "revoked"]) {
|
|
32
|
+
const id = status === "active" ? ids.active : `n_read_${status}`;
|
|
33
|
+
if (status !== "active") db.run("INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?2,?3)", [id, `read-${status}`, a.network_id]);
|
|
34
|
+
db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,error,created_at,updated_at)
|
|
35
|
+
VALUES(?1,?2,?3,?4,'/private/fixture',?5,?6,?7,1,2)`,
|
|
36
|
+
[`adopt_read_${status}`, a.network_id, id, ids.daemon, a.user.user_id, status, status === "refused" ? "adopt_start_evidence_missing" : null]);
|
|
37
|
+
}
|
|
38
|
+
db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token)
|
|
39
|
+
VALUES('cr_read_created',?1,'created',?2,'codex-sdk','{}','[]','succeeded',1,'private-token')`, [ids.daemon, a.network_id]);
|
|
40
|
+
db.run(`INSERT INTO node_stop_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,action,created_by_token,status,error,created_at,acked_at)
|
|
41
|
+
VALUES('stop_read_failed',?1,?2,?3,'read-active','stop','private-token','stop_failed','adopt_explicit_private_socket_required',3,4)`, [a.network_id, ids.daemon, ids.active]);
|
|
42
|
+
db.run(`INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,error,created_at,acked_at)
|
|
43
|
+
VALUES('start_read_failed',?1,?2,?3,'read-active','private-token','start_failed','adopt_start_evidence_missing',5,6)`, [a.network_id, ids.daemon, ids.active]);
|
|
44
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES('read-daemon-session','read-daemon',?1,?2,'idle',datetime('now'))", [ids.daemon, a.network_id]);
|
|
45
|
+
const { generateNetworkToken, hashToken } = await import("./db.js");
|
|
46
|
+
daemonToken = generateNetworkToken();
|
|
47
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('read-daemon-token',?1,?2,'network','node:read-daemon',?3)", [a.user.user_id, a.network_id, hashToken(daemonToken)]);
|
|
48
|
+
const { bootServer } = await import("./server.js");
|
|
49
|
+
hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
50
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
51
|
+
}, 30_000);
|
|
52
|
+
afterAll(() => hub?.stop(true));
|
|
53
|
+
|
|
54
|
+
test("public errors redact free text for granted viewers on every projection", async () => {
|
|
55
|
+
db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
|
|
56
|
+
try {
|
|
57
|
+
for (const error of [
|
|
58
|
+
"/private/fixture/secret on fixture-host",
|
|
59
|
+
"not_my_child: daemon has no local record of fixture-node (fixture-id) — no children-map entry and no config at /private/fixture/fixture-node/config.json; refusing to signal processes by alias.",
|
|
60
|
+
"bin: ENOENT: /private/fixture/bin/anet on fixture-host",
|
|
61
|
+
"adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
|
|
62
|
+
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
63
|
+
]) {
|
|
64
|
+
const expected = error === null || ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed"].includes(error) ? error : "lifecycle_error";
|
|
65
|
+
for (const [kind, table, request] of [
|
|
66
|
+
["adopt", "node_daemon_bindings", "adopt_read_active"],
|
|
67
|
+
["start", "node_start_requests", "start_read_failed"],
|
|
68
|
+
["stop", "node_stop_requests", "stop_read_failed"],
|
|
69
|
+
]) {
|
|
70
|
+
db.run(`UPDATE ${table} SET error=?1 WHERE request_id=?2`, [error, request]);
|
|
71
|
+
for (const selector of [`request_id=${request}`, `node_id=${ids.active}`]) {
|
|
72
|
+
const r = await get(query(kind, selector), limited.token);
|
|
73
|
+
expect(r.status).toBe(200);
|
|
74
|
+
expect(r.body.request.error).toBe(expected);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes;
|
|
78
|
+
expect(nodes[0].adoption.error).toBe(expected);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]);
|
|
82
|
+
db.run("UPDATE node_daemon_bindings SET error=NULL WHERE request_id='adopt_read_active'");
|
|
83
|
+
db.run("UPDATE node_start_requests SET error='adopt_start_evidence_missing' WHERE request_id='start_read_failed'");
|
|
84
|
+
db.run("UPDATE node_stop_requests SET error='adopt_explicit_private_socket_required' WHERE request_id='stop_read_failed'");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("created projection prefers recorded child id and only falls back for legacy null", async () => {
|
|
89
|
+
const read = async () => (await get(`/api/nodes?network_id=${a.network_id}`)).body.nodes;
|
|
90
|
+
try {
|
|
91
|
+
expect((await read()).find((n: any) => n.node_id === ids.created).managed).toBe("created");
|
|
92
|
+
db.run("UPDATE node_create_requests SET child_node_id=?1 WHERE request_id='cr_read_created'", [ids.manual]);
|
|
93
|
+
let nodes = await read();
|
|
94
|
+
expect(nodes.find((n: any) => n.node_id === ids.manual).managed).toBe("created");
|
|
95
|
+
expect(nodes.find((n: any) => n.node_id === ids.created).managed).toBe("none");
|
|
96
|
+
db.run("UPDATE node_create_requests SET child_node_id='missing-child' WHERE request_id='cr_read_created'");
|
|
97
|
+
expect((await read()).find((n: any) => n.node_id === ids.created).managed).toBe("none");
|
|
98
|
+
db.run("UPDATE node_create_requests SET child_node_id=?1,network_id=?2 WHERE request_id='cr_read_created'", [ids.manual, b.network_id]);
|
|
99
|
+
nodes = await read();
|
|
100
|
+
expect(nodes.find((n: any) => n.node_id === ids.manual).managed).toBe("none");
|
|
101
|
+
} finally {
|
|
102
|
+
db.run("UPDATE node_create_requests SET child_node_id=NULL,network_id=?1 WHERE request_id='cr_read_created'", [a.network_id]);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("authenticated port-0 HTTP projects actual authority, never an id prefix", async () => {
|
|
107
|
+
const { status, body } = await get(`/api/nodes?network_id=${a.network_id}`);
|
|
108
|
+
expect(status).toBe(200);
|
|
109
|
+
const rows = body.nodes;
|
|
110
|
+
expect(rows.find((r: any) => r.node_id === ids.created)).toMatchObject({ managed: "created", adoption: null, lifecycle_controllable: true });
|
|
111
|
+
expect(rows.find((r: any) => r.node_id === ids.manual)).toMatchObject({ managed: "none", adoption: null, lifecycle_controllable: false });
|
|
112
|
+
expect(rows.find((r: any) => r.node_id === ids.active)).toMatchObject({ managed: "adopted", adoption: { status: "active", request_id: "adopt_read_active" }, lifecycle_controllable: true });
|
|
113
|
+
for (const status of ["pending", "refused", "revoked"]) expect(rows.find((r: any) => r.node_id === `n_read_${status}`)).toMatchObject({ managed: "none", adoption: { status } });
|
|
114
|
+
});
|
|
115
|
+
test("adopt query preserves all persisted states, request id and bounded projection", async () => {
|
|
116
|
+
for (const status of ["pending", "active", "refused", "revoked"]) {
|
|
117
|
+
const r = await get(query("adopt", `request_id=adopt_read_${status}`));
|
|
118
|
+
expect(r.status).toBe(200);
|
|
119
|
+
expect(r.body.request).toMatchObject({ kind: "adopt", status, request_id: `adopt_read_${status}` });
|
|
120
|
+
expect(r.body.request.workdir).toBeUndefined();
|
|
121
|
+
expect(r.body.request.requested_by).toBeUndefined();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
test("start/stop returns daemon failure codes; node selector returns latest, not success fiction", async () => {
|
|
125
|
+
for (const [kind, error] of [["start", "adopt_start_evidence_missing"], ["stop", "adopt_explicit_private_socket_required"]]) {
|
|
126
|
+
const r = await get(query(kind, `node_id=${ids.active}`));
|
|
127
|
+
expect(r.status).toBe(200);
|
|
128
|
+
expect(r.body.request).toMatchObject({ status: `${kind}_failed`, error, node_id: ids.active });
|
|
129
|
+
expect(r.body.request.created_by_token).toBeUndefined();
|
|
130
|
+
}
|
|
131
|
+
expect((await get(query("start", `node_id=${ids.manual}`))).body.request).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
test("cross-network request and node guesses do not disclose data", async () => {
|
|
134
|
+
for (const kind of ["adopt", "start", "stop"]) {
|
|
135
|
+
const id = kind === "adopt" ? "adopt_read_active" : `${kind}_read_failed`;
|
|
136
|
+
const denied = await get(query(kind, `request_id=${id}`), b.token);
|
|
137
|
+
expect(denied.status).toBe(404);
|
|
138
|
+
expect(denied.body).toEqual((await get(query(kind, "request_id=nonexistent"), b.token)).body);
|
|
139
|
+
expect((await get(query(kind, `node_id=${ids.active}`), b.token)).status).toBe(404);
|
|
140
|
+
expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), b.token)).status).toBe(403);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
test("same-network restricted viewer without node grants cannot read requests", async () => {
|
|
144
|
+
for (const kind of ["adopt", "start", "stop"]) {
|
|
145
|
+
expect((await get(query(kind, `node_id=${ids.active}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
|
|
146
|
+
const id = kind === "adopt" ? "adopt_read_active" : `${kind}_read_failed`;
|
|
147
|
+
expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
|
|
148
|
+
}
|
|
149
|
+
expect((await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
test("current node grant allows reads; revocation immediately closes them", async () => {
|
|
152
|
+
db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
|
|
153
|
+
try {
|
|
154
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), limited.token)).status).toBe(200);
|
|
155
|
+
const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes;
|
|
156
|
+
expect(nodes).toHaveLength(1);
|
|
157
|
+
expect(nodes[0].managed).toBe("adopted");
|
|
158
|
+
} finally { db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]); }
|
|
159
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), limited.token)).status).toBe(404);
|
|
160
|
+
});
|
|
161
|
+
test("daemon and legacy master do not gain the new user read surface", async () => {
|
|
162
|
+
for (const token of [daemonToken, process.env.COMMHUB_AUTH_TOKEN!]) {
|
|
163
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), token)).status).toBe(403);
|
|
164
|
+
const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, token)).body.nodes;
|
|
165
|
+
expect(nodes.length).toBeGreaterThan(0);
|
|
166
|
+
expect(nodes.every((n: any) => !("managed" in n) && !("adoption" in n))).toBe(true);
|
|
167
|
+
}
|
|
168
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), "invalid")).status).toBe(401);
|
|
169
|
+
const queryOnly = await fetch(base + query("adopt", `request_id=adopt_read_active&token=${encodeURIComponent(a.token)}`));
|
|
170
|
+
expect([401, 403]).toContain(queryOnly.status);
|
|
171
|
+
});
|
|
172
|
+
test("host-supervisors capability respects user token and node visibility; absent stays absent", async () => {
|
|
173
|
+
const path = `/api/host-supervisors?network_id=${a.network_id}`;
|
|
174
|
+
expect((await get(path)).body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adopt_capable).toBe(true);
|
|
175
|
+
for (const token of [limited.token, daemonToken]) {
|
|
176
|
+
const r = await get(path, token);
|
|
177
|
+
expect((r.body.daemons ?? []).every((d: any) => !("adopt_capable" in d))).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: false } }), ids.daemon]);
|
|
180
|
+
expect((await get(path)).body.daemons[0].adopt_capable).toBe(false);
|
|
181
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), ids.daemon]);
|
|
182
|
+
expect((await get(path)).body.daemons[0].adopt_capable).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
test("invalid selectors rejected rather than listing every lifecycle request", async () => {
|
|
185
|
+
for (const suffix of ["", "kind=adopt", "kind=other&node_id=x", "kind=start&request_id=x&node_id=y", "kind=stop&node_id=x&node_id=y", "kind=start&request_id=x&node_id=", "kind=adopt&node_id=%20"])
|
|
186
|
+
expect((await get(`/api/node-lifecycle-requests?${suffix}`)).status).toBe(400);
|
|
187
|
+
});
|
|
188
|
+
test("request state refresh exposes pending, completion and binding-missing failure without caching", async () => {
|
|
189
|
+
for (const [kind, statuses] of [["start", ["pending", "delivered", "started", "start_failed", "timeout"]], ["stop", ["pending", "delivered", "stopped", "stop_failed", "noop_not_my_child"]]] as const) {
|
|
190
|
+
for (const status of statuses) {
|
|
191
|
+
const error = status.endsWith("failed") ? "adopt_active_binding_required" : null;
|
|
192
|
+
db.run(`UPDATE node_${kind}_requests SET status=?1,error=?2 WHERE request_id=?3`, [status, error, `${kind}_read_failed`]);
|
|
193
|
+
expect((await get(query(kind, `request_id=${kind}_read_failed`))).body.request).toMatchObject({ status, error });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
test("join refuses a request whose stored network disagrees with its target node", async () => {
|
|
198
|
+
db.run("UPDATE node_start_requests SET network_id=?1 WHERE request_id='start_read_failed'", [b.network_id]);
|
|
199
|
+
try {
|
|
200
|
+
expect((await get(query("start", "request_id=start_read_failed"))).status).toBe(404);
|
|
201
|
+
expect((await get(query("start", "request_id=start_read_failed"), b.token)).status).toBe(404);
|
|
202
|
+
} finally { db.run("UPDATE node_start_requests SET network_id=?1 WHERE request_id='start_read_failed'", [a.network_id]); }
|
|
203
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js";
|
|
3
|
+
|
|
4
|
+
// Exact public codes only; daemon exception text must never reach node viewers.
|
|
5
|
+
const publicLifecycleErrors = new Set([
|
|
6
|
+
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
7
|
+
"adopt_active_binding_required", "adopt_binding_unavailable",
|
|
8
|
+
"adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
|
|
9
|
+
"adopt_start_evidence_missing", "adopt_stop_evidence_missing",
|
|
10
|
+
"adopt_launch_mode_mismatch", "adopt_lifecycle_verification_failed",
|
|
11
|
+
"adopt_process_generation_changed", "adopt_process_still_running",
|
|
12
|
+
"adopt_process_stop_timeout", "adopt_process_tree_unstable",
|
|
13
|
+
"adopt_registry_identity_mismatch", "adopt_self_process_refused",
|
|
14
|
+
"adopt_start_timeout", "adopt_tmux_session_still_exists",
|
|
15
|
+
"adopt_local_verification_failed", "adopt_roots_not_configured",
|
|
16
|
+
"adopt_workdir_outside_roots", "adopt_workdir_not_absolute",
|
|
17
|
+
"adopt_alias_invalid", "adopt_alias_mismatch", "adopt_config_invalid",
|
|
18
|
+
"adopt_config_network_mismatch", "adopt_network_mismatch",
|
|
19
|
+
"adopt_hub_invalid", "adopt_hub_missing", "adopt_hub_mismatch",
|
|
20
|
+
"adopt_identity_ambiguous", "adopt_identity_not_found", "adopt_identity_changed",
|
|
21
|
+
"adopt_path_not_regular", "adopt_path_owner_mismatch", "adopt_path_writable_by_others",
|
|
22
|
+
"adopt_process_argv_mismatch", "adopt_process_home_mismatch",
|
|
23
|
+
"adopt_process_identity_mismatch", "adopt_process_changed",
|
|
24
|
+
"adopt_tmux_socket_mismatch", "adopt_pane_invalid", "adopt_pane_unverified",
|
|
25
|
+
"adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
|
|
26
|
+
"adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
|
|
27
|
+
"adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
|
|
28
|
+
"adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
|
|
29
|
+
"adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
|
|
30
|
+
"adopt_env_file_unsafe", "adopt_request_mismatch", "adopt_ack_rejected",
|
|
31
|
+
]);
|
|
32
|
+
function publicLifecycleError(error: unknown): string | null {
|
|
33
|
+
return error == null ? null
|
|
34
|
+
: typeof error === "string" && publicLifecycleErrors.has(error) ? error : "lifecycle_error";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Adoption = { request_id: string; daemon_node_id: string; status: string; error: string | null };
|
|
38
|
+
export type LifecycleProjection = { managed: "created" | "adopted" | "none"; adoption: Adoption | null };
|
|
39
|
+
const scopeNodes = (sql: string, params: any[], scope: RestNetworkScope) =>
|
|
40
|
+
addAgentNetworkScope(sql, params, scope, { network: "n.network_id", nodeId: "n.node_id", alias: "n.alias" });
|
|
41
|
+
|
|
42
|
+
/** Called only for authenticated human readers. Same visibility as /api/nodes;
|
|
43
|
+
* constant query count, no id-prefix or host-name inference of authority. */
|
|
44
|
+
export function lifecycleProjections(scope: RestNetworkScope): Map<string, LifecycleProjection> {
|
|
45
|
+
const result = new Map<string, LifecycleProjection>();
|
|
46
|
+
const bp: any[] = [];
|
|
47
|
+
const bindings = db.all<Adoption & { node_id: string }>(scopeNodes(
|
|
48
|
+
`SELECT b.node_id,b.request_id,b.daemon_node_id,b.status,b.error
|
|
49
|
+
FROM node_daemon_bindings b JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
|
|
50
|
+
WHERE 1=1`, bp, scope) + " ORDER BY b.created_at DESC,b.request_id DESC", ...bp);
|
|
51
|
+
for (const b of bindings) {
|
|
52
|
+
let projection = result.get(b.node_id);
|
|
53
|
+
if (!projection) {
|
|
54
|
+
projection = { managed: "none", adoption: { request_id: b.request_id, daemon_node_id: b.daemon_node_id, status: b.status, error: publicLifecycleError(b.error) } };
|
|
55
|
+
result.set(b.node_id, projection);
|
|
56
|
+
}
|
|
57
|
+
if (b.status === "active") projection.managed = "adopted";
|
|
58
|
+
}
|
|
59
|
+
const cp: any[] = [];
|
|
60
|
+
const created = db.all<{ node_id: string }>(scopeNodes(
|
|
61
|
+
`SELECT n.node_id FROM node_create_requests c JOIN nodes n
|
|
62
|
+
ON n.node_id=COALESCE(c.child_node_id, CASE WHEN substr(c.request_id,1,3)='cr_'
|
|
63
|
+
THEN 'node_' || substr(c.request_id,4) END) AND n.network_id=c.network_id
|
|
64
|
+
WHERE 1=1`, cp, scope), ...cp);
|
|
65
|
+
for (const n of created) result.set(n.node_id, { managed: "created", adoption: result.get(n.node_id)?.adoption ?? null });
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read-only, selector-bound lookup. Join the live node in the SAME network,
|
|
70
|
+
* then apply its current visibility (including restricted member grants).
|
|
71
|
+
* Result fields exclude credentials, PID and config; error values separately
|
|
72
|
+
* use the exact public-code allowlist (daemon text may contain workdir paths). */
|
|
73
|
+
export function lifecycleRequestResponse(url: URL, scope: RestNetworkScope): Response {
|
|
74
|
+
const kind = url.searchParams.get("kind");
|
|
75
|
+
const requestId = url.searchParams.get("request_id"), nodeId = url.searchParams.get("node_id");
|
|
76
|
+
const fail = (error: string, status: number) => Response.json({ ok: false, error }, { status });
|
|
77
|
+
if (!["adopt", "start", "stop"].includes(kind ?? "")
|
|
78
|
+
|| url.searchParams.has("request_id") === url.searchParams.has("node_id")
|
|
79
|
+
|| !(requestId ?? nodeId ?? "").trim()
|
|
80
|
+
|| ["kind", "request_id", "node_id"].some(k => url.searchParams.getAll(k).length > 1)
|
|
81
|
+
|| (requestId ?? nodeId ?? "").length > 200) return fail("invalid_lifecycle_query", 400);
|
|
82
|
+
// The table and column names below come only from this closed enum.
|
|
83
|
+
const table = kind === "adopt" ? "node_daemon_bindings" : kind === "start" ? "node_start_requests" : "node_stop_requests";
|
|
84
|
+
const nodeColumn = kind === "adopt" ? "node_id" : "child_node_id";
|
|
85
|
+
// A node lookup with no request must distinguish 'no requests' from 'not visible'.
|
|
86
|
+
if (nodeId) {
|
|
87
|
+
const params: any[] = [nodeId];
|
|
88
|
+
if (!db.get(scopeNodes("SELECT n.node_id FROM nodes n WHERE n.node_id=?1", params, scope), ...params))
|
|
89
|
+
return fail("node_not_found", 404);
|
|
90
|
+
}
|
|
91
|
+
const params: any[] = [requestId ?? nodeId];
|
|
92
|
+
const sql = scopeNodes(`SELECT r.request_id,r.${nodeColumn} AS node_id,r.network_id,r.daemon_node_id,
|
|
93
|
+
r.status,r.error,r.created_at,${kind === "adopt" ? "r.updated_at" : "r.acked_at,r.delivered_at"}
|
|
94
|
+
FROM ${table} r JOIN nodes n ON n.node_id=r.${nodeColumn} AND n.network_id=r.network_id
|
|
95
|
+
WHERE r.${requestId ? "request_id" : nodeColumn}=?1${kind === "stop" ? " AND r.action='stop'" : ""}`, params, scope);
|
|
96
|
+
const row = db.get<Record<string, unknown>>(sql + " ORDER BY r.created_at DESC,r.request_id DESC LIMIT 1", ...params);
|
|
97
|
+
if (!row && requestId) return fail("request_not_found", 404);
|
|
98
|
+
return Response.json({ ok: true, request: row ? { kind, ...row, error: publicLifecycleError(row.error) } : null });
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
// Call inside the caller's transaction. Serialize registration and issuance for
|
|
4
|
+
// this alias on PG; SQLite already serializes writes on its single connection.
|
|
5
|
+
export function lockNodeAlias(network: string, alias: string, nodeId?: string): void {
|
|
6
|
+
if (db.dialect !== "postgres") return;
|
|
7
|
+
db.get("SELECT pg_advisory_xact_lock(hashtext(?1))", JSON.stringify([network, "alias", alias]));
|
|
8
|
+
if (nodeId) db.get("SELECT pg_advisory_xact_lock(hashtext(?1))", JSON.stringify([network, "id", nodeId]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function legacyNodeHolder(user: string, network: string, alias: string, nodeId: string): boolean {
|
|
12
|
+
return !!db.get(`SELECT token_id FROM api_tokens WHERE user_id=?1 AND network_id=?2
|
|
13
|
+
AND name=?3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
14
|
+
AND (bound_node_id=?4 OR (bound_node_id IS NULL AND node_identity_epoch=0)) LIMIT 1`,
|
|
15
|
+
user, network, `node:${alias}`, nodeId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve a name-only refresh to the existing identity, never claim its owner. */
|
|
19
|
+
export function checkNodeTokenClaim(user: string, network: string, alias: string, nodeId?: string): string | undefined {
|
|
20
|
+
lockNodeAlias(network, alias, nodeId);
|
|
21
|
+
const rows = db.all<{node_id: string; owner_user_id: string | null}>(
|
|
22
|
+
"SELECT node_id, owner_user_id FROM nodes WHERE network_id=?1 AND alias=?2", network, alias);
|
|
23
|
+
if (rows.length > 1 || rows.some(row => nodeId && row.node_id !== nodeId)) throw Error("node_owner_mismatch");
|
|
24
|
+
const row = rows[0];
|
|
25
|
+
// Explicit-ID ownerless claims retain auth.ts's node_owner_unclaimed error;
|
|
26
|
+
// name-only requests need holder evidence before resolving to that ID.
|
|
27
|
+
if (row && (row.owner_user_id ? row.owner_user_id !== user : !nodeId && !legacyNodeHolder(user, network, alias, row.node_id))) {
|
|
28
|
+
throw Error("node_owner_mismatch");
|
|
29
|
+
}
|
|
30
|
+
if (!row && db.get(`SELECT token_id FROM api_tokens WHERE network_id=?1 AND name=?2
|
|
31
|
+
AND user_id<>?3 AND node_identity_epoch=2 AND revoked_at IS NULL
|
|
32
|
+
AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1`, network, `node:${alias}`, user)) {
|
|
33
|
+
throw Error("node_owner_mismatch");
|
|
34
|
+
}
|
|
35
|
+
return nodeId ?? row?.node_id;
|
|
36
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// #667 — a restart from the right directory must clear the cwd warning the
|
|
2
|
+
// previous process left on the same resume_id. Hub stores
|
|
3
|
+
// `task = COALESCE(?11, sessions.task)`, so omitting task keeps the warning.
|
|
4
|
+
// Register sends task: "" once. Later idle reports omit task, so the
|
|
5
|
+
// description of a finished task stays. This boots a throwaway hub on
|
|
6
|
+
// port 0 (never 9200).
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { reportedTask } from "../../agent-node/src/project-dir-mismatch.ts";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-667-clear-"));
|
|
15
|
+
process.env.COMMHUB_DB = join(DIR, "hub.db");
|
|
16
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
17
|
+
process.env.HOST = "127.0.0.1";
|
|
18
|
+
|
|
19
|
+
const PW = "ProjectDirWarn123!x";
|
|
20
|
+
const stamp = Date.now();
|
|
21
|
+
const ROOT = "/work";
|
|
22
|
+
const NODE_DIR = "/work/.anet/nodes/demo-node";
|
|
23
|
+
const CONFIG = `${NODE_DIR}/config.json`;
|
|
24
|
+
|
|
25
|
+
let db: { get: (sql: string, ...params: unknown[]) => { task: string | null } | null };
|
|
26
|
+
let hub: { port: number; stop: (close?: boolean) => void } | null = null;
|
|
27
|
+
let BASE = "";
|
|
28
|
+
let NET = "";
|
|
29
|
+
let node: { alias: string; nodeId: string; token: string };
|
|
30
|
+
|
|
31
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
32
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${token}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
Accept: "application/json, text/event-stream",
|
|
38
|
+
"MCP-Protocol-Version": "2025-03-26",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
41
|
+
});
|
|
42
|
+
const raw = await res.text();
|
|
43
|
+
const data = raw.split("\n").filter((line) => line.startsWith("data:"));
|
|
44
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
45
|
+
return JSON.parse(payload.result.content[0].text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sessionTask(): string | null {
|
|
49
|
+
return db.get("SELECT task FROM sessions WHERE resume_id = ?1", `sdk-${node.nodeId}`)?.task ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
({ db } = await import("./db.js"));
|
|
54
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
55
|
+
const boss = register(`cwd_boss_${stamp}`, PW);
|
|
56
|
+
expect(boss.ok).toBe(true);
|
|
57
|
+
NET = boss.network_id!;
|
|
58
|
+
const alias = `demo-node-${stamp}`;
|
|
59
|
+
const nodeId = `n_cwd_${stamp}`;
|
|
60
|
+
const minted = createNetworkTokenForNode(boss.user!.user_id, NET, alias, nodeId);
|
|
61
|
+
expect(minted.ok).toBe(true);
|
|
62
|
+
node = { alias, nodeId, token: minted.token! };
|
|
63
|
+
const mod = await import("./server.js");
|
|
64
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
65
|
+
expect(hub.port).not.toBe(9200);
|
|
66
|
+
expect(hub.port).toBeGreaterThan(0);
|
|
67
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
68
|
+
}, 30_000);
|
|
69
|
+
|
|
70
|
+
afterAll(() => {
|
|
71
|
+
try { hub?.stop(true); } catch { /* already stopped */ }
|
|
72
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch { /* temp dir */ }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("#667 restart from the workspace root clears the cwd warning", () => {
|
|
76
|
+
test("wrong directory writes the warning; the right directory sends \"\" and the hub row is empty", async () => {
|
|
77
|
+
const resume = `sdk-${node.nodeId}`;
|
|
78
|
+
const wrong = reportedTask({ configPath: CONFIG, cwd: NODE_DIR, inFlight: 0, status: "idle" });
|
|
79
|
+
expect(wrong).toContain("目录不一致");
|
|
80
|
+
const started = await tool(node.token, "report_status", {
|
|
81
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: wrong,
|
|
82
|
+
});
|
|
83
|
+
expect(started.ok).toBe(true);
|
|
84
|
+
expect(sessionTask()).toBe(wrong);
|
|
85
|
+
|
|
86
|
+
const kept = await tool(node.token, "report_status", {
|
|
87
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET,
|
|
88
|
+
});
|
|
89
|
+
expect(kept.ok).toBe(true);
|
|
90
|
+
expect(sessionTask()).toBe(wrong);
|
|
91
|
+
|
|
92
|
+
const idle = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" });
|
|
93
|
+
expect(idle).toBeUndefined();
|
|
94
|
+
const cleared = idle ?? "";
|
|
95
|
+
expect(cleared).toBe("");
|
|
96
|
+
const fixed = await tool(node.token, "report_status", {
|
|
97
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: cleared,
|
|
98
|
+
});
|
|
99
|
+
expect(fixed.ok).toBe(true);
|
|
100
|
+
expect(sessionTask()).toBe("");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("after startup, a finished task's description stays on the hub", async () => {
|
|
104
|
+
const resume = `sdk-${node.nodeId}`;
|
|
105
|
+
const description = "BOARD667-FINISHED-TASK";
|
|
106
|
+
const startup = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" }) ?? "";
|
|
107
|
+
expect(startup).toBe("");
|
|
108
|
+
const registered = await tool(node.token, "report_status", {
|
|
109
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: startup,
|
|
110
|
+
});
|
|
111
|
+
expect(registered.ok).toBe(true);
|
|
112
|
+
expect(sessionTask()).toBe("");
|
|
113
|
+
|
|
114
|
+
const working = reportedTask({
|
|
115
|
+
configPath: CONFIG, cwd: ROOT, inFlight: 1, status: "working", task: description,
|
|
116
|
+
});
|
|
117
|
+
expect(working).toBe(description);
|
|
118
|
+
const ran = await tool(node.token, "report_status", {
|
|
119
|
+
resume_id: resume, alias: node.alias, status: "working", node_id: node.nodeId, network_id: NET, task: working,
|
|
120
|
+
});
|
|
121
|
+
expect(ran.ok).toBe(true);
|
|
122
|
+
expect(sessionTask()).toBe(description);
|
|
123
|
+
|
|
124
|
+
const finished = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" });
|
|
125
|
+
expect(finished).toBeUndefined();
|
|
126
|
+
const done = await tool(node.token, "report_status", {
|
|
127
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET,
|
|
128
|
+
});
|
|
129
|
+
expect(done.ok).toBe(true);
|
|
130
|
+
expect(sessionTask()).toBe(description);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -73,10 +73,21 @@ beforeAll(async () => {
|
|
|
73
73
|
// 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
|
|
74
74
|
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
|
|
75
75
|
NODE = `n_ev_${stamp}`;
|
|
76
|
-
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "ev-node", "ev-node", NET]);
|
|
77
76
|
const ntok = createNetworkTokenForNode(admin.id, NET, "ev-node");
|
|
78
77
|
expect(ntok.ok).toBe(true);
|
|
79
78
|
nodeToken = ntok.token!;
|
|
79
|
+
// Real legacy-client order: mint by name, then register; never fill owner.
|
|
80
|
+
const registration = await fetch(`${BASE}/mcp`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { Authorization: `Bearer ${nodeToken}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
83
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: {
|
|
84
|
+
resume_id: `ev-session-${stamp}`, alias: "ev-node", status: "idle", node_id: NODE,
|
|
85
|
+
} } }),
|
|
86
|
+
});
|
|
87
|
+
expect(registration.status).toBe(200);
|
|
88
|
+
await registration.text();
|
|
89
|
+
expect(db.get<{owner_user_id: string | null}>("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE)?.owner_user_id).toBeNull();
|
|
90
|
+
expect(db.get<{bound_node_id: string | null}>("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id!)?.bound_node_id).toBe(NODE);
|
|
80
91
|
}, 30_000);
|
|
81
92
|
|
|
82
93
|
afterAll(() => {
|
|
@@ -92,10 +92,15 @@ beforeAll(async () => {
|
|
|
92
92
|
// 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
|
|
93
93
|
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
|
|
94
94
|
NODE = `n_le_${stamp}`;
|
|
95
|
-
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "le-node", "le-node", NET]);
|
|
96
95
|
const ntok = createNetworkTokenForNode(admin.id, NET, "le-node");
|
|
97
96
|
expect(ntok.ok).toBe(true);
|
|
98
97
|
nodeToken = ntok.token!;
|
|
98
|
+
// Real legacy-client order, with no owner backfill or administrator claim.
|
|
99
|
+
expect((await mcp(nodeToken, "report_status", {
|
|
100
|
+
resume_id: `le-session-${stamp}`, alias: "le-node", status: "idle", node_id: NODE,
|
|
101
|
+
})).data.ok).toBe(true);
|
|
102
|
+
expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE).owner_user_id).toBeNull();
|
|
103
|
+
expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id).bound_node_id).toBe(NODE);
|
|
99
104
|
}, 30_000);
|
|
100
105
|
|
|
101
106
|
afterAll(() => {
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { db, logTaskEvent } from "./db.js";
|
|
2
|
+
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
3
|
+
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
2
4
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
3
5
|
import { assertNodeHealthy } from "./node-health-guard.js";
|
|
4
6
|
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
@@ -366,7 +368,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
366
368
|
// Dispatch is not completion. Keep the run open until the exact bound
|
|
367
369
|
// task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
|
|
368
370
|
db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
|
|
369
|
-
db.run(
|
|
371
|
+
db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(row.task_content), node.node_id, row.network_id]);
|
|
370
372
|
db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
|
|
371
373
|
if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
|
|
372
374
|
createdTaskId = taskId;
|
|
@@ -517,7 +519,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
517
519
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
518
520
|
const content = typeof body.task === "string" ? body.task.trim() : "";
|
|
519
521
|
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
520
|
-
if (!content || content.length >
|
|
522
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
521
523
|
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
522
524
|
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
523
525
|
const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
|
|
@@ -604,7 +606,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
604
606
|
const content = body.task === undefined ? row.task_content : String(body.task).trim();
|
|
605
607
|
const priority = body.priority === undefined ? row.priority : String(body.priority);
|
|
606
608
|
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
607
|
-
if (!content || content.length >
|
|
609
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
608
610
|
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
609
611
|
const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
|
|
610
612
|
const parsed = body.schedule === undefined
|