@songsid/agend 2.3.0-beta.19 → 2.3.0-beta.20
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/dist/agent-cli-instructions.md +4 -0
- package/dist/agent-cli.js +25 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/config-validator.js +19 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +7 -0
- package/dist/cross-instance-notice.js +10 -0
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.js +4 -0
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.js +5 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +26 -0
- package/dist/fleet-manager.js +115 -14
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +25 -1
- package/dist/instance-config-impact.js +4 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/log-steps.d.ts +1 -0
- package/dist/log-steps.js +15 -0
- package/dist/log-steps.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +20 -4
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +33 -0
- package/dist/outbound-schemas.js +25 -8
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +5 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/scheduler/db.d.ts +40 -13
- package/dist/scheduler/db.js +123 -89
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +135 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +650 -0
- package/dist/scheduler/task-board.js +1938 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/search/search-docs.d.ts +58 -0
- package/dist/search/search-docs.js +63 -0
- package/dist/search/search-docs.js.map +1 -0
- package/dist/search/search-index.d.ts +141 -0
- package/dist/search/search-index.js +440 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search/search-store.d.ts +122 -0
- package/dist/search/search-store.js +236 -0
- package/dist/search/search-store.js.map +1 -0
- package/dist/search/search-worker.d.ts +1 -0
- package/dist/search/search-worker.js +163 -0
- package/dist/search/search-worker.js.map +1 -0
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +154 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +44 -0
- package/dist/task-board-waker.js +128 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/ui/fleet-task-card.js +436 -0
- package/dist/ui/fleet-task-common.js +448 -0
- package/dist/ui/fleet-tasks.js +535 -0
- package/dist/ui/panel-chat.js +8 -0
- package/dist/ui/panel-fleet.js +21 -65
- package/dist/ui/search-dialog.js +349 -0
- package/dist/ui/shared/app-i18n.js +3 -0
- package/dist/ui/shared/app-shell.js +43 -1
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +270 -2
- package/dist/ui/shared/app.js +3 -1
- package/dist/ui/shared/ui-toast.js +16 -3
- package/dist/web-api.d.ts +14 -4
- package/dist/web-api.js +408 -10
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +2 -0
- package/dist/web-chat-history.js +2 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/workflow-templates/default.md +3 -0
- package/package.json +1 -1
package/dist/web-api.js
CHANGED
|
@@ -12,6 +12,7 @@ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
|
|
|
12
12
|
import { canonicalText } from "./web-theme.js";
|
|
13
13
|
import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
|
|
14
14
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
15
|
+
import { crossInstanceVisibility } from "./cross-instance-notice.js";
|
|
15
16
|
import { permitWebContinuation } from "./web-continuation.js";
|
|
16
17
|
/**
|
|
17
18
|
* Web UI HTTP API handler.
|
|
@@ -30,6 +31,7 @@ import { fileURLToPath } from "node:url";
|
|
|
30
31
|
import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
|
|
31
32
|
import { scheduleView } from "./web-schedules.js";
|
|
32
33
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
34
|
+
import { CARD_PRIORITIES, CARD_SIZES, CARD_STATUSES, TaskBoardError } from "./scheduler/task-board.js";
|
|
33
35
|
import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
|
|
34
36
|
import { z } from "zod";
|
|
35
37
|
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
@@ -45,6 +47,8 @@ import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
|
45
47
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
46
48
|
import { resolveTz } from "./tz-utils.js";
|
|
47
49
|
import { filesPlayInChat } from "./web-effect.js";
|
|
50
|
+
import { QUERY_MAX_CHARS, RESULT_MAX, SEARCH_SOURCES } from "./search/search-store.js";
|
|
51
|
+
import { xmsgShown } from "./search/search-docs.js";
|
|
48
52
|
// ── Strict public-facing schemas ────────────────────────────────────────────
|
|
49
53
|
// web-api endpoints must reject unknown fields so the dashboard cannot inject
|
|
50
54
|
// internal-only flags that would reach handleCreate/scheduler/config writers.
|
|
@@ -59,11 +63,70 @@ const TaskUpdateSchema = z.object({
|
|
|
59
63
|
action: z.enum(["claim", "complete", "update"]).optional(),
|
|
60
64
|
assignee: z.string().max(128).optional(),
|
|
61
65
|
result: z.string().max(MAX_TEXT).optional(),
|
|
62
|
-
status:
|
|
66
|
+
// A status the task board knows: each carries its rules now (scheduler/task-board.ts).
|
|
67
|
+
status: z.enum(CARD_STATUSES).optional(),
|
|
63
68
|
title: z.string().max(512).optional(),
|
|
64
69
|
description: z.string().max(MAX_TEXT).optional(),
|
|
65
70
|
priority: z.string().max(64).optional(),
|
|
66
71
|
}).strict();
|
|
72
|
+
// The task board (scheduler/task-board.ts). Coarse bounds here; the board checks each field's own limit.
|
|
73
|
+
const BOARD_TEXT = 8_192;
|
|
74
|
+
const CardRef = z.string().min(1).max(64);
|
|
75
|
+
/** Who handed a card out: an instance, the person writing ({ kind: "person" }), or nobody. */
|
|
76
|
+
const DispatcherSchema = z.union([z.object({ kind: z.literal("instance"), name: z.string().min(1).max(128) }).strict(), z.object({ kind: z.literal("person") }).strict()]).nullable();
|
|
77
|
+
const CardCreateSchema = z.object({
|
|
78
|
+
kind: z.enum(["main", "sub"]).optional(),
|
|
79
|
+
project: z.union([z.number().int(), z.string().max(256)]).nullable().optional(),
|
|
80
|
+
parent: CardRef.nullable().optional(),
|
|
81
|
+
title: z.string().max(512),
|
|
82
|
+
goal: z.string().max(BOARD_TEXT).optional(),
|
|
83
|
+
scope: z.string().max(BOARD_TEXT).optional(),
|
|
84
|
+
acceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
85
|
+
context: z.string().max(BOARD_TEXT).optional(),
|
|
86
|
+
size: z.enum(CARD_SIZES).nullable().optional(),
|
|
87
|
+
priority: z.enum(CARD_PRIORITIES).optional(),
|
|
88
|
+
dependsOn: z.array(CardRef).max(50).optional(),
|
|
89
|
+
assignee: z.string().max(128).nullable().optional(),
|
|
90
|
+
dispatcher: DispatcherSchema.optional(),
|
|
91
|
+
needsReview: z.boolean().optional(),
|
|
92
|
+
}).strict();
|
|
93
|
+
const CardPatchSchema = z.object({
|
|
94
|
+
version: z.number().int().optional(),
|
|
95
|
+
title: z.string().max(512).optional(),
|
|
96
|
+
goal: z.string().max(BOARD_TEXT).optional(),
|
|
97
|
+
scope: z.string().max(BOARD_TEXT).optional(),
|
|
98
|
+
context: z.string().max(BOARD_TEXT).optional(),
|
|
99
|
+
state: z.string().max(BOARD_TEXT).optional(),
|
|
100
|
+
result: z.string().max(BOARD_TEXT).optional(),
|
|
101
|
+
blockedReason: z.string().max(BOARD_TEXT).optional(),
|
|
102
|
+
size: z.enum(CARD_SIZES).nullable().optional(),
|
|
103
|
+
priority: z.enum(CARD_PRIORITIES).optional(),
|
|
104
|
+
assignee: z.string().max(128).nullable().optional(),
|
|
105
|
+
dispatcher: DispatcherSchema.optional(),
|
|
106
|
+
needsReview: z.boolean().optional(),
|
|
107
|
+
dependsOn: z.array(CardRef).max(50).optional(),
|
|
108
|
+
acceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
109
|
+
addAcceptance: z.array(z.string().max(1024)).max(50).optional(),
|
|
110
|
+
check: z.array(z.number().int()).max(50).optional(),
|
|
111
|
+
uncheck: z.array(z.number().int()).max(50).optional(),
|
|
112
|
+
projectId: z.number().int().nullable().optional(),
|
|
113
|
+
log: z.string().max(BOARD_TEXT).optional(),
|
|
114
|
+
comment: z.string().max(MAX_TEXT).optional(),
|
|
115
|
+
}).strict();
|
|
116
|
+
const CardMoveSchema = z.object({
|
|
117
|
+
to: z.enum(CARD_STATUSES),
|
|
118
|
+
reason: z.string().max(BOARD_TEXT).optional(),
|
|
119
|
+
result: z.string().max(BOARD_TEXT).optional(),
|
|
120
|
+
comment: z.string().max(MAX_TEXT).optional(),
|
|
121
|
+
version: z.number().int().optional(),
|
|
122
|
+
}).strict();
|
|
123
|
+
const CardReorderSchema = z.object({ after: CardRef.nullable().optional(), before: CardRef.nullable().optional() }).strict();
|
|
124
|
+
const ProjectCreateSchema = z.object({ name: z.string().max(256), description: z.string().max(BOARD_TEXT).optional() }).strict();
|
|
125
|
+
const ProjectPatchSchema = z.object({
|
|
126
|
+
name: z.string().max(256).optional(),
|
|
127
|
+
description: z.string().max(BOARD_TEXT).optional(),
|
|
128
|
+
archived: z.boolean().optional(),
|
|
129
|
+
}).strict();
|
|
67
130
|
const TeamCreateSchema = z.object({
|
|
68
131
|
name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
|
|
69
132
|
members: z.array(z.string().min(1).max(256)).min(1).max(256),
|
|
@@ -176,10 +239,19 @@ async function parseBody(req) {
|
|
|
176
239
|
}
|
|
177
240
|
/** Send JSON response. */
|
|
178
241
|
function json(res, status, data) {
|
|
242
|
+
jsonText(res, status, JSON.stringify(data));
|
|
243
|
+
}
|
|
244
|
+
/** A JSON answer already serialized (a body kept for reuse). */
|
|
245
|
+
function jsonText(res, status, body) {
|
|
179
246
|
res.setHeader("Content-Type", "application/json");
|
|
180
247
|
res.writeHead(status);
|
|
181
|
-
res.end(
|
|
248
|
+
res.end(body);
|
|
182
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* #2296: the last dashboard board read, serialized, per board: its key (revision, done=all, minute) and body. One per
|
|
252
|
+
* board (the newest revision replaces the previous), so it holds one board's JSON at most.
|
|
253
|
+
*/
|
|
254
|
+
const boardBodies = new WeakMap();
|
|
183
255
|
/**
|
|
184
256
|
* #1630 (#1635 review): an answer that waited on something (a list, a download) is sent only if the request is still
|
|
185
257
|
* allowed — the session not revoked meanwhile, a public link's exposure still current. Otherwise 401, and nothing.
|
|
@@ -1048,6 +1120,57 @@ function webAuthorityOf(req, url, ctx, instance) {
|
|
|
1048
1120
|
* header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
|
|
1049
1121
|
* one). Equal before and after an await: the same admission still holds. Never touches the session.
|
|
1050
1122
|
*/
|
|
1123
|
+
/** #2293: an admin's admission — the header token or an admin session — else null (a guest's or a read-only session's). */
|
|
1124
|
+
function adminAdmissionOf(req, url, ctx) {
|
|
1125
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1126
|
+
if (v.kind !== "allow" || (v.via === "session" && v.session.tier !== "admin"))
|
|
1127
|
+
return null;
|
|
1128
|
+
return admissionOf(req, url, ctx);
|
|
1129
|
+
}
|
|
1130
|
+
const SEARCH_LIMIT_DEFAULT = 50;
|
|
1131
|
+
/**
|
|
1132
|
+
* #2293: GET /ui/search's parameters: q (1–QUERY_MAX_CHARS), instance (repeated), team (its members join `instance`),
|
|
1133
|
+
* source (repeated: chat, xmsg, task), since / until (epoch ms), mine=1, limit (1–RESULT_MAX).
|
|
1134
|
+
*/
|
|
1135
|
+
function parseSearchQuery(url, ctx) {
|
|
1136
|
+
const p = url.searchParams;
|
|
1137
|
+
const q = (p.get("q") ?? "").trim();
|
|
1138
|
+
if (!q || q.length > QUERY_MAX_CHARS)
|
|
1139
|
+
return { error: `q required (1-${QUERY_MAX_CHARS} chars)` };
|
|
1140
|
+
const instances = new Set(p.getAll("instance").filter(n => n.length > 0 && n.length <= 128));
|
|
1141
|
+
const team = p.get("team");
|
|
1142
|
+
if (team) {
|
|
1143
|
+
const def = ctx.fleetConfig?.teams && Object.hasOwn(ctx.fleetConfig.teams, team) ? ctx.fleetConfig.teams[team] : undefined;
|
|
1144
|
+
if (!def)
|
|
1145
|
+
return { error: "unknown team" };
|
|
1146
|
+
for (const member of def.members ?? [])
|
|
1147
|
+
instances.add(member);
|
|
1148
|
+
if (!instances.size)
|
|
1149
|
+
return { error: "team has no members" };
|
|
1150
|
+
}
|
|
1151
|
+
const sources = p.getAll("source");
|
|
1152
|
+
if (sources.some(s => !SEARCH_SOURCES.includes(s)))
|
|
1153
|
+
return { error: `source must be one of: ${SEARCH_SOURCES.join(", ")}` };
|
|
1154
|
+
const time = (name) => {
|
|
1155
|
+
const raw = p.get(name);
|
|
1156
|
+
if (raw === null || raw === "")
|
|
1157
|
+
return undefined;
|
|
1158
|
+
const n = Number(raw);
|
|
1159
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
1160
|
+
};
|
|
1161
|
+
const since = time("since"), until = time("until");
|
|
1162
|
+
if (since === null || until === null)
|
|
1163
|
+
return { error: "since/until must be epoch milliseconds" };
|
|
1164
|
+
const limitRaw = p.get("limit");
|
|
1165
|
+
const limit = limitRaw === null ? SEARCH_LIMIT_DEFAULT : Number(limitRaw);
|
|
1166
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > RESULT_MAX)
|
|
1167
|
+
return { error: `limit must be 1-${RESULT_MAX}` };
|
|
1168
|
+
return {
|
|
1169
|
+
filters: { q, limit, ...(instances.size ? { instances: [...instances] } : {}), ...(sources.length ? { sources: sources } : {}),
|
|
1170
|
+
...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}) },
|
|
1171
|
+
mine: p.get("mine") === "1",
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1051
1174
|
function admissionOf(req, url, ctx) {
|
|
1052
1175
|
const gateway = gatewayRequestContext(req);
|
|
1053
1176
|
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
@@ -1063,6 +1186,61 @@ function webPersonOr401(req, res, url, ctx) {
|
|
|
1063
1186
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1064
1187
|
return p;
|
|
1065
1188
|
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Who a dashboard request acts as on the task board. The person only through a signed-in local session: the header
|
|
1191
|
+
* token is the CLI's or a script's (any agent on this machine can read the token file), and the temporary public link
|
|
1192
|
+
* carries no verified authority (docs/web-dashboard.md) — either is a script, held to the rules an instance is held to,
|
|
1193
|
+
* so neither confirms work waiting for the person.
|
|
1194
|
+
*/
|
|
1195
|
+
function boardActor(req, url, ctx, p) {
|
|
1196
|
+
if (gatewayRequestContext(req))
|
|
1197
|
+
return { kind: "script", name: "public-link" };
|
|
1198
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1199
|
+
return v.kind === "allow" && v.via === "session" ? { kind: "person", name: p.name } : { kind: "script", name: "web-token" };
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* A board refusal as the dashboard reads it: the board's sentence, its code and the status that fits, and — when the
|
|
1203
|
+
* person can meet it — the key and values the page says it with in its own language.
|
|
1204
|
+
*/
|
|
1205
|
+
function boardError(res, err) {
|
|
1206
|
+
if (res.headersSent)
|
|
1207
|
+
return;
|
|
1208
|
+
if (err instanceof TaskBoardError) {
|
|
1209
|
+
const status = err.code === "not_found" ? 404 : err.code === "forbidden" ? 403 : err.code === "conflict" ? 409 : 400;
|
|
1210
|
+
json(res, status, { error: err.message, code: err.code, ...(err.key ? { key: err.key, args: err.args } : {}) });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
json(res, 400, { error: err.message });
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* One board write from the dashboard: the body read and checked, the request still allowed, the person asked for
|
|
1217
|
+
* again now the body is in (#1827), then the write as that person. The board's own routes take a signed-in session
|
|
1218
|
+
* only (instances use their task tool, scripts the old /ui/tasks routes).
|
|
1219
|
+
*/
|
|
1220
|
+
function boardWrite(req, res, url, ctx, board, schema, write) {
|
|
1221
|
+
void (async () => {
|
|
1222
|
+
try {
|
|
1223
|
+
const body = await parseBody(req);
|
|
1224
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1225
|
+
return;
|
|
1226
|
+
const parsed = parseOrReject(schema, body, res);
|
|
1227
|
+
if (!parsed)
|
|
1228
|
+
return;
|
|
1229
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
1230
|
+
if (!person)
|
|
1231
|
+
return;
|
|
1232
|
+
const actor = boardActor(req, url, ctx, person);
|
|
1233
|
+
if (actor.kind !== "person") {
|
|
1234
|
+
json(res, 403, { error: "The task board takes changes from a signed-in dashboard", code: "forbidden", key: "signed_in_only" });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
json(res, 200, write(parsed, actor));
|
|
1238
|
+
}
|
|
1239
|
+
catch (err) {
|
|
1240
|
+
boardError(res, err);
|
|
1241
|
+
}
|
|
1242
|
+
})();
|
|
1243
|
+
}
|
|
1066
1244
|
/** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
|
|
1067
1245
|
function profileBody(p) {
|
|
1068
1246
|
return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
|
|
@@ -2321,6 +2499,11 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2321
2499
|
json(res, 200, { tasks });
|
|
2322
2500
|
return true;
|
|
2323
2501
|
}
|
|
2502
|
+
// #2246: the public link writes no task — the old list's create and update are board writes now (see below).
|
|
2503
|
+
if (method === "POST" && (path === "/ui/tasks" || /^\/ui\/tasks\/[^/]+$/.test(path)) && gatewayRequestContext(req)) {
|
|
2504
|
+
json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
|
|
2505
|
+
return true;
|
|
2506
|
+
}
|
|
2324
2507
|
if (method === "POST" && path === "/ui/tasks") {
|
|
2325
2508
|
if (!ctx.scheduler) {
|
|
2326
2509
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
@@ -2343,7 +2526,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2343
2526
|
priority: parsed.priority,
|
|
2344
2527
|
assignee: parsed.assignee,
|
|
2345
2528
|
created_by: person.name, // #1827
|
|
2346
|
-
});
|
|
2529
|
+
}, boardActor(req, url, ctx, person));
|
|
2347
2530
|
json(res, 200, task);
|
|
2348
2531
|
}
|
|
2349
2532
|
catch (err) {
|
|
@@ -2352,15 +2535,144 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2352
2535
|
})();
|
|
2353
2536
|
return true;
|
|
2354
2537
|
}
|
|
2538
|
+
// ── Task board (scheduler/task-board.ts) ──────────────
|
|
2539
|
+
// Reads are the session's; every write is the signed-in person's, through the same rules an instance's tools use.
|
|
2540
|
+
if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
|
|
2541
|
+
const board = ctx.scheduler?.db.board;
|
|
2542
|
+
if (!board) {
|
|
2543
|
+
json(res, 503, { error: "The task board is not available" });
|
|
2544
|
+
return true;
|
|
2545
|
+
}
|
|
2546
|
+
// The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and
|
|
2547
|
+
// assigning are the person's authority, which the public link never carries (docs/web-dashboard.md).
|
|
2548
|
+
const gateway = gatewayRequestContext(req);
|
|
2549
|
+
if (gateway && method !== "GET") {
|
|
2550
|
+
json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
|
|
2551
|
+
return true;
|
|
2552
|
+
}
|
|
2553
|
+
// …nor shows the cards of instances kept out of sight (cross_instance_visibility: hidden).
|
|
2554
|
+
const fleet = ctx.fleetConfig;
|
|
2555
|
+
const hiddenName = (n) => !!gateway && typeof n === "string" && !!n && !!ctx.fleetConfig?.instances?.[n] && crossInstanceVisibility(fleet, n) === "hidden";
|
|
2556
|
+
const hidden = (c) => [c.assignee, c.createdBy, c.dispatcher?.name].some(hiddenName);
|
|
2557
|
+
// #2303 review: what a visible card shows of the cards around it follows the same rule — its children, its main
|
|
2558
|
+
// task (a search hit's too) — and so do the words and the history a hidden instance left on it.
|
|
2559
|
+
// …and a hidden instance's words on a visible card are not counted, named or found there (#2304 review).
|
|
2560
|
+
const scope = { leaveOutAuthors: gateway ? Object.keys(ctx.fleetConfig?.instances ?? {}).filter(hiddenName) : [] };
|
|
2561
|
+
const hiddenRef = (ref) => { const c = ref ? board.findCard(ref) : undefined; return !!c && hidden(c); };
|
|
2562
|
+
const mentionsHidden = (v) => (typeof v === "string" ? hiddenName(v) : !!v && typeof v === "object" && Object.values(v).some(mentionsHidden));
|
|
2563
|
+
const shown = (d) => !gateway ? d : {
|
|
2564
|
+
...d,
|
|
2565
|
+
parent: d.card.parentId && hiddenRef(d.card.parentId) ? null : d.parent,
|
|
2566
|
+
children: d.children.filter((c) => !hidden(c)),
|
|
2567
|
+
comments: d.comments.filter((e) => !hiddenName(e.author)),
|
|
2568
|
+
logs: d.logs.filter((e) => !hiddenName(e.author)),
|
|
2569
|
+
history: d.history.filter((e) => !hiddenName(e.actor) && !mentionsHidden(e.data)),
|
|
2570
|
+
};
|
|
2571
|
+
const card = /^\/ui\/tasks\/cards\/([^/]+)(?:\/(move|reorder))?$/.exec(path);
|
|
2572
|
+
const project = /^\/ui\/tasks\/projects\/(\d+)$/.exec(path);
|
|
2573
|
+
try {
|
|
2574
|
+
const ref = card ? decodeURIComponent(card[1]) : "";
|
|
2575
|
+
if (method === "GET" && path === "/ui/tasks/board") {
|
|
2576
|
+
// A page that has this revision already is told so, not sent the board again.
|
|
2577
|
+
const rev = board.revision();
|
|
2578
|
+
if (url.searchParams.get("rev") === String(rev)) {
|
|
2579
|
+
json(res, 200, { rev, unchanged: true });
|
|
2580
|
+
return true;
|
|
2581
|
+
}
|
|
2582
|
+
const allDone = url.searchParams.get("done") === "all";
|
|
2583
|
+
// #2296: every open Tasks tab reads the board again after each change; on the dashboard they all get the same
|
|
2584
|
+
// board for a revision, so it is built once per (revision, done=all, minute) — the minute for the 14-day
|
|
2585
|
+
// window. The public link's reads are filtered per request (hidden instances) and are built each time.
|
|
2586
|
+
const key = gateway ? null : `${rev}:${allDone ? 1 : 0}:${Math.floor(Date.now() / 60_000)}`;
|
|
2587
|
+
const kept = key ? boardBodies.get(board) : undefined;
|
|
2588
|
+
if (kept && kept.key === key) {
|
|
2589
|
+
jsonText(res, 200, kept.body);
|
|
2590
|
+
return true;
|
|
2591
|
+
}
|
|
2592
|
+
const cards = board.boardCards(undefined, allDone).filter((c) => !hidden(c));
|
|
2593
|
+
const body = JSON.stringify({ rev, cards: board.views(cards, scope), projects: board.listProjects(true), stats: board.projectStats() });
|
|
2594
|
+
if (key)
|
|
2595
|
+
boardBodies.set(board, { key, body });
|
|
2596
|
+
jsonText(res, 200, body);
|
|
2597
|
+
return true;
|
|
2598
|
+
}
|
|
2599
|
+
if (method === "GET" && path === "/ui/tasks/search") {
|
|
2600
|
+
const names = new Map(board.listProjects(true).map((p) => [p.id, p.name]));
|
|
2601
|
+
const hits = board.search(url.searchParams.get("q") ?? "", Number(url.searchParams.get("limit")) || 20, scope).filter((h) => !hidden(h.card));
|
|
2602
|
+
const views = board.views(hits.map((h) => h.card), scope);
|
|
2603
|
+
const results = hits.map(({ card: c, match }, i) => ({
|
|
2604
|
+
card: views[i],
|
|
2605
|
+
match,
|
|
2606
|
+
project: c.projectId === null ? null : names.get(c.projectId) ?? null,
|
|
2607
|
+
parentTitle: c.parent && !hiddenRef(c.parent) ? board.findCard(c.parent)?.title ?? null : null,
|
|
2608
|
+
}));
|
|
2609
|
+
json(res, 200, { results });
|
|
2610
|
+
return true;
|
|
2611
|
+
}
|
|
2612
|
+
if (method === "GET" && card && !card[2]) {
|
|
2613
|
+
const found = board.getCard(ref);
|
|
2614
|
+
if (hidden(found)) {
|
|
2615
|
+
json(res, 404, { error: "no such card", code: "not_found" });
|
|
2616
|
+
return true;
|
|
2617
|
+
}
|
|
2618
|
+
json(res, 200, shown(board.detail(ref, scope)));
|
|
2619
|
+
return true;
|
|
2620
|
+
}
|
|
2621
|
+
if (method === "POST" && path === "/ui/tasks/cards") {
|
|
2622
|
+
boardWrite(req, res, url, ctx, board, CardCreateSchema, (spec, person) => {
|
|
2623
|
+
const { cards, said } = board.createCards(person, { cards: [spec] });
|
|
2624
|
+
return { card: board.view(cards[0]), notes: said };
|
|
2625
|
+
});
|
|
2626
|
+
return true;
|
|
2627
|
+
}
|
|
2628
|
+
if (method === "POST" && card) {
|
|
2629
|
+
if (card[2] === "move") {
|
|
2630
|
+
boardWrite(req, res, url, ctx, board, CardMoveSchema, ({ to, ...move }, person) => {
|
|
2631
|
+
const { card: moved, said } = board.moveCard(person, ref, to, move);
|
|
2632
|
+
return { card: board.view(moved), notes: said };
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
else if (card[2] === "reorder") {
|
|
2636
|
+
boardWrite(req, res, url, ctx, board, CardReorderSchema, ({ after, before }, person) => ({ card: board.view(board.reorder(person, ref, after ?? null, before ?? null)) }));
|
|
2637
|
+
}
|
|
2638
|
+
else {
|
|
2639
|
+
boardWrite(req, res, url, ctx, board, CardPatchSchema, (patch, person) => {
|
|
2640
|
+
const { card: updated, said } = board.updateCard(person, ref, patch);
|
|
2641
|
+
return { card: board.view(updated), notes: said };
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
return true;
|
|
2645
|
+
}
|
|
2646
|
+
if (method === "POST" && path === "/ui/tasks/projects") {
|
|
2647
|
+
boardWrite(req, res, url, ctx, board, ProjectCreateSchema, (input, person) => ({ project: board.createProject(person, input) }));
|
|
2648
|
+
return true;
|
|
2649
|
+
}
|
|
2650
|
+
if (method === "POST" && project) {
|
|
2651
|
+
boardWrite(req, res, url, ctx, board, ProjectPatchSchema, (patch, person) => ({ project: board.updateProject(person, Number(project[1]), patch) }));
|
|
2652
|
+
return true;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
catch (err) {
|
|
2656
|
+
// A malformed %-escape in the card's address is the caller's mistake, not the server's.
|
|
2657
|
+
if (err instanceof URIError) {
|
|
2658
|
+
json(res, 400, { error: "bad card reference", code: "invalid" });
|
|
2659
|
+
return true;
|
|
2660
|
+
}
|
|
2661
|
+
boardError(res, err);
|
|
2662
|
+
return true;
|
|
2663
|
+
}
|
|
2664
|
+
json(res, 404, { error: "Not found" });
|
|
2665
|
+
return true;
|
|
2666
|
+
}
|
|
2355
2667
|
const taskMatch = path.match(/^\/ui\/tasks\/(.+)$/);
|
|
2356
2668
|
if (method === "POST" && taskMatch) {
|
|
2357
2669
|
if (!ctx.scheduler) {
|
|
2358
2670
|
json(res, 500, { error: "Scheduler not initialized" });
|
|
2359
2671
|
return true;
|
|
2360
2672
|
}
|
|
2361
|
-
const id = decodeURIComponent(taskMatch[1]);
|
|
2362
2673
|
(async () => {
|
|
2363
2674
|
try {
|
|
2675
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
2364
2676
|
const body = await parseBody(req);
|
|
2365
2677
|
if (!permitWebContinuation(req, res, ctx))
|
|
2366
2678
|
return;
|
|
@@ -2368,19 +2680,22 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2368
2680
|
if (!parsed)
|
|
2369
2681
|
return;
|
|
2370
2682
|
let result;
|
|
2683
|
+
// #1827: the person, who is also the default assignee of a claim; the task board's review gate needs to know
|
|
2684
|
+
// a person (not an instance) made the move.
|
|
2685
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
2686
|
+
if (!person)
|
|
2687
|
+
return;
|
|
2688
|
+
const by = boardActor(req, url, ctx, person);
|
|
2371
2689
|
if (parsed.action === "claim") {
|
|
2372
|
-
|
|
2373
|
-
if (!parsed.assignee && !person)
|
|
2374
|
-
return;
|
|
2375
|
-
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
|
|
2690
|
+
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name, by);
|
|
2376
2691
|
}
|
|
2377
2692
|
else if (parsed.action === "complete") {
|
|
2378
|
-
result = ctx.scheduler.db.completeTask(id, parsed.result);
|
|
2693
|
+
result = ctx.scheduler.db.completeTask(id, parsed.result, by);
|
|
2379
2694
|
}
|
|
2380
2695
|
else {
|
|
2381
2696
|
// Strip action before passing remaining fields to updateTask
|
|
2382
2697
|
const { action: _a, ...rest } = parsed;
|
|
2383
|
-
result = ctx.scheduler.db.updateTask(id, rest);
|
|
2698
|
+
result = ctx.scheduler.db.updateTask(id, rest, by);
|
|
2384
2699
|
}
|
|
2385
2700
|
json(res, 200, result);
|
|
2386
2701
|
}
|
|
@@ -2508,6 +2823,89 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2508
2823
|
}
|
|
2509
2824
|
return true;
|
|
2510
2825
|
}
|
|
2826
|
+
// #2293: global search — an admin's only: the header token or an admin session. Never over the public link (the gateway
|
|
2827
|
+
// does not list it either), never a guest (not in GUEST_ROUTES). Asked on arrival and again before the answer is sent.
|
|
2828
|
+
if (method === "GET" && (path === "/ui/search" || path === "/ui/search/context")) {
|
|
2829
|
+
const refusal = () => {
|
|
2830
|
+
if (gatewayRequestContext(req))
|
|
2831
|
+
return [404, "search_not_over_public_link"];
|
|
2832
|
+
if (guestRequestOf(req))
|
|
2833
|
+
return [403, "guest_forbidden"];
|
|
2834
|
+
if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
|
|
2835
|
+
return [404, "search_unavailable"];
|
|
2836
|
+
return null;
|
|
2837
|
+
};
|
|
2838
|
+
const first = refusal();
|
|
2839
|
+
if (first) {
|
|
2840
|
+
json(res, first[0], { error: first[1] });
|
|
2841
|
+
return true;
|
|
2842
|
+
}
|
|
2843
|
+
const admitted = adminAdmissionOf(req, url, ctx);
|
|
2844
|
+
if (!admitted) {
|
|
2845
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2846
|
+
return true;
|
|
2847
|
+
}
|
|
2848
|
+
const index = ctx.searchIndex;
|
|
2849
|
+
// cross_instance_visibility "hidden" (fleet-traffic's rule): a message to or from such a name is not found.
|
|
2850
|
+
const visibility = () => ctx.searchVisibility?.() ?? { hidden: [] };
|
|
2851
|
+
const shown = (doc) => xmsgShown(doc, visibility());
|
|
2852
|
+
const finish = (status, body) => {
|
|
2853
|
+
const now = refusal();
|
|
2854
|
+
if (now)
|
|
2855
|
+
return json(res, now[0], { error: now[1] });
|
|
2856
|
+
if (adminAdmissionOf(req, url, ctx) !== admitted)
|
|
2857
|
+
return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2858
|
+
json(res, status, body);
|
|
2859
|
+
};
|
|
2860
|
+
const failed = (reason) => finish(reason === "timeout" || reason === "busy" ? 503 : 404, { error: `search_${reason}` });
|
|
2861
|
+
if (path === "/ui/search/context") {
|
|
2862
|
+
const key = url.searchParams.get("key") ?? "";
|
|
2863
|
+
if (!key || key.length > 300) {
|
|
2864
|
+
json(res, 400, { error: "key required" });
|
|
2865
|
+
return true;
|
|
2866
|
+
}
|
|
2867
|
+
void index.context(key).then(r => {
|
|
2868
|
+
if (!r.ok)
|
|
2869
|
+
return failed(r.reason);
|
|
2870
|
+
const doc = r.value?.doc;
|
|
2871
|
+
// A message to or from an instance that is hidden now is not shown, as in the search itself.
|
|
2872
|
+
if (!doc || !shown(doc))
|
|
2873
|
+
return finish(404, { error: "not_found" });
|
|
2874
|
+
finish(200, { ...r.value, building: r.building });
|
|
2875
|
+
}, () => failed("unavailable"));
|
|
2876
|
+
return true;
|
|
2877
|
+
}
|
|
2878
|
+
const parsed = parseSearchQuery(url, ctx);
|
|
2879
|
+
if ("error" in parsed) {
|
|
2880
|
+
json(res, 400, { error: parsed.error });
|
|
2881
|
+
return true;
|
|
2882
|
+
}
|
|
2883
|
+
if (parsed.mine) {
|
|
2884
|
+
const person = webPersonOf(req, url, ctx);
|
|
2885
|
+
if (!person) {
|
|
2886
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2887
|
+
return true;
|
|
2888
|
+
}
|
|
2889
|
+
parsed.filters.mine = [person.voterKey];
|
|
2890
|
+
}
|
|
2891
|
+
const v = visibility();
|
|
2892
|
+
if ("hidden" in v)
|
|
2893
|
+
parsed.filters.hidden = v.hidden;
|
|
2894
|
+
else
|
|
2895
|
+
parsed.filters.xmsgAmong = v.among;
|
|
2896
|
+
void index.query(parsed.filters).then(r => {
|
|
2897
|
+
if (!r.ok)
|
|
2898
|
+
return failed(r.reason);
|
|
2899
|
+
// A chat row still in the history opens in place (#m=); one that has left it opens as the index's copy.
|
|
2900
|
+
const history = ctx.webChatHistory;
|
|
2901
|
+
// The visibility as it is now (#2295 review): a name hidden while the query ran is left out too.
|
|
2902
|
+
const now = visibility();
|
|
2903
|
+
const hits = r.value.hits.filter(h => xmsgShown(h, now)).map(h => h.source !== "chat" ? h
|
|
2904
|
+
: { ...h, live: !!history?.find(h.instance, String(h.ref.boot ?? ""), Number(h.ref.id)) });
|
|
2905
|
+
finish(200, { hits, limited: r.value.limited, building: r.building });
|
|
2906
|
+
}, () => failed("unavailable"));
|
|
2907
|
+
return true;
|
|
2908
|
+
}
|
|
2511
2909
|
// #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
|
|
2512
2910
|
// usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
|
|
2513
2911
|
// from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
|