@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.
Files changed (81) hide show
  1. package/dist/agent-cli-instructions.md +4 -0
  2. package/dist/agent-cli.js +25 -0
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/channel/mcp-tools.js +2 -1
  5. package/dist/channel/mcp-tools.js.map +1 -1
  6. package/dist/config-validator.js +19 -1
  7. package/dist/config-validator.js.map +1 -1
  8. package/dist/cross-instance-notice.d.ts +7 -0
  9. package/dist/cross-instance-notice.js +10 -0
  10. package/dist/cross-instance-notice.js.map +1 -1
  11. package/dist/daemon.js +4 -0
  12. package/dist/daemon.js.map +1 -1
  13. package/dist/delivery-outbox.js +5 -0
  14. package/dist/delivery-outbox.js.map +1 -1
  15. package/dist/fleet-manager.d.ts +26 -0
  16. package/dist/fleet-manager.js +115 -14
  17. package/dist/fleet-manager.js.map +1 -1
  18. package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
  19. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +25 -1
  20. package/dist/instance-config-impact.js +4 -0
  21. package/dist/instance-config-impact.js.map +1 -1
  22. package/dist/log-steps.d.ts +1 -0
  23. package/dist/log-steps.js +15 -0
  24. package/dist/log-steps.js.map +1 -1
  25. package/dist/outbound-handlers.d.ts +3 -0
  26. package/dist/outbound-handlers.js +20 -4
  27. package/dist/outbound-handlers.js.map +1 -1
  28. package/dist/outbound-schemas.d.ts +33 -0
  29. package/dist/outbound-schemas.js +25 -8
  30. package/dist/outbound-schemas.js.map +1 -1
  31. package/dist/public-web-gateway.js +5 -2
  32. package/dist/public-web-gateway.js.map +1 -1
  33. package/dist/scheduler/db.d.ts +40 -13
  34. package/dist/scheduler/db.js +123 -89
  35. package/dist/scheduler/db.js.map +1 -1
  36. package/dist/scheduler/task-board-text.d.ts +26 -0
  37. package/dist/scheduler/task-board-text.js +135 -0
  38. package/dist/scheduler/task-board-text.js.map +1 -0
  39. package/dist/scheduler/task-board.d.ts +650 -0
  40. package/dist/scheduler/task-board.js +1938 -0
  41. package/dist/scheduler/task-board.js.map +1 -0
  42. package/dist/scheduler/types.d.ts +6 -1
  43. package/dist/search/search-docs.d.ts +58 -0
  44. package/dist/search/search-docs.js +63 -0
  45. package/dist/search/search-docs.js.map +1 -0
  46. package/dist/search/search-index.d.ts +141 -0
  47. package/dist/search/search-index.js +440 -0
  48. package/dist/search/search-index.js.map +1 -0
  49. package/dist/search/search-store.d.ts +122 -0
  50. package/dist/search/search-store.js +236 -0
  51. package/dist/search/search-store.js.map +1 -0
  52. package/dist/search/search-worker.d.ts +1 -0
  53. package/dist/search/search-worker.js +163 -0
  54. package/dist/search/search-worker.js.map +1 -0
  55. package/dist/task-board-tool.d.ts +25 -0
  56. package/dist/task-board-tool.js +154 -0
  57. package/dist/task-board-tool.js.map +1 -0
  58. package/dist/task-board-waker.d.ts +44 -0
  59. package/dist/task-board-waker.js +128 -0
  60. package/dist/task-board-waker.js.map +1 -0
  61. package/dist/types.d.ts +15 -0
  62. package/dist/ui/fleet-task-card.js +436 -0
  63. package/dist/ui/fleet-task-common.js +448 -0
  64. package/dist/ui/fleet-tasks.js +535 -0
  65. package/dist/ui/panel-chat.js +8 -0
  66. package/dist/ui/panel-fleet.js +21 -65
  67. package/dist/ui/search-dialog.js +349 -0
  68. package/dist/ui/shared/app-i18n.js +3 -0
  69. package/dist/ui/shared/app-shell.js +43 -1
  70. package/dist/ui/shared/app-stream.js +2 -1
  71. package/dist/ui/shared/app.css +270 -2
  72. package/dist/ui/shared/app.js +3 -1
  73. package/dist/ui/shared/ui-toast.js +16 -3
  74. package/dist/web-api.d.ts +14 -4
  75. package/dist/web-api.js +408 -10
  76. package/dist/web-api.js.map +1 -1
  77. package/dist/web-chat-history.d.ts +2 -0
  78. package/dist/web-chat-history.js +2 -0
  79. package/dist/web-chat-history.js.map +1 -1
  80. package/dist/workflow-templates/default.md +3 -0
  81. 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: z.string().max(64).optional(),
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(JSON.stringify(data));
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
- const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
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.