@worca/app 1.4.0 → 1.5.0-rc.3

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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
package/ui/server.mjs CHANGED
@@ -39,7 +39,7 @@ import { listProjects, addProject, removeProject, normalizeProjectPath, countPro
39
39
  import { renderIndexHtml, INDEX_THEME_ANCHOR } from '../src/core/index-html.mjs';
40
40
  import {
41
41
  getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
42
- rawProjectsRoot, defaultProjectsRoot, runRootMode,
42
+ rawProjectsRoot, defaultProjectsRoot, runRootMode, getProjectsRoot,
43
43
  pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
44
44
  setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
45
45
  humanRateUsdPerHour, setHumanRateUsdPerHour, assertHumanRateInput,
@@ -60,7 +60,7 @@ import { effectiveHumanRateUsd } from '../src/core/human-rate.mjs';
60
60
  import {
61
61
  ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
62
62
  listThreads as askListThreads, updateThread as askUpdateThread,
63
- deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages,
63
+ deleteThread as askDeleteThread, sweepEmptyThreads, sweepStreamingMessages, sweepCloningCards,
64
64
  countThreads as askCountThreads, listThreadIds as askListThreadIds,
65
65
  countWorktrees as askCountWorktrees, countAttachments as askCountAttachments,
66
66
  appendMessage as askAppendMessage, getMessage as askGetMessage,
@@ -101,7 +101,8 @@ import {
101
101
  discoverAll, scanMembers, routeWorkspaceMembers, projectMetricsStatus, startTeamMetricsBackground,
102
102
  metricsEvents, slugDirName, autoMetricsHome,
103
103
  } from '../src/core/metrics/sync.mjs';
104
- import { readScope, listScopes, parseScopeParam, aggregate, resolveRange, GROUP_BYS, PROJECT_KEY_RE as TM_PROJECT_KEY_RE } from '../src/core/metrics/read.mjs';
104
+ import { readScope, scopeSources, listScopes, parseScopeParam, aggregate, resolveRange, GROUP_BYS, PROJECT_KEY_RE as TM_PROJECT_KEY_RE } from '../src/core/metrics/read.mjs';
105
+ import { resolveRunPrs, listPrEvents, MAX_LOOKUPS as TM_MAX_PR_LOOKUPS } from '../src/core/metrics/prs.mjs';
105
106
  // Team policy (team-policy design §9, §11): the worca-policy branch, its gates and its pages.
106
107
  import {
107
108
  policyEvents, discoverPolicy, discoverAllPolicies, resolveProjectPolicy, resolveWorkspacePolicy, enableTeamPolicy, publishPolicy,
@@ -115,6 +116,12 @@ import { readPolicyState } from '../src/core/policy/state.mjs';
115
116
  import { policyForScope, policyPayload } from '../src/core/policy/scope.mjs';
116
117
  import { policyCatalogModels } from '../src/core/policy/cache.mjs';
117
118
  import { pickFolderNative } from '../src/core/folder-dialog.mjs';
119
+ import {
120
+ readRemoteAccessConfig, checkRemoteAccessConfig, isRemoteMode, createHostGuard, createIdentityCheck, isInContainer,
121
+ } from '../src/core/remote-access.mjs';
122
+ import { detectDeployment, deploymentFacts } from '../src/core/deployment.mjs';
123
+ import { resolveIdentity, startedByOf, prAttributionFooter, actorOf, isSharedIdentity, byActor } from '../src/core/identity.mjs';
124
+ import { planClone, cloneProject, CloneError } from '../src/core/clone-project.mjs';
118
125
  import { listFolders } from '../src/core/fs-browse.mjs';
119
126
  import {
120
127
  readConfig, setStep, addCustomModel, removeCustomModel, listModels,
@@ -157,6 +164,7 @@ import { policyEventPrompt, policyNoticeText } from '../src/core/ask/policy-prop
157
164
  import { scheduleEventPrompt, scheduleNoticeText } from '../src/core/ask/schedule-spec.mjs';
158
165
  import { applyModelChange } from '../src/core/ask/model-deps.mjs';
159
166
  import { modelEventPrompt, modelNoticeText } from '../src/core/ask/model-proposal.mjs';
167
+ import { cloneEventPrompt, cloneNoticeText } from '../src/core/ask/clone-proposal.mjs';
160
168
  import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
161
169
  import { sweepV1Runs, V1_RUN_RETIRED, getDb } from '../src/core/db.mjs';
162
170
  import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
@@ -176,7 +184,7 @@ import {
176
184
  listWorkspaces, readWorkspace, createWorkspace,
177
185
  updateWorkspace, deleteWorkspace, isGitRepo, WORKSPACE_KEY_RE, countWorkspaces,
178
186
  } from '../src/core/workspaces.mjs';
179
- import { listWorkspacePipelines, readWorkspacePipeline } from '../src/core/artifacts.mjs';
187
+ import { listWorkspacePipelines, readWorkspacePipeline, appendAuditById } from '../src/core/artifacts.mjs';
180
188
  import { generateOverview } from '../src/core/overview-agent.mjs';
181
189
  import { projectKey, PROJECT_KEY_RE } from '../src/core/store.mjs';
182
190
  import { validateMemoryScope, withStoreLock } from '../src/core/memory-sync.mjs';
@@ -336,6 +344,34 @@ const PORT = Number(process.env.PORT) || DEFAULT_UI_PORT;
336
344
  // applies unless they also front it with auth.
337
345
  const HOST = process.env.WORCA_HOST || '127.0.0.1';
338
346
 
347
+ // Remote access behind an identity proxy (src/core/remote-access.mjs): opt-in
348
+ // via WORCA_ALLOWED_HOSTS + WORCA_CF_ACCESS_*. Unset = the localhost-only
349
+ // contract above, unchanged. A config error stops the server at boot (isMain)
350
+ // and, should the app be imported anyway, refuses every non-local request.
351
+ const REMOTE_ACCESS = readRemoteAccessConfig(process.env);
352
+ const REMOTE_ACCESS_CHECK = checkRemoteAccessConfig(REMOTE_ACCESS, { bindHost: HOST });
353
+ const REMOTE_MODE = isRemoteMode(REMOTE_ACCESS);
354
+ const isLocalRequest = createHostGuard(REMOTE_ACCESS.allowedHosts);
355
+ const identityCheck = REMOTE_ACCESS_CHECK.errors.length ? null : createIdentityCheck(REMOTE_ACCESS);
356
+ // local | container | hosted (src/core/deployment.mjs): what Ask Worca is told about where it runs.
357
+ const DEPLOYMENT = detectDeployment(process.env, { remoteMode: REMOTE_MODE });
358
+ const SEEN_SIGN_INS = new Set();
359
+ const HOST_FORBIDDEN = REMOTE_MODE
360
+ ? 'forbidden: host not allowed (see WORCA_ALLOWED_HOSTS)'
361
+ : 'forbidden: worca is a localhost-only tool';
362
+
363
+ /**
364
+ * Who is asking: `{ local: true }` for an in-container caller or when no
365
+ * identity check applies, `{ email, sub }` for a valid proxy token, null when
366
+ * refused. Rejects when the identity provider cannot be reached (-> 503).
367
+ */
368
+ async function requestIdentity(req) {
369
+ if (isInContainer(req)) return { local: true };
370
+ if (REMOTE_ACCESS_CHECK.errors.length) return null;
371
+ if (!identityCheck) return { local: true };
372
+ return identityCheck(req);
373
+ }
374
+
339
375
  // ---------------------------------------------------------------------------
340
376
  // Run registry. Each entry holds the live orchestrator + a ring buffer of the
341
377
  // events emitted so far so that a WebSocket which connects late can replay.
@@ -398,7 +434,22 @@ const MAX_BUFFER = 5000;
398
434
  // ---------------------------------------------------------------------------
399
435
  const app = express();
400
436
  const server = http.createServer(app);
401
- const wss = new WebSocketServer({ server, path: '/ws' });
437
+ // Refuse during the upgrade (before any replay is sent): the Host/Origin guard
438
+ // first, then the identity check when remote access is on. The 'connection'
439
+ // handler below re-checks the host guard as a second line.
440
+ const wss = new WebSocketServer({
441
+ server,
442
+ path: '/ws',
443
+ verifyClient: (info, done) => {
444
+ if (!isLocalRequest(info.req)) return done(false, 403);
445
+ requestIdentity(info.req).then(
446
+ // Keep who this is on the upgrade request: the connection handler reads it to scope
447
+ // per-thread Ask frames to their owner (askViewer).
448
+ (who) => { if (who) info.req.worcaUser = who; return who ? done(true) : done(false, 401); },
449
+ () => done(false, 503),
450
+ );
451
+ },
452
+ });
402
453
  // ws re-emits the http server's 'error' on the WebSocketServer. With no listener
403
454
  // here, an EADDRINUSE on listen() became an unhandled 'error' event and a full
404
455
  // stack trace; the http server's own handler (isMain below) is the one that
@@ -430,6 +481,8 @@ wss.on('connection', (ws, req) => {
430
481
  return;
431
482
  }
432
483
  sockets.add(ws);
484
+ // Whose Ask threads this socket may see (a shared sign-in's name, else null = all).
485
+ ws.worcaViewer = askViewer(req);
433
486
  // Optional ?runId=... (or ?scanId=.../?genId=...) -> replay that entry's buffered
434
487
  // events so a reconnecting client immediately sees the full state. Scan + agentgen
435
488
  // entries live in the SAME runs Map keyed by scanId/genId, so a single id lookup
@@ -455,13 +508,13 @@ wss.on('connection', (ws, req) => {
455
508
  }
456
509
  const id = requestedRunId || requestedScanId || requestedGenId || requestedBenchId;
457
510
 
458
- send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
511
+ send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello(ws) });
459
512
 
460
513
  if (id && runs.has(id)) {
461
514
  replayEntry(ws, runs.get(id));
462
515
  }
463
516
 
464
- if (requestedThreadId && askJobs.has(requestedThreadId)) {
517
+ if (requestedThreadId && askJobs.has(requestedThreadId) && askSocketSees(ws, requestedThreadId)) {
465
518
  replayAskJob(ws, askJobs.get(requestedThreadId));
466
519
  }
467
520
 
@@ -482,7 +535,7 @@ wss.on('connection', (ws, req) => {
482
535
  replayEntry(ws, runs.get(subId));
483
536
  }
484
537
  const askThreadId = msg && msg.type === 'subscribe' && typeof msg.threadId === 'string' ? msg.threadId : null;
485
- if (askThreadId && askJobs.has(askThreadId)) {
538
+ if (askThreadId && askJobs.has(askThreadId) && askSocketSees(ws, askThreadId)) {
486
539
  replayAskJob(ws, askJobs.get(askThreadId));
487
540
  }
488
541
  });
@@ -535,7 +588,9 @@ function replayEntry(ws, entry) {
535
588
  /** Broadcast an already-tagged event object to every open socket. */
536
589
  function broadcast(obj) {
537
590
  const text = JSON.stringify(obj);
591
+ const owner = askFrameOwner(obj);
538
592
  for (const ws of sockets) {
593
+ if (owner && ws.worcaViewer && ws.worcaViewer !== owner) continue; // someone else's Ask thread
539
594
  if (ws.readyState === ws.OPEN) {
540
595
  try {
541
596
  ws.send(text);
@@ -546,6 +601,24 @@ function broadcast(obj) {
546
601
  }
547
602
  }
548
603
 
604
+ // Per-person delivery of Ask frames (step 3): on a shared sign-in an ask-* frame that names a
605
+ // thread goes only to sockets of that thread's owner (and to non-shared sockets, e.g. an
606
+ // in-container tool), like the HTTP guard on /api/ask/threads/:id. Owners never change, so
607
+ // they are cached; nothing is looked up unless some socket is a shared viewer.
608
+ const ASK_OWNER_CACHE = new Map(); // threadId -> createdBy | null
609
+ function askFrameOwner(obj) {
610
+ if (!obj || typeof obj.threadId !== 'string' || typeof obj.type !== 'string' || !obj.type.startsWith('ask-')) return null;
611
+ let anyViewer = false;
612
+ for (const ws of sockets) if (ws.worcaViewer) { anyViewer = true; break; }
613
+ if (!anyViewer) return null;
614
+ if (ASK_OWNER_CACHE.has(obj.threadId)) return ASK_OWNER_CACHE.get(obj.threadId);
615
+ let owner = null;
616
+ try { owner = askGetThread(obj.threadId)?.createdBy || null; } catch { return null; }
617
+ if (ASK_OWNER_CACHE.size > 5000) ASK_OWNER_CACHE.clear();
618
+ ASK_OWNER_CACHE.set(obj.threadId, owner);
619
+ return owner;
620
+ }
621
+
549
622
  // Fire-and-forget "this entity set changed — refetch your counts" signal. Bare +
550
623
  // unbuffered + global, exactly like the history-pr broadcast: every connected tab
551
624
  // (including the one that triggered the mutation) gets it and re-reads /api/counts.
@@ -671,6 +744,9 @@ function summarizeRuns() {
671
744
  // reload/reconnect restores the "Paused · error" detail, not a bare card.
672
745
  pauseDetail: r.pauseDetail || null,
673
746
  startedAt: r.startedAt,
747
+ startedBy: r.startedBy || null,
748
+ // Who last stopped / paused / resumed it ({ kind, by, at }), or null.
749
+ lastAction: r.lastAction || r.orch?.state?.lastAction || null,
674
750
  pendingQuestion: r.pendingQuestion || null,
675
751
  // kind discriminator so the client routes runs vs scans vs agent generations
676
752
  // vs workspace runs without guessing; scanId/genId/workspaceId are the
@@ -699,6 +775,8 @@ function announceRun(entry) {
699
775
  projectNames: entry.projectNames || null,
700
776
  status: entry.status,
701
777
  startedAt: entry.startedAt,
778
+ startedBy: entry.startedBy || null,
779
+ lastAction: entry.lastAction || null,
702
780
  });
703
781
  }
704
782
 
@@ -996,6 +1074,9 @@ app.post('/api/ingress/teams/:plugin/:channelId/:token',
996
1074
  // suspenders: reject any request whose Host (or browser Origin) is not a
997
1075
  // loopback name, so a malicious page resolving a name to 127.0.0.1 still can't
998
1076
  // drive the API. Override WORCA_HOST only if you understand the exposure.
1077
+ // The one sanctioned exception is a deployment behind an identity proxy:
1078
+ // WORCA_ALLOWED_HOSTS adds its hostname here, and the identity middleware
1079
+ // right below then demands the proxy's token (src/core/remote-access.mjs).
999
1080
  //
1000
1081
  // FIRST, ahead of the body parser (MIN-108): a refused request must be refused
1001
1082
  // before a single byte of its body is parsed or buffered, and a malformed body
@@ -1004,11 +1085,63 @@ app.post('/api/ingress/teams/:plugin/:channelId/:token',
1004
1085
  // stays exempt — it carries its own token check and 256 KB cap.
1005
1086
  app.use((req, res, next) => {
1006
1087
  if (!isLocalRequest(req)) {
1007
- return res.status(403).json({ error: 'forbidden: worca is a localhost-only tool' });
1088
+ return res.status(403).json({ error: HOST_FORBIDDEN });
1008
1089
  }
1009
1090
  next();
1010
1091
  });
1011
1092
 
1093
+ // Remote access (src/core/remote-access.mjs): when an identity proxy fronts the
1094
+ // server, every request must carry a valid token for it — the proxy stays the
1095
+ // network layer, this is the identity layer, so a misconfigured proxy or an
1096
+ // accidental public domain still exposes nothing. Also ahead of the body
1097
+ // parser (MIN-108). /api/health stays open for the platform healthcheck and
1098
+ // answers only name + version to a remote caller.
1099
+ app.use((req, res, next) => {
1100
+ if (!identityCheck && !REMOTE_ACCESS_CHECK.errors.length) return next(); // local mode: no await
1101
+ if (req.method === 'GET' && req.path === '/api/health') return next();
1102
+ requestIdentity(req).then((who) => {
1103
+ if (!who) {
1104
+ return res.status(401).json({ error: 'unauthorized: sign in through the identity proxy (Cloudflare Access)' });
1105
+ }
1106
+ req.worcaUser = who;
1107
+ // One line per person per server lifetime (attribution, not an audit log): email only.
1108
+ if (who.email && !SEEN_SIGN_INS.has(who.email)) {
1109
+ SEEN_SIGN_INS.add(who.email);
1110
+ console.log(`[worca-ui] signed in: ${who.email} (first request since boot)`);
1111
+ }
1112
+ next();
1113
+ }, () => {
1114
+ res.status(503).json({ error: 'cannot verify the sign-in token right now' });
1115
+ });
1116
+ });
1117
+
1118
+ // Ask threads have an owner (ask_threads.created_by). On a shared deployment (a real per-person
1119
+ // sign-in, identity.mjs#isSharedIdentity) a person reaches only their own threads and ownerless
1120
+ // legacy ones: every route under /api/ask/threads/:id answers 404 for someone else's. A local or
1121
+ // operator deployment is one person, so nothing changes there.
1122
+ function askViewer(req) {
1123
+ const who = resolveIdentity(req);
1124
+ return isSharedIdentity(who.source) ? who.name : null;
1125
+ }
1126
+ /** Whether a thread's owner is a shared sign-in, for turns with no request (event turns after a
1127
+ * card click): the owner of a thread on a shared deployment is recorded from a verified identity,
1128
+ * so it is shared exactly when this deployment verifies identities. */
1129
+ function askSharedOwner(thread) {
1130
+ return !!(thread && thread.createdBy && thread.createdBy !== 'local' && (identityCheck || process.env.WORCA_IDENTITY_HEADER));
1131
+ }
1132
+ function askThreadVisible(thread, req) {
1133
+ const viewer = askViewer(req);
1134
+ return !viewer || !thread || !thread.createdBy || thread.createdBy === viewer;
1135
+ }
1136
+ app.use('/api/ask/threads/:id', (req, res, next) => {
1137
+ const viewer = askViewer(req);
1138
+ if (!viewer || typeof req.params.id !== 'string' || !ASK_ID_RE.test(req.params.id)) return next();
1139
+ let thread = null;
1140
+ try { thread = askGetThread(req.params.id); } catch { return next(); }
1141
+ if (thread && !askThreadVisible(thread, req)) return res.status(404).json({ error: 'thread not found' });
1142
+ next();
1143
+ });
1144
+
1012
1145
  // Ask attachments ride base64 inside the message JSON (§7.3), and a binary
1013
1146
  // attachment (#398) may legitimately be 5 MB — several of them blow the app-wide
1014
1147
  // 8mb cap below. Registered BEFORE the global parser on the ONE route that
@@ -1136,28 +1269,6 @@ app.use((req, res, next) => {
1136
1269
 
1137
1270
  app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
1138
1271
 
1139
- const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
1140
- /** Hostname (no port) from a Host header value or full Origin URL, or null. */
1141
- function hostnameOf(value) {
1142
- if (!value) return null;
1143
- try {
1144
- return new URL(value.includes('://') ? value : `http://${value}`).hostname;
1145
- } catch {
1146
- return null;
1147
- }
1148
- }
1149
- /** True when both Host and (if present) Origin are loopback. */
1150
- function isLocalRequest(req) {
1151
- const host = hostnameOf(req.headers.host);
1152
- if (!host || !LOCAL_HOSTNAMES.has(host)) return false;
1153
- const origin = req.headers.origin;
1154
- if (origin) {
1155
- const oh = hostnameOf(origin);
1156
- if (!oh || !LOCAL_HOSTNAMES.has(oh)) return false;
1157
- }
1158
- return true;
1159
- }
1160
-
1161
1272
  function badRequest(res, message) {
1162
1273
  res.status(400).json({ error: message });
1163
1274
  }
@@ -1431,6 +1542,13 @@ const startRunHandler = async (req, res) => {
1431
1542
  // IS the runId, plus the CLI-only options a stored request may hold.
1432
1543
  const internal = req._internal && typeof req._internal === 'object' ? req._internal : null;
1433
1544
  const stored = internal && body.internal && typeof body.internal === 'object' ? body.internal : {};
1545
+ // Who started it (identity.mjs): this request's resolved identity; a scheduled run keeps the
1546
+ // identity of whoever scheduled it (stored with the request, never taken from an HTTP body).
1547
+ // "Run now" credits whoever clicked it (internal.runNowBy, never from HTTP); a timer firing credits the scheduler.
1548
+ const startedBy = internal
1549
+ ? (typeof internal.runNowBy === 'string' && internal.runNowBy ? internal.runNowBy
1550
+ : typeof stored.startedBy === 'string' && stored.startedBy ? stored.startedBy : null)
1551
+ : startedByOf(req);
1434
1552
 
1435
1553
  // Mutual exclusion: exactly one of workspaceId / projectDir (§2.6).
1436
1554
  const hasWorkspace = typeof body.workspaceId === 'string' && body.workspaceId.trim();
@@ -1620,7 +1738,7 @@ const startRunHandler = async (req, res) => {
1620
1738
 
1621
1739
  // Team total cap (design §7): soft — `pastTeamCap` acknowledges it once per window per home.
1622
1740
  {
1623
- const gate = await checkTeamTotalGate({ workspaceId: ws.id }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null });
1741
+ const gate = await checkTeamTotalGate({ workspaceId: ws.id }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null, by: startedBy });
1624
1742
  if (gate.blocked) return res.status(gate.code === 'reason_required' ? 400 : 403).json({ error: gate.error, code: gate.code, policy: gate.policy, needsPolicyAck: gate.code === 'team_total' });
1625
1743
  }
1626
1744
 
@@ -1670,7 +1788,7 @@ const startRunHandler = async (req, res) => {
1670
1788
  if (!r.ok) return badRequest(res, r.error);
1671
1789
  sched.afterRef = r.after;
1672
1790
  }
1673
- if (sched) return res.status(202).json(await scheduleRequest({ body, sched, title, askLink, budget, workspaceId: ws.id, projectDir: projects[0].projectDir }));
1791
+ if (sched) return res.status(202).json(await scheduleRequest({ body, sched, title, askLink, budget, workspaceId: ws.id, projectDir: projects[0].projectDir, startedBy }));
1674
1792
 
1675
1793
  orch = await createOrchestratorFor({
1676
1794
  workspace: {
@@ -1688,6 +1806,7 @@ const startRunHandler = async (req, res) => {
1688
1806
  workflowId,
1689
1807
  template: workflowRow,
1690
1808
  guardrailsId,
1809
+ startedBy,
1691
1810
  branch,
1692
1811
  claude: { permissionMode: stored.permissionMode || 'acceptEdits', ...(stored.model ? { model: stored.model } : {}), mock },
1693
1812
  // A CLI-made ticket may carry `--yes`: the explicit non-interactive choice survives the wait.
@@ -1704,6 +1823,7 @@ const startRunHandler = async (req, res) => {
1704
1823
  title,
1705
1824
  status: 'starting',
1706
1825
  startedAt: new Date().toISOString(),
1826
+ startedBy,
1707
1827
  events: [],
1708
1828
  pendingQuestion: null,
1709
1829
  };
@@ -1745,7 +1865,7 @@ const startRunHandler = async (req, res) => {
1745
1865
 
1746
1866
  // Team total cap (design §7): soft — `pastTeamCap` acknowledges it once per window per home.
1747
1867
  {
1748
- const gate = await checkTeamTotalGate({ projectDir }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null });
1868
+ const gate = await checkTeamTotalGate({ projectDir }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null, by: startedBy });
1749
1869
  if (gate.blocked) return res.status(gate.code === 'reason_required' ? 400 : 403).json({ error: gate.error, code: gate.code, policy: gate.policy, needsPolicyAck: gate.code === 'team_total' });
1750
1870
  }
1751
1871
  const humanInLoop = bodyHumanInLoop ?? ((await readRunConfig(projectDir)).humanInLoop !== false);
@@ -1757,7 +1877,7 @@ const startRunHandler = async (req, res) => {
1757
1877
  }
1758
1878
  // A schedule stores the pair as checked (the catalog's casing, trimmed): its ticket takes it verbatim.
1759
1879
  const storedBody = startPair ? { ...body, model: startPair.model, effort: startPair.effort || undefined } : body;
1760
- if (sched) return res.status(202).json(await scheduleRequest({ body: storedBody, sched, title, askLink, budget, projectDir }));
1880
+ if (sched) return res.status(202).json(await scheduleRequest({ body: storedBody, sched, title, askLink, budget, projectDir, startedBy }));
1761
1881
 
1762
1882
  orch = await createOrchestratorFor({
1763
1883
  projectDir,
@@ -1769,6 +1889,7 @@ const startRunHandler = async (req, res) => {
1769
1889
  workflowId,
1770
1890
  template: workflowRow,
1771
1891
  guardrailsId,
1892
+ startedBy,
1772
1893
  branch,
1773
1894
  humanInLoop,
1774
1895
  ...(memoryScope ? { memoryScope } : {}),
@@ -1789,6 +1910,7 @@ const startRunHandler = async (req, res) => {
1789
1910
  title,
1790
1911
  status: 'starting',
1791
1912
  startedAt: new Date().toISOString(),
1913
+ startedBy,
1792
1914
  events: [],
1793
1915
  pendingQuestion: null,
1794
1916
  };
@@ -1926,7 +2048,7 @@ function parseScheduleRequest(body, { now = Date.now() } = {}) {
1926
2048
  }
1927
2049
 
1928
2050
  /** The request a ticket stores: the validated body minus schedule fields and uploads. */
1929
- async function storedRequestOf(body, stageId, projectDir) {
2051
+ async function storedRequestOf(body, stageId, projectDir, startedBy = null) {
1930
2052
  const request = { ...body };
1931
2053
  for (const k of ['scheduledFor', 'repeat', 'after', 'afterPolicy', 'sourceFromPrevious', 'ifMissed', 'graceMin', 'extras', 'internal']) delete request[k];
1932
2054
  // Text the user authored is part of the request: a prompt FILE is frozen now, so a
@@ -1936,21 +2058,22 @@ async function storedRequestOf(body, stageId, projectDir) {
1936
2058
  request.source = { type: 'markdown', promptText };
1937
2059
  }
1938
2060
  const extrasPaths = await writeExtras(stageId, body.extras, path.join(scheduleStageDir(stageId), 'extras'));
1939
- request.internal = { extrasPaths };
2061
+ request.internal = { extrasPaths, ...(startedBy ? { startedBy } : {}) };
1940
2062
  return request;
1941
2063
  }
1942
2064
 
1943
2065
  /** Turn a validated run request into a ticket (or a recurring schedule). 202 body. */
1944
- async function scheduleRequest({ body, sched, title, askLink, budget, projectDir, workspaceId = null }) {
2066
+ async function scheduleRequest({ body, sched, title, askLink, budget, projectDir, workspaceId = null, startedBy = null }) {
1945
2067
  const target = workspaceId ? { workspaceId } : { projectDir };
1946
2068
  let ticket, schedule = null;
1947
2069
  if (sched.repeat) {
1948
2070
  const id = `sch_${randomBytes(4).toString('hex')}`;
1949
- const request = await storedRequestOf(body, id, projectDir);
2071
+ const request = await storedRequestOf(body, id, projectDir, startedBy);
1950
2072
  ({ schedule, ticket } = createSchedule({
1951
2073
  id, title, ...target, request, rule: sched.repeat.rule, overlap: sched.repeat.overlap,
1952
2074
  maxFailures: sched.repeat.maxFailures, ifMissed: sched.ifMissed, graceMin: sched.graceMin,
1953
2075
  askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null,
2076
+ createdBy: startedBy,
1954
2077
  }));
1955
2078
  // An Ask card that became a repeating schedule follows the SERIES, not one run of it.
1956
2079
  if (askLink) {
@@ -1959,13 +2082,13 @@ async function scheduleRequest({ body, sched, title, askLink, budget, projectDir
1959
2082
  }
1960
2083
  } else {
1961
2084
  const id = randomUUID();
1962
- const request = await storedRequestOf(body, id, projectDir);
2085
+ const request = await storedRequestOf(body, id, projectDir, startedBy);
1963
2086
  // Both arms carry ifMissed / graceMin: parseScheduleRequest filled them with the Settings defaults
1964
2087
  // (an after body may not name them), and a chained ticket later moved to a time shows them.
1965
2088
  const chain = sched.after ? {
1966
2089
  after: { kind: sched.afterRef.kind, id: sched.afterRef.id }, afterPolicy: sched.afterPolicy, sourceFromPrevious: sched.sourceFromPrevious, ifMissed: sched.ifMissed, graceMin: sched.graceMin,
1967
2090
  } : { runAtMs: sched.runAtMs, ifMissed: sched.ifMissed, graceMin: sched.graceMin };
1968
- ticket = createTicket({ id, title, ...target, request, ...chain, askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null });
2091
+ ticket = createTicket({ id, title, ...target, request, ...chain, askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null, createdBy: startedBy });
1969
2092
  if (askLink) {
1970
2093
  try { flipCard(askLink.threadId, askLink.cardId, { state: 'scheduled', runId: id, scheduledFor: sched.after ? null : ticket.runAt, after: sched.after ? { kind: sched.afterRef.kind, id: sched.afterRef.id, title: sched.afterRef.title } : null }); }
1971
2094
  catch (err) { console.error(`[worca-ui] ask card schedule flip failed: ${err && err.message ? err.message : err}`); }
@@ -2006,6 +2129,9 @@ async function invokeStartRun(body, internal) {
2006
2129
  return out;
2007
2130
  }
2008
2131
 
2132
+ /** Ticket id -> who clicked "Run now" (identity.mjs actor), consumed by the firing it causes. */
2133
+ const RUN_NOW_BY = new Map();
2134
+
2009
2135
  /** runDueTickets' `start`: probe an external task first (transient errors retry), then start. */
2010
2136
  async function fireTicket(ticket) {
2011
2137
  const body = { ...(ticket.request || {}) };
@@ -2044,7 +2170,10 @@ async function fireTicket(ticket) {
2044
2170
  const day = (s && s.tz ? localDate(Date.parse(ticket.runAt), s.tz) : ticket.runAt.slice(0, 10)).replace(/-/g, '');
2045
2171
  body.featureBranch = `${body.featureBranch.trim()}-${day}`;
2046
2172
  }
2047
- const out = await invokeStartRun(body, { ticket });
2173
+ // A "Run now" click names its clicker for this one firing (scheduleVerb records it).
2174
+ const runNowBy = RUN_NOW_BY.get(ticket.id) || null;
2175
+ RUN_NOW_BY.delete(ticket.id);
2176
+ const out = await invokeStartRun(body, { ticket, ...(runNowBy ? { runNowBy } : {}) });
2048
2177
  if (out.status === 200 && out.body && out.body.runId) return { ok: true };
2049
2178
  const error = (out.body && out.body.error) || `the run could not be started (HTTP ${out.status})`;
2050
2179
  return { ok: false, error, transient: out.status >= 500 };
@@ -2141,7 +2270,7 @@ app.get('/api/schedules', (req, res) => {
2141
2270
  res.json({
2142
2271
  schedules: listSchedules({ projectDir, workspaceId }),
2143
2272
  tickets: listTickets({ projectDir, workspaceId, all }).map(withAfter),
2144
- counts: { ...scheduleCounts(), unread: unreadCount('schedule') },
2273
+ counts: { ...scheduleCounts(), unread: unreadCount('schedule', { reader: notifReader(req) }) },
2145
2274
  defaults: scheduleDefaults(),
2146
2275
  });
2147
2276
  } catch (err) {
@@ -2237,7 +2366,7 @@ app.get('/api/schedules/:id', (req, res) => {
2237
2366
  const found = findScheduleItem(req.params.id);
2238
2367
  if (!found) return res.status(404).json({ error: 'schedule not found' });
2239
2368
  const history = found.kind === 'recurring' ? listTickets({ scheduleId: found.item.id, all: true, limit: 50 }).reverse() : [];
2240
- res.json({ ...found, item: found.kind === 'once' ? withAfter(found.item) : found.item, history, notifications: listNotifications({ scheduleId: found.kind === 'recurring' ? found.item.id : null, limit: 50 }).filter((n) => found.kind === 'recurring' || n.ticketId === found.item.id) });
2369
+ res.json({ ...found, item: found.kind === 'once' ? withAfter(found.item) : found.item, history, notifications: listNotifications({ scheduleId: found.kind === 'recurring' ? found.item.id : null, limit: 50, reader: notifReader(req) }).filter((n) => found.kind === 'recurring' || n.ticketId === found.item.id) });
2241
2370
  });
2242
2371
 
2243
2372
  // One schedule change, for the REST routes AND an applied Ask Worca schedule card — so the
@@ -2247,7 +2376,7 @@ app.get('/api/schedules/:id', (req, res) => {
2247
2376
  // verb 'delete' cancel a one-off ticket, or delete a series
2248
2377
  // verb 'run-now' start a ticket now, or one extra occurrence of a series
2249
2378
  // verb 'pause' | 'resume' | 'skip-next' a series only
2250
- async function scheduleVerb(verb, id, body = {}) {
2379
+ async function scheduleVerb(verb, id, body = {}, { by = null } = {}) {
2251
2380
  const found = findScheduleItem(id);
2252
2381
  const out = (status, payload) => ({ status, body: payload });
2253
2382
  if (!found) return out(404, { error: 'schedule not found' });
@@ -2291,7 +2420,7 @@ async function scheduleVerb(verb, id, body = {}) {
2291
2420
  patch.sourceFromPrevious = body.sourceFromPrevious;
2292
2421
  }
2293
2422
  if (found.item.scheduleId && (patch.runAtMs != null || patch.after)) return out(400, { error: 'an occurrence of a repeating schedule cannot be moved — edit the schedule, or skip this occurrence' });
2294
- const t = updateTicket(found.item.id, patch);
2423
+ const t = updateTicket(found.item.id, patch, { by: by || undefined });
2295
2424
  if (!t) return out(409, { error: `this run is ${found.item.status} and can no longer be changed` });
2296
2425
  const item = withAfter(t);
2297
2426
  if (t.askThreadId && t.askCardId && (patch.runAtMs != null || patch.after)) {
@@ -2310,7 +2439,7 @@ async function scheduleVerb(verb, id, body = {}) {
2310
2439
  if (patch.graceMin !== undefined && (!Number.isSafeInteger(patch.graceMin) || patch.graceMin < 0 || patch.graceMin > 10080)) {
2311
2440
  return out(400, { error: 'graceMin must be a whole number of minutes from 0 to 10080' });
2312
2441
  }
2313
- const s = updateSchedule(found.item.id, patch);
2442
+ const s = updateSchedule(found.item.id, patch, { by: by || undefined });
2314
2443
  if (s && s.askThreadId && s.askCardId && patch.rule) {
2315
2444
  try { flipCard(s.askThreadId, s.askCardId, { sentence: s.sentence, scheduledFor: s.nextRunAt }); } catch { /* display only */ }
2316
2445
  }
@@ -2326,7 +2455,7 @@ async function scheduleVerb(verb, id, body = {}) {
2326
2455
  releaseAskCard(found.item);
2327
2456
  } else {
2328
2457
  if (found.item.scheduleId) return out(400, { error: 'this is an occurrence of a repeating schedule — skip it instead' });
2329
- const t = cancelTicket(found.item.id);
2458
+ const t = cancelTicket(found.item.id, { by: by || undefined });
2330
2459
  if (!t) return out(409, { error: `this run is ${found.item.status} and can no longer be canceled` });
2331
2460
  releaseAskCard(t);
2332
2461
  }
@@ -2339,8 +2468,9 @@ async function scheduleVerb(verb, id, body = {}) {
2339
2468
  const p = predecessorState(found.item.after, { policy: found.item.after.policy, isLive: liveProbe });
2340
2469
  if (!p.pipelineId || !previousBranchesOf(p.pipelineId)) return out(409, { error: `Start ‘${p.title || 'the run before it'}’ first, or change its source branch` });
2341
2470
  }
2342
- const ticket = found.kind === 'recurring' ? runScheduleNow(found.item.id) : requestRunNow(found.item.id);
2471
+ const ticket = found.kind === 'recurring' ? runScheduleNow(found.item.id, { by: by || undefined }) : requestRunNow(found.item.id, { by: by || undefined });
2343
2472
  if (!ticket) return out(409, { error: `this ${found.kind === 'recurring' ? 'schedule' : 'run'} is ${found.item.status} and cannot be started` });
2473
+ if (by) RUN_NOW_BY.set(ticket.id, by);
2344
2474
  emitChanged('schedules-changed', 'run-now');
2345
2475
  emitChanged('notifications-changed');
2346
2476
  await schedulerTick();
@@ -2350,7 +2480,8 @@ async function scheduleVerb(verb, id, body = {}) {
2350
2480
  }
2351
2481
  if (['pause', 'resume', 'skip-next'].includes(verb)) {
2352
2482
  if (found.kind !== 'recurring') return out(404, { error: 'repeating schedule not found' });
2353
- const s = verb === 'pause' ? pauseSchedule(found.item.id) : verb === 'resume' ? resumeSchedule(found.item.id) : skipNext(found.item.id);
2483
+ const opt = { by: by || undefined };
2484
+ const s = verb === 'pause' ? pauseSchedule(found.item.id, opt) : verb === 'resume' ? resumeSchedule(found.item.id, opt) : skipNext(found.item.id, opt);
2354
2485
  if (!s) return out(409, { error: `this schedule is ${found.item.status}` });
2355
2486
  emitChanged('schedules-changed', verb);
2356
2487
  emitChanged('notifications-changed');
@@ -2360,11 +2491,11 @@ async function scheduleVerb(verb, id, body = {}) {
2360
2491
  }
2361
2492
 
2362
2493
  /** Apply a CONFIRMED Ask Worca schedule card (schedule-spec.mjs shape) through scheduleVerb. */
2363
- async function applyScheduleCard(card) {
2494
+ async function applyScheduleCard(card, { by = null } = {}) {
2364
2495
  const map = { run_now: ['run-now', {}], move: ['patch', card.patch || {}], edit: ['patch', card.patch || {}], cancel: ['delete', {}], delete: ['delete', {}] };
2365
2496
  const m = map[card.action];
2366
2497
  if (!m) return { ok: false, error: `unknown schedule action "${card.action}"` };
2367
- const r = await scheduleVerb(m[0], card.id, m[1]);
2498
+ const r = await scheduleVerb(m[0], card.id, m[1], { by });
2368
2499
  if (r.status !== 200) return { ok: false, error: (r.body && r.body.error) || `failed (${r.status})` };
2369
2500
  const b = r.body || {};
2370
2501
  let detail = '';
@@ -2386,37 +2517,43 @@ async function applyScheduleCard(card) {
2386
2517
  // PATCH /api/schedules/:id — a ticket: { scheduledFor?, ifMissed?, graceMin? };
2387
2518
  // a series: { title?, rule?, overlap?, maxFailures?, ifMissed?, graceMin? }.
2388
2519
  app.patch('/api/schedules/:id', async (req, res) => {
2389
- const r = await scheduleVerb('patch', req.params.id, req.body || {});
2520
+ const r = await scheduleVerb('patch', req.params.id, req.body || {}, { by: actorOf(req) });
2390
2521
  res.status(r.status).json(r.body);
2391
2522
  });
2392
2523
 
2393
2524
  // DELETE /api/schedules/:id — cancel a one-shot ticket, or delete a series.
2394
2525
  app.delete('/api/schedules/:id', async (req, res) => {
2395
- const r = await scheduleVerb('delete', req.params.id);
2526
+ const r = await scheduleVerb('delete', req.params.id, {}, { by: actorOf(req) });
2396
2527
  res.status(r.status).json(r.body);
2397
2528
  });
2398
2529
 
2399
2530
  // POST /api/schedules/:id/run-now — start a ticket now, or one extra occurrence of a series.
2400
2531
  app.post('/api/schedules/:id/run-now', async (req, res) => {
2401
- const r = await scheduleVerb('run-now', req.params.id);
2532
+ const r = await scheduleVerb('run-now', req.params.id, {}, { by: actorOf(req) });
2402
2533
  res.status(r.status).json(r.body);
2403
2534
  });
2404
2535
 
2405
2536
  for (const verb of ['pause', 'resume', 'skip-next']) {
2406
2537
  app.post(`/api/schedules/:id/${verb}`, async (req, res) => {
2407
- const r = await scheduleVerb(verb, req.params.id);
2538
+ const r = await scheduleVerb(verb, req.params.id, {}, { by: actorOf(req) });
2408
2539
  res.status(r.status).json(r.body);
2409
2540
  });
2410
2541
  }
2411
2542
 
2543
+ /** The reader whose read state applies: a person on a shared deployment, else null (the global column). */
2544
+ function notifReader(req) {
2545
+ const who = resolveIdentity(req);
2546
+ return isSharedIdentity(who.source) ? who.name : null;
2547
+ }
2548
+
2412
2549
  // GET /api/notifications?scope=schedule[&unread=1][&problems=1] -> { notifications, unread }
2413
2550
  app.get('/api/notifications', (req, res) => {
2414
2551
  try {
2415
2552
  const scope = typeof req.query.scope === 'string' && req.query.scope ? req.query.scope : 'schedule';
2416
2553
  const flag = (v) => v === '1' || v === 'true';
2417
2554
  res.json({
2418
- notifications: listNotifications({ scope, unread: flag(req.query.unread), problems: flag(req.query.problems) }),
2419
- unread: unreadCount(scope),
2555
+ notifications: listNotifications({ scope, unread: flag(req.query.unread), problems: flag(req.query.problems), reader: notifReader(req) }),
2556
+ unread: unreadCount(scope, { reader: notifReader(req) }),
2420
2557
  });
2421
2558
  } catch (err) {
2422
2559
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
@@ -2425,18 +2562,18 @@ app.get('/api/notifications', (req, res) => {
2425
2562
 
2426
2563
  app.post('/api/notifications/read-all', (req, res) => {
2427
2564
  const scope = req.body && typeof req.body.scope === 'string' && req.body.scope ? req.body.scope : 'schedule';
2428
- markAllRead(scope);
2565
+ markAllRead(scope, { reader: notifReader(req) });
2429
2566
  emitChanged('notifications-changed');
2430
- res.json({ unread: unreadCount(scope) });
2567
+ res.json({ unread: unreadCount(scope, { reader: notifReader(req) }) });
2431
2568
  });
2432
2569
 
2433
2570
  app.post('/api/notifications/:id/read', (req, res) => {
2434
2571
  const id = Number(req.params.id);
2435
2572
  if (!Number.isSafeInteger(id)) return badRequest(res, 'invalid notification id');
2436
2573
  const read = !(req.body && req.body.read === false);
2437
- if (!markRead(id, { read })) return res.status(404).json({ error: 'notification not found' });
2574
+ if (!markRead(id, { read, reader: notifReader(req) })) return res.status(404).json({ error: 'notification not found' });
2438
2575
  emitChanged('notifications-changed');
2439
- res.json({ unread: unreadCount('schedule') });
2576
+ res.json({ unread: unreadCount('schedule', { reader: notifReader(req) }) });
2440
2577
  });
2441
2578
 
2442
2579
  // ---------------------------------------------------------------------------
@@ -2464,14 +2601,15 @@ const chatActions = {
2464
2601
  })),
2465
2602
  runState: (runId) => { try { return runs.get(runId)?.orch?.getState() ?? null; } catch { return null; } },
2466
2603
  pendingQuestion: (runId) => runs.get(runId)?.pendingQuestion ?? null,
2467
- answer: (runId, id, payload) => answerRun(runId, id, payload),
2468
- stop: (runId) => stopRun(runId),
2469
- pause: (runId) => pauseRun(runId),
2604
+ // `by` = the chat actor ("ada via Slack", identity.mjs chatActor): attribution text only.
2605
+ answer: (runId, id, payload, by) => answerRun(runId, id, payload, by || 'local'),
2606
+ stop: (runId, by) => stopRun(runId, by || 'local'),
2607
+ pause: (runId, by) => pauseRun(runId, by || 'local'),
2470
2608
  // The long chain of budget/worktree/double-resume guards lives in resumeRun();
2471
2609
  // call it in-process. (It used to be reached by POSTing to 127.0.0.1:PORT — a
2472
2610
  // loopback self-fetch that breaks under WORCA_HOST and can hit another instance.)
2473
- resume: async (pipelineId) => {
2474
- try { return await resumeRun(pipelineId); }
2611
+ resume: async (pipelineId, by) => {
2612
+ try { return await resumeRun(pipelineId, { by: by || 'local' }); }
2475
2613
  catch (err) { return { ok: false, error: err?.body?.error || err?.message || String(err) }; }
2476
2614
  },
2477
2615
  // Chat reads only DB fields (id/title/status/cost/activeMs/pauseReason), so bound the
@@ -2576,24 +2714,28 @@ function reloadChatWorkers(name) {
2576
2714
  * every tab while the run is still waiting on it. Callers (`POST /api/answer` →
2577
2715
  * 422, and P4's CLI + chat) branch on `err.code === 'INVALID_ANSWER'`.
2578
2716
  */
2579
- function answerRun(runId, id, payload) {
2717
+ /** `by` = who answered (identity.mjs actorOf / chatActor); the harness stores and audits it. */
2718
+ function answerRun(runId, id, payload, by = 'local') {
2580
2719
  const entry = runs.get(runId);
2581
2720
  if (!entry) throw new Error('unknown runId');
2582
- entry.orch.answer(id, payload);
2721
+ entry.orch.answer(id, payload, by || 'local');
2583
2722
  resolvePending(entry, { id, reason: 'answered' });
2584
2723
  }
2585
- function stopRun(runId) {
2724
+ /** `by` = who asked (identity.mjs actorOf / chatActor); recorded on the entry and the run state. */
2725
+ function stopRun(runId, by = 'local') {
2586
2726
  const entry = runs.get(runId);
2587
2727
  if (!entry) throw new Error('unknown runId');
2588
- entry.orch.stop();
2728
+ entry.lastAction = { kind: 'stop', by: by || 'local', at: new Date().toISOString() };
2729
+ entry.orch.stop(entry.lastAction.by);
2589
2730
  entry.status = 'stopped';
2590
2731
  resolvePending(entry, { reason: 'stopped' });
2591
2732
  }
2592
- function pauseRun(runId) {
2733
+ function pauseRun(runId, by = 'local') {
2593
2734
  const entry = runs.get(runId);
2594
2735
  if (!entry) throw new Error('unknown runId');
2595
- const ok = typeof entry.orch?.pause === 'function' && entry.orch.pause();
2736
+ const ok = typeof entry.orch?.pause === 'function' && entry.orch.pause(by || 'local');
2596
2737
  if (!ok) throw Object.assign(new Error('cannot pause in the current state'), { code: 'CANNOT_PAUSE' });
2738
+ entry.lastAction = { kind: 'pause', by: by || 'local', at: new Date().toISOString() };
2597
2739
  entry.status = 'pausing';
2598
2740
  resolvePending(entry, { reason: 'paused' });
2599
2741
  }
@@ -2607,7 +2749,7 @@ app.post('/api/answer', (req, res) => {
2607
2749
  if (!runId || !runs.has(runId)) return badRequest(res, 'unknown runId');
2608
2750
  if (!id) return badRequest(res, 'question id is required');
2609
2751
  try {
2610
- answerRun(runId, id, payload);
2752
+ answerRun(runId, id, payload, actorOf(req));
2611
2753
  res.json({ ok: true });
2612
2754
  } catch (err) {
2613
2755
  // Gate 3 (ask forms, spec §5): the answer was validated and refused, so the
@@ -2628,7 +2770,7 @@ app.post('/api/stop', (req, res) => {
2628
2770
  const { runId } = req.body || {};
2629
2771
  if (!runId || !runs.has(runId)) return badRequest(res, 'unknown runId');
2630
2772
  try {
2631
- stopRun(runId);
2773
+ stopRun(runId, actorOf(req));
2632
2774
  res.json({ ok: true });
2633
2775
  } catch (err) {
2634
2776
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
@@ -2644,7 +2786,7 @@ app.post('/api/pause', (req, res) => {
2644
2786
  const { runId } = req.body || {};
2645
2787
  if (!runId || !runs.has(runId)) return badRequest(res, 'unknown runId');
2646
2788
  try {
2647
- pauseRun(runId);
2789
+ pauseRun(runId, actorOf(req));
2648
2790
  res.json({ ok: true });
2649
2791
  } catch (err) {
2650
2792
  if (err?.code === 'CANNOT_PAUSE') return badRequest(res, err.message);
@@ -2668,7 +2810,7 @@ class ResumeError extends Error {
2668
2810
  }
2669
2811
  }
2670
2812
 
2671
- async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, pastTeamCap = false, policyReason = null } = {}) {
2813
+ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, pastTeamCap = false, policyReason = null, by = 'local' } = {}) {
2672
2814
  if (!pipelineId || typeof pipelineId !== 'string') throw new ResumeError(400, { error: 'pipelineId is required' });
2673
2815
  const saved = readPipelineForResume(pipelineId);
2674
2816
  if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
@@ -2694,6 +2836,7 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, past
2694
2836
  // a total-refused request must not leave cost_cap_override armed.
2695
2837
  if (ignoreCostCap === true) {
2696
2838
  setCostCapOverride(pipelineId); // persistent per-pipeline override (F7)
2839
+ appendAuditById(pipelineId, `Pipeline cost limit override set${byActor(by)}.`, { actor: by });
2697
2840
  }
2698
2841
  const pipeCap = budget.pipelineLimitUsd;
2699
2842
  const spentSoFar = Number(saved.row.total_cost_usd || 0);
@@ -2741,14 +2884,18 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, past
2741
2884
  // per run. Both soft — `pastTeamCap` (with an optional / required reason) records the choice
2742
2885
  // and lets the resume through; the local gates above stay never-bypassable.
2743
2886
  const teamTarget = workspace ? { workspaceId: workspace.id } : { projectDir };
2744
- const totalGate = await checkTeamTotalGate(teamTarget, { pastTeamCap, reason: policyReason });
2887
+ const totalGate = await checkTeamTotalGate(teamTarget, { pastTeamCap, reason: policyReason, by });
2745
2888
  if (totalGate.blocked) {
2746
2889
  throw new ResumeError(totalGate.code === 'reason_required' ? 400 : 403, { error: totalGate.error, code: totalGate.code, policy: totalGate.policy, needsPolicyAck: totalGate.code === 'team_total' });
2747
2890
  }
2748
- const pipeGate = checkTeamPipelineGate(totalGate.caps, { pipelineId, spentSoFar, pastTeamCap, reason: policyReason });
2891
+ const pipeGate = checkTeamPipelineGate(totalGate.caps, { pipelineId, spentSoFar, pastTeamCap, reason: policyReason, by });
2749
2892
  if (pipeGate.blocked) {
2750
2893
  throw new ResumeError(pipeGate.code === 'reason_required' ? 400 : 403, { error: pipeGate.error, code: pipeGate.code, policy: pipeGate.policy, needsPolicyOverride: pipeGate.code === 'team_pipeline' });
2751
2894
  }
2895
+ // Who continued past a team cap, and why (the audit timeline; policy_state keeps it too).
2896
+ const why = (r) => (r ? ` — reason: ${String(r).replace(/[\r\n]+/g, ' ').slice(0, 300)}` : '');
2897
+ if (totalGate.ack) appendAuditById(pipelineId, `Continued past the team total cap${byActor(by)}${why(totalGate.ack.reason)}.`, { actor: by });
2898
+ if (pipeGate.fresh) appendAuditById(pipelineId, `Continued past the team cost cap${byActor(by)}${why(pipeGate.reason)}.`, { actor: by });
2752
2899
 
2753
2900
  // A paused defragment run is not live; another one on the same scope may have started since.
2754
2901
  // Resuming the first would sync its stale mount over the second's work (spec §5 concurrency).
@@ -2766,11 +2913,15 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, past
2766
2913
  agentsDir: AGENTS_DIR,
2767
2914
  claude: { permissionMode: 'acceptEdits', mock: effMock },
2768
2915
  resume: saved,
2916
+ resumedBy: by || 'local',
2769
2917
  });
2770
2918
  const entry = {
2771
2919
  id: runId,
2772
2920
  orch,
2773
2921
  projectDir,
2922
+ // The run keeps its starter across a resume; the resume itself is the last action.
2923
+ startedBy: saved.row.started_by || null,
2924
+ lastAction: { kind: 'resume', by: by || 'local', at: new Date().toISOString() },
2774
2925
  ...(workspace
2775
2926
  ? {
2776
2927
  workspaceId: workspace.id,
@@ -2841,6 +2992,7 @@ app.post('/api/resume', async (req, res) => {
2841
2992
  mock: !!(req.body && req.body.mock),
2842
2993
  pastTeamCap: req.body?.pastTeamCap === true,
2843
2994
  policyReason: typeof req.body?.policyReason === 'string' ? req.body.policyReason : null,
2995
+ by: actorOf(req),
2844
2996
  });
2845
2997
  res.json(out);
2846
2998
  } catch (err) {
@@ -3131,13 +3283,13 @@ app.post('/api/onboarding', async (req, res) => {
3131
3283
  catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err), ...onboardingPrefs() }); }
3132
3284
  });
3133
3285
 
3134
- app.get('/api/counts', (_req, res) => {
3286
+ app.get('/api/counts', (req, res) => {
3135
3287
  try {
3136
3288
  res.json({
3137
3289
  pipelines: countPipelines(),
3138
3290
  projects: countProjects(),
3139
3291
  workspaces: countWorkspaces(),
3140
- schedules: { ...scheduleCounts(), unread: unreadCount('schedule') },
3292
+ schedules: { ...scheduleCounts(), unread: unreadCount('schedule', { reader: notifReader(req) }) },
3141
3293
  });
3142
3294
  } catch (err) {
3143
3295
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
@@ -3225,6 +3377,37 @@ app.get('/api/team-metrics', async (req, res) => {
3225
3377
  } catch (err) { sendMetricsError(res, err); }
3226
3378
  });
3227
3379
 
3380
+ // Timeline (docs/team-metrics.md "Timeline"): the pull requests behind the runs on screen.
3381
+ // Body { scope, runs: [{ id, repos, branch, pr, endedAt }] } (prLookupFor() rows). Answers from
3382
+ // the Action's PR events, the gh cache and gh itself; `status` says which of them was missing.
3383
+ app.post('/api/team-metrics/prs', async (req, res) => {
3384
+ const body = req.body || {};
3385
+ const scope = parseScopeParam(body.scope);
3386
+ if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
3387
+ if (!Array.isArray(body.runs)) return badRequest(res, 'runs must be an array');
3388
+ if (body.runs.length > TM_MAX_PR_LOOKUPS) return badRequest(res, `at most ${TM_MAX_PR_LOOKUPS} runs per request`);
3389
+ try {
3390
+ const { sources } = await scopeSources(scope);
3391
+ res.json(await resolveRunPrs({ runs: body.runs, sinks: sources.map((s) => s.slug) }));
3392
+ } catch (err) { sendMetricsError(res, err); }
3393
+ });
3394
+
3395
+ // Timeline: every PR the merge-tracking Action recorded for the scope's repositories in
3396
+ // [from, to) (ms). The page shows the ones no recorded run points at as work outside Worca.
3397
+ app.get('/api/team-metrics/pr-events', async (req, res) => {
3398
+ const scope = parseScopeParam(req.query.scope);
3399
+ if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
3400
+ const num = (v) => (typeof v === 'string' && /^\d{1,15}$/.test(v) ? Number(v) : null);
3401
+ const from = num(req.query.from);
3402
+ const to = num(req.query.to);
3403
+ if ((req.query.from != null && from == null) || (req.query.to != null && to == null)) return badRequest(res, 'from and to must be epoch milliseconds');
3404
+ try {
3405
+ const { sources, meta } = await scopeSources(scope);
3406
+ const repos = meta.kind === 'project' ? [meta.slug] : sources.map((s) => s.slug);
3407
+ res.json(await listPrEvents({ sinks: sources.map((s) => s.slug), repos, from, to }));
3408
+ } catch (err) { sendMetricsError(res, err); }
3409
+ });
3410
+
3228
3411
  // "Push now" / "Retry": body { slug } flushes one outbox, { scope } flushes that scope's sinks,
3229
3412
  // an empty body flushes every outbox on this machine (same as `worca metrics push`).
3230
3413
  app.post('/api/team-metrics/flush', async (req, res) => {
@@ -3282,7 +3465,7 @@ app.post('/api/projects/:key/team-metrics/enable', async (req, res) => {
3282
3465
  return badRequest(res, 'attribution must be "git-user" or "none"');
3283
3466
  }
3284
3467
  try {
3285
- const result = await enableTeamMetrics(p.path, { mode, attribution: body.attribution || 'git-user', delegateTo: body.delegateTo || null, change: body.change === true });
3468
+ const result = await enableTeamMetrics(p.path, { mode, attribution: body.attribution || 'git-user', delegateTo: body.delegateTo || null, change: body.change === true, by: actorOf(req) });
3286
3469
  res.json({ ...result, status: await projectMetricsStatus(p) });
3287
3470
  } catch (err) { sendMetricsError(res, err); }
3288
3471
  });
@@ -3375,7 +3558,7 @@ app.put('/api/policy', async (req, res) => {
3375
3558
  const { r } = await policyForScope(scope);
3376
3559
  if (!r.ok) return res.status(404).json({ error: r.detail || 'no team policy for this scope', code: 'NOT_ENABLED' });
3377
3560
  if (!r.homeDir || !fs.existsSync(r.homeDir)) return res.status(409).json({ error: `the policy home ${r.home} is not checked out on this machine`, code: 'NOT_HOME' });
3378
- const out = await publishPolicy(r.homeDir, req.body.doc, { message: typeof req.body.message === 'string' ? req.body.message : null });
3561
+ const out = await publishPolicy(r.homeDir, req.body.doc, { message: typeof req.body.message === 'string' ? req.body.message : null, by: actorOf(req) });
3379
3562
  res.json({ ok: true, slug: out.slug, sha: out.sha, unchanged: !!out.unchanged, doc: out.doc });
3380
3563
  } catch (err) { sendPolicyError(res, err); }
3381
3564
  });
@@ -3403,7 +3586,7 @@ app.post('/api/projects/:key/policy/enable', async (req, res) => {
3403
3586
  const mode = body.mode === 'follow' ? 'follow' : body.mode === 'here' || body.mode == null ? 'here' : null;
3404
3587
  if (!mode) return badRequest(res, 'mode must be "here" or "follow"');
3405
3588
  try {
3406
- const result = await enableTeamPolicy(p.path, { mode, delegateTo: body.delegateTo || null, change: body.change === true, title: typeof body.title === 'string' ? body.title : '' });
3589
+ const result = await enableTeamPolicy(p.path, { mode, delegateTo: body.delegateTo || null, change: body.change === true, title: typeof body.title === 'string' ? body.title : '', by: actorOf(req) });
3407
3590
  res.json({ ...result, status: await projectPolicyStatus(p) });
3408
3591
  } catch (err) { sendPolicyError(res, err); }
3409
3592
  });
@@ -3540,7 +3723,7 @@ async function commentsCreate(req, res, storeKey, id) {
3540
3723
  const comment = addDiffComment({
3541
3724
  storeKey, pipelineId: row.id, patchText,
3542
3725
  project: body.project ?? null, path: body.path, side: body.side, line: body.line,
3543
- body: body.body, author: 'user',
3726
+ body: body.body, author: 'user', authorName: actorOf(req),
3544
3727
  });
3545
3728
  res.status(201).json({ comment });
3546
3729
  } catch (err) {
@@ -3584,7 +3767,7 @@ function commentsPatch(req, res, storeKey, id, cid) {
3584
3767
  function commentsReply(req, res, storeKey, id, cid) {
3585
3768
  try {
3586
3769
  if (!commentOfRun(res, storeKey, id, cid)) return;
3587
- const comment = addDiffCommentReply({ parentId: cid, body: (req.body || {}).body, author: 'user' });
3770
+ const comment = addDiffCommentReply({ parentId: cid, body: (req.body || {}).body, author: 'user', authorName: actorOf(req) });
3588
3771
  res.status(201).json({ comment });
3589
3772
  } catch (err) {
3590
3773
  if (err instanceof DiffCommentError) return badRequest(res, err.message);
@@ -3754,6 +3937,10 @@ app.delete('/api/runs/:id', async (req, res) => {
3754
3937
  id,
3755
3938
  });
3756
3939
  if (!report) return res.status(404).json({ error: 'pipeline not found' });
3940
+ if (report.archived) {
3941
+ const archBy = actorOf(req);
3942
+ appendAuditById(report.id, `Run archived${byActor(archBy)}.`, { actor: archBy });
3943
+ }
3757
3944
  emitChanged('pipelines-changed', 'deleted');
3758
3945
  res.json({ ok: true, ...report });
3759
3946
  } catch (e) {
@@ -3781,6 +3968,7 @@ app.post('/api/runs/:id/discard-worktree', async (req, res) => {
3781
3968
  key: scope.workspaceId ? null : (scope.projectKey || null),
3782
3969
  projectDir: (scope.workspaceId || scope.projectKey) ? null : scope.projectDir,
3783
3970
  id,
3971
+ by: actorOf(req),
3784
3972
  });
3785
3973
  if (!report) return res.status(404).json({ error: 'pipeline not found' });
3786
3974
  emitChanged('pipelines-changed', 'updated');
@@ -3949,8 +4137,11 @@ app.post('/api/pr', async (req, res) => {
3949
4137
  const pushed = await pushBranch(repoDir, feature, pushRemote);
3950
4138
  if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
3951
4139
 
4140
+ // A PR opened by a shared bot still names the person behind it (identity.mjs); none for 'local'.
4141
+ const footer = prAttributionFooter(state.startedBy);
3952
4142
  const pr = await createPr({
3953
4143
  projectDir: repoDir, base, head: feature, title: state.title || feature, repo, headOwner,
4144
+ ...(footer ? { body: `${state.title || feature}${footer}` } : {}),
3954
4145
  });
3955
4146
  if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
3956
4147
 
@@ -3959,6 +4150,9 @@ app.post('/api/pr', async (req, res) => {
3959
4150
  const pipelineIdForPr = state?.id || id; // prefer the canonical state id
3960
4151
  if (pipelineIdForPr) {
3961
4152
  persistPrState(pipelineIdForPr, { url: pr.url, number: parsePrNumber(pr.url), state: 'OPEN' });
4153
+ // Who clicked Create PR (the footer names who STARTED the run; this names who shipped it).
4154
+ const prBy = actorOf(req);
4155
+ appendAuditById(pipelineIdForPr, `Pull request ${pr.existed ? 'linked' : 'opened'}${byActor(prBy)}: ${pr.url}`, { actor: prBy });
3962
4156
  }
3963
4157
  // Remember the choice for this project (only once a PR was actually created).
3964
4158
  if (remotes.length) {
@@ -4090,6 +4284,59 @@ app.post('/api/projects', async (req, res) => {
4090
4284
  }
4091
4285
  });
4092
4286
 
4287
+ // POST /api/projects/clone {url, branch?, name?} -> 202 {jobId}: clone a repository into the
4288
+ // projects folder and register it (src/core/clone-project.mjs). A job, because a large clone
4289
+ // outlives a proxied request (Cloudflare closes at 100 s). GET /api/projects/clone/:id polls it;
4290
+ // 'clone-changed' broadcasts each transition. Refusals known up front answer at once (4xx).
4291
+ const CLONE_JOBS = new Map(); // jobId -> { id, url, name, dir, state: running|done|error, code?, error?, project? }
4292
+ const CLONE_STATUS = { invalid: 400, 'not-allowed': 403, exists: 409, 'not-found': 404, 'auth-failed': 502, timeout: 504, failed: 500 };
4293
+ function publicCloneJob(j) {
4294
+ const { id, url, name, dir, state, code = null, error = null, project = null, startedAt, endedAt = null } = j;
4295
+ return { id, url, name, dir, state, code, error, project, startedAt, endedAt };
4296
+ }
4297
+ async function startCloneJob(req) {
4298
+ const plan = planClone(req, { projectsRoot: getProjectsRoot() }); // throws CloneError at once
4299
+ if ([...CLONE_JOBS.values()].some((j) => j.state === 'running' && j.dir === plan.dir)) {
4300
+ throw new CloneError('exists', `${plan.dir} is being cloned already`);
4301
+ }
4302
+ const job = { id: `cln_${randomBytes(4).toString('hex')}`, url: plan.url, name: plan.name, dir: plan.dir, state: 'running', startedAt: new Date().toISOString() };
4303
+ CLONE_JOBS.set(job.id, job);
4304
+ broadcast({ type: 'clone-changed', job: publicCloneJob(job) });
4305
+ (async () => {
4306
+ try {
4307
+ await pinUiLevel();
4308
+ const { project } = await cloneProject(req, { projectsRoot: getProjectsRoot(), listProjects, addProject });
4309
+ Object.assign(job, { state: 'done', project });
4310
+ emitChanged('projects-changed', 'created');
4311
+ discoverProject(project.path, { force: true })
4312
+ .then(() => emitChanged('team-metrics-changed', 'discovered'))
4313
+ .catch(() => { /* offline or not a git repo: discovery retries hourly */ });
4314
+ } catch (err) {
4315
+ Object.assign(job, { state: 'error', code: err instanceof CloneError ? err.code : 'failed', error: err && err.message ? err.message : String(err) });
4316
+ }
4317
+ job.endedAt = new Date().toISOString();
4318
+ broadcast({ type: 'clone-changed', job: publicCloneJob(job) });
4319
+ // Keep finished jobs for an hour so a reload can still read the outcome.
4320
+ setTimeout(() => CLONE_JOBS.delete(job.id), 3600_000).unref?.();
4321
+ })();
4322
+ return job;
4323
+ }
4324
+ app.post('/api/projects/clone', async (req, res) => {
4325
+ const body = req.body || {};
4326
+ try {
4327
+ const job = await startCloneJob({ url: body.url, branch: body.branch ?? null, name: body.name ?? null });
4328
+ res.status(202).json({ jobId: job.id, job: publicCloneJob(job) });
4329
+ } catch (err) {
4330
+ const code = err instanceof CloneError ? err.code : 'failed';
4331
+ res.status(CLONE_STATUS[code] || 500).json({ error: err && err.message ? err.message : String(err), code });
4332
+ }
4333
+ });
4334
+ app.get('/api/projects/clone/:id', (req, res) => {
4335
+ const job = CLONE_JOBS.get(String(req.params.id));
4336
+ if (!job) return res.status(404).json({ error: 'clone job not found' });
4337
+ res.json({ job: publicCloneJob(job) });
4338
+ });
4339
+
4093
4340
  app.delete('/api/projects', async (req, res) => {
4094
4341
  const name = typeof req.query.name === 'string' ? req.query.name : '';
4095
4342
  if (!name.trim()) return badRequest(res, 'name is required');
@@ -4669,6 +4916,9 @@ const uiControl = { token: null, onShutdown: null, startedAt: null };
4669
4916
  const startedAtIso = () => uiControl.startedAt || null;
4670
4917
 
4671
4918
  app.get('/api/health', (req, res) => {
4919
+ // Remote mode: it is the one unauthenticated route, so a caller from outside
4920
+ // the box learns only what it is, not the pid/bind/port/boot time.
4921
+ if (REMOTE_MODE && !isInContainer(req)) return res.json({ name: UI_HEALTH_NAME, version: PKG_VERSION });
4672
4922
  const addr = req.socket && req.socket.localPort;
4673
4923
  res.json({
4674
4924
  name: UI_HEALTH_NAME,
@@ -4680,6 +4930,14 @@ app.get('/api/health', (req, res) => {
4680
4930
  });
4681
4931
  });
4682
4932
 
4933
+ // Who this request is, for the header's "Signed in as" (identity.mjs). Attribution only.
4934
+ // `shared`: a real per-person sign-in (Access or a trusted header), the one case where the UI
4935
+ // shows people at all; a local install or a one-person WORCA_IDENTITY_NAME deployment shows none.
4936
+ app.get('/api/whoami', (req, res) => {
4937
+ const who = resolveIdentity(req);
4938
+ res.json(who.source === 'local' ? { name: null, source: 'local', shared: false } : { ...who, shared: isSharedIdentity(who.source) });
4939
+ });
4940
+
4683
4941
  /** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
4684
4942
  function bearerMatches(header, expected) {
4685
4943
  if (!expected || typeof header !== 'string') return false;
@@ -5832,14 +6090,22 @@ function askRunningCount() {
5832
6090
  /** hello payload: running turns only (§8.2). A job whose slot was just
5833
6091
  * reserved (messageId still null — the message route's atomic reservation,
5834
6092
  * Task 6) is skipped: it becomes visible once its assistant row exists. */
5835
- function askHello() {
6093
+ function askHello(ws = null) {
5836
6094
  const out = [];
5837
6095
  for (const [threadId, job] of askJobs.entries()) {
5838
- if (job.status === 'running' && job.messageId) out.push({ threadId, messageId: job.messageId });
6096
+ if (job.status === 'running' && job.messageId && (!ws || askSocketSees(ws, threadId))) out.push({ threadId, messageId: job.messageId });
5839
6097
  }
5840
6098
  return out;
5841
6099
  }
5842
6100
 
6101
+ /** Whether this socket may receive thread `threadId`'s frames (its owner, or not a shared viewer). */
6102
+ function askSocketSees(ws, threadId) {
6103
+ if (!ws || !ws.worcaViewer) return true;
6104
+ let owner = null;
6105
+ try { owner = askGetThread(threadId)?.createdBy || null; } catch { return true; }
6106
+ return !owner || owner === ws.worcaViewer;
6107
+ }
6108
+
5843
6109
  /** Replay a job's stamped ring buffer to one socket. No state snapshot — the
5844
6110
  * REST thread GET is the snapshot; the client dedupes by seq (§6.6). */
5845
6111
  function replayAskJob(ws, job) {
@@ -5895,12 +6161,13 @@ app.get('/api/ask/threads', (req, res) => {
5895
6161
  try {
5896
6162
  const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
5897
6163
  const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
5898
- const threads = askListThreads({ limit }).map((t) => {
6164
+ const visibleTo = askViewer(req);
6165
+ const threads = askListThreads({ limit, visibleTo }).map((t) => {
5899
6166
  const trackingRuns = askTrackingCount(t.id);
5900
6167
  return { ...t, inFlight: !!askInFlight(t.id), tracking: trackingRuns > 0, trackingRuns };
5901
6168
  });
5902
6169
  // total = EVERY saved chat (the History popover's meter), not the capped page above.
5903
- res.json({ threads, total: askCountThreads() });
6170
+ res.json({ threads, total: askCountThreads({ visibleTo }) });
5904
6171
  } catch (err) {
5905
6172
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
5906
6173
  }
@@ -5911,7 +6178,7 @@ app.get('/api/ask/threads', (req, res) => {
5911
6178
  app.get('/api/ask/history', (req, res) => {
5912
6179
  try {
5913
6180
  res.json({
5914
- threads: askCountThreads(),
6181
+ threads: askCountThreads({ visibleTo: askViewer(req) }),
5915
6182
  worktrees: askCountWorktrees(),
5916
6183
  attachments: askCountAttachments(),
5917
6184
  inFlight: askRunningCount(),
@@ -5931,10 +6198,14 @@ app.delete('/api/ask/threads', async (req, res) => {
5931
6198
  const removed = { threads: 0, worktrees: 0 };
5932
6199
  const failed = [];
5933
6200
  try {
5934
- for (const id of askListThreadIds()) {
6201
+ // A shared deployment's "delete all" deletes only the caller's own threads.
6202
+ const viewer = askViewer(req);
6203
+ const deleted = [];
6204
+ for (const id of askListThreadIds(viewer ? { ownedBy: viewer } : {})) {
5935
6205
  try {
5936
6206
  const r = await deleteAskThreadFully(id);
5937
6207
  if (r.deleted) {
6208
+ deleted.push(id);
5938
6209
  removed.threads += 1;
5939
6210
  removed.worktrees += r.worktrees;
5940
6211
  } else failed.push(id);
@@ -5943,7 +6214,8 @@ app.delete('/api/ask/threads', async (req, res) => {
5943
6214
  }
5944
6215
  }
5945
6216
  res.json({ ok: true, removed, failed });
5946
- broadcast({ type: 'ask-history-cleared' });
6217
+ // Shared: name the deleted threads, so other people's tabs keep theirs open.
6218
+ broadcast({ type: 'ask-history-cleared', ...(viewer ? { threadIds: deleted } : {}) });
5947
6219
  } catch (err) {
5948
6220
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
5949
6221
  }
@@ -5959,7 +6231,7 @@ app.post('/api/ask/threads', (req, res) => {
5959
6231
  }
5960
6232
  title = body.title.trim() || null;
5961
6233
  }
5962
- const thread = askCreateThread();
6234
+ const thread = askCreateThread({ createdBy: actorOf(req) });
5963
6235
  if (title) askUpdateThread(thread.id, { title });
5964
6236
  res.status(201).json({ thread: askGetThread(thread.id) });
5965
6237
  } catch (err) {
@@ -6225,7 +6497,7 @@ function askValidateScope(raw) {
6225
6497
  * "Create and run scripts" pref is on (W20) — the scripts section with the runtimes this host
6226
6498
  * actually has (the python probe, cached 60 s). Memory is mounted, not rendered. */
6227
6499
  async function askSystemPromptFor(catalog) {
6228
- return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput() });
6500
+ return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput(), deployment: DEPLOYMENT });
6229
6501
  }
6230
6502
 
6231
6503
  /** "scheduled Sat Sep 19, 02:00 (run 1a2b…)" / "repeats: Every weekday at 02:00 (sch_…)" / "proposes: …" — or ''. */
@@ -6246,8 +6518,14 @@ function askCardScheduleLine(b, tz = null) {
6246
6518
  * buildContextHeader consumes (§6.5: server-resolved rows only — never
6247
6519
  * client-supplied titles or paths). Every lookup is individually guarded:
6248
6520
  * a vanished row degrades to an absent header line, never a 500. */
6249
- async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
6521
+ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null, { signedIn = null } = {}) {
6250
6522
  const out = { now: new Date().toISOString() };
6523
+ // Where worca runs (absent on a local install) and who the sign-in proxy verified for this request.
6524
+ try {
6525
+ const facts = deploymentFacts(process.env, { remoteMode: REMOTE_MODE, projectsRoot: getProjectsRoot() });
6526
+ if (facts) out.deployment = facts;
6527
+ } catch { /* absent line */ }
6528
+ if (typeof signedIn === 'string' && signedIn) out.signedIn = signedIn;
6251
6529
  if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
6252
6530
  if (ctx.timeZone) out.timeZone = ctx.timeZone; // validated IANA name; the header adds the user's clock
6253
6531
  if (ctx.view) out.view = ctx.view;
@@ -6340,7 +6618,7 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
6340
6618
  // workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
6341
6619
  const wf = !!(b.card && b.card.type === 'workflow');
6342
6620
  if (wf && b.state === 'building') continue; // transient (no name yet) — never worth a header line
6343
- if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy')) {
6621
+ if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy' || b.card.type === 'clone')) {
6344
6622
  cards.push({ id: b.id, type: b.card.type, state: b.state, summary: b.card.summary || '' });
6345
6623
  continue;
6346
6624
  }
@@ -6398,7 +6676,13 @@ function mockAskCard(ctx = {}, text = '') {
6398
6676
  * text what the MODEL gets as the user message: the typed text, or the `[worca event] …` line (synthetic)
6399
6677
  * synthetic the user row renders as a notice (never a bubble) and never titles the thread (PD6)
6400
6678
  */
6401
- async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, files = [], synthetic = null }) {
6679
+ /** The identity Ask Worca's header shows: resolved like every other attribution, absent for 'local'. */
6680
+ function askSignedIn(req) {
6681
+ const who = resolveIdentity(req);
6682
+ return who.source === 'local' ? null : who.name;
6683
+ }
6684
+
6685
+ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, files = [], synthetic = null, signedIn = null, reader = null }) {
6402
6686
  // §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
6403
6687
  // top 409/429 pair and here resolves in microtasks (validateModelEffort ->
6404
6688
  // composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
@@ -6471,7 +6755,7 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
6471
6755
  const catalog = await askBuildCatalog();
6472
6756
  const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
6473
6757
  const { inline, listed } = askSelectInlineAttachments(withText);
6474
- const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
6758
+ const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id, { signedIn });
6475
6759
  const systemPrompt = await askSystemPromptFor(catalog);
6476
6760
  const header = askBuildContextHeader(headerCtx);
6477
6761
  const prompt = askBuildTurnPrompt(header, text, inline);
@@ -6482,6 +6766,8 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
6482
6766
 
6483
6767
  turn = createAskTurn({
6484
6768
  threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
6769
+ // A shared sign-in's name: the MCP child reads/marks notifications per person (step 3).
6770
+ reader: reader || (thread.createdBy && askSharedOwner(thread) ? thread.createdBy : null),
6485
6771
  prompt, systemPrompt, restoredPrompt,
6486
6772
  model, effort,
6487
6773
  resumeSessionId: thread.sessionId || null,
@@ -6628,7 +6914,7 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
6628
6914
  }
6629
6915
  }
6630
6916
 
6631
- const r = await startAskTurn({ threadId: id, thread, ctx, model: mv.model, effort: mv.effort, text, files });
6917
+ const r = await startAskTurn({ threadId: id, thread, ctx, model: mv.model, effort: mv.effort, text, files, signedIn: askSignedIn(req), reader: askViewer(req) });
6632
6918
  if (!r.ok) return res.status(r.status).json({ error: r.error, ...(r.budget ? { budget: r.budget } : {}) });
6633
6919
  // `attachments` carries the store-minted ids so the sender's own echo can key
6634
6920
  // image thumbnails and the thread budget off them (the ask-message broadcast
@@ -6772,9 +7058,9 @@ async function startMetricsEventTurn(threadId, block) {
6772
7058
  const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
6773
7059
  const result = card.result || null;
6774
7060
  // One event turn for every non-workflow card; the type picks the wording. Metrics is the fallback.
6775
- const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' ? card.type : 'metrics';
6776
- const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, metrics: metricsEventPrompt }[kind];
6777
- const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, metrics: metricsNoticeText }[kind];
7061
+ const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' ? card.type : 'metrics';
7062
+ const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, clone: cloneEventPrompt, metrics: metricsEventPrompt }[kind];
7063
+ const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, clone: cloneNoticeText, metrics: metricsNoticeText }[kind];
6778
7064
  const text = eventPrompt({ cardId: block.id, state, card, result });
6779
7065
  const notice = noticeText({ state, card, result });
6780
7066
  let mv = await validateModelEffort(thread.model, thread.effort);
@@ -6797,6 +7083,23 @@ async function startMetricsEventTurn(threadId, block) {
6797
7083
  return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
6798
7084
  }
6799
7085
 
7086
+ /** Follow a clone card's job to its end: flip the card to applied | failed and start the event turn.
7087
+ * Reads the job object startCloneJob returned (it is updated in place when the clone ends). */
7088
+ function followCloneCard(threadId, cardId, job, { everyMs = 500 } = {}) {
7089
+ const timer = setInterval(async () => {
7090
+ if (!job || job.state === 'running') return;
7091
+ clearInterval(timer);
7092
+ const result = job.state === 'done'
7093
+ ? { ok: true, jobId: job.id, project: job.project ? { name: job.project.name, path: job.project.path } : null }
7094
+ : { ok: false, jobId: job.id, code: job.code || 'failed', error: job.error || 'the clone failed' };
7095
+ const block = flipCard(threadId, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
7096
+ if (!block) return;
7097
+ try { await startMetricsEventTurn(threadId, block); }
7098
+ catch (err) { console.error(`[worca-ui] clone card event turn failed: ${err && err.message ? err.message : err}`); }
7099
+ }, everyMs);
7100
+ timer.unref?.();
7101
+ }
7102
+
6800
7103
  // D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed') for a RUN card;
6801
7104
  // the workflow-card state machine (spec §8.4) for a workflow one. The card is looked up BEFORE
6802
7105
  // the body is validated because the legal verb set depends on the card's type.
@@ -6827,7 +7130,7 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
6827
7130
  let block;
6828
7131
  try {
6829
7132
  let result;
6830
- try { result = await applyScheduleCard(found.block.card); }
7133
+ try { result = await applyScheduleCard(found.block.card, { by: actorOf(req) }); }
6831
7134
  catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
6832
7135
  block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
6833
7136
  } finally { askCardBusy.delete(cardId); }
@@ -6859,6 +7162,37 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
6859
7162
  const turn = await startMetricsEventTurn(id, block);
6860
7163
  return res.json({ block, turn });
6861
7164
  }
7165
+ if (found.block.card && found.block.card.type === 'clone') {
7166
+ // Clone card (docs/deploy-railway.md "First project"): proposed → cloning → applied | failed, or declined.
7167
+ // The clone happens HERE, behind the click, as the same job the Projects view starts (startCloneJob);
7168
+ // a refusal known up front fails the card at once, otherwise the card follows the job to its end.
7169
+ if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
7170
+ if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
7171
+ if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
7172
+ if (body.state === 'declined') {
7173
+ const block = flipCard(id, cardId, { state: 'declined' });
7174
+ if (!block) return res.status(409).json({ error: 'card vanished' });
7175
+ const turn = await startMetricsEventTurn(id, block);
7176
+ return res.json({ block, turn });
7177
+ }
7178
+ askCardBusy.add(cardId);
7179
+ let block;
7180
+ let job = null;
7181
+ try {
7182
+ const ch = found.block.card.change || {};
7183
+ try {
7184
+ job = await startCloneJob({ url: ch.url, branch: ch.branch ?? null, name: ch.name ?? null });
7185
+ block = flipCard(id, cardId, { state: 'cloning', card: { result: { ok: null, jobId: job.id } } });
7186
+ } catch (err) {
7187
+ const result = { ok: false, code: (err && err.code) || 'failed', error: err && err.message ? err.message : String(err) };
7188
+ block = flipCard(id, cardId, { state: 'failed', error: result.error, card: { result } });
7189
+ }
7190
+ } finally { askCardBusy.delete(cardId); }
7191
+ if (!block) return res.status(409).json({ error: 'card vanished' });
7192
+ if (block.state === 'failed') return res.json({ block, turn: await startMetricsEventTurn(id, block) });
7193
+ followCloneCard(id, cardId, job);
7194
+ return res.json({ block });
7195
+ }
6862
7196
  if (found.block.card && (found.block.card.type === 'metrics' || found.block.card.type === 'policy')) {
6863
7197
  // Metrics / policy card (docs/team-metrics.md, docs/team-policy.md "Ask Worca"): proposed → applied | failed |
6864
7198
  // declined. The change is the outward-facing part — a branch on origin, a commit to the team's policy, a
@@ -8340,6 +8674,9 @@ export async function bootMaintenance({ log } = {}) {
8340
8674
  try {
8341
8675
  const interrupted = sweepStreamingMessages();
8342
8676
  const emptyThreads = sweepEmptyThreads();
8677
+ // A clone job lives in this process: a clone card still `cloning` from the last one can never finish.
8678
+ const clones = sweepCloningCards();
8679
+ if (clones) console.log(`[worca-ui] ask sweep: ${clones} clone card(s) interrupted by the restart`);
8343
8680
  summary.ask = { interrupted, emptyThreads };
8344
8681
  if (interrupted || emptyThreads) {
8345
8682
  console.log(`[worca-ui] ask sweep: ${interrupted} interrupted turn(s), ${emptyThreads} empty thread(s)`);
@@ -8383,6 +8720,14 @@ export async function bootMaintenance({ log } = {}) {
8383
8720
  // test, skip listening so the test can mount `app` on its own ephemeral port.
8384
8721
  const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
8385
8722
  if (isMain) {
8723
+ // Remote access fails closed: an unsafe or broken config never starts serving.
8724
+ if (REMOTE_ACCESS_CHECK.errors.length) {
8725
+ for (const e of REMOTE_ACCESS_CHECK.errors) console.error(`[worca-ui] remote access: ${e}`);
8726
+ console.error('[worca-ui] not starting. See docs/remote-access.md.');
8727
+ process.exit(1);
8728
+ }
8729
+ for (const w of REMOTE_ACCESS_CHECK.warnings) console.warn(`[worca-ui] remote access: ${w}`);
8730
+
8386
8731
  try {
8387
8732
  seedBuiltinMarketplace();
8388
8733
  } catch (err) {
@@ -8427,6 +8772,10 @@ if (isMain) {
8427
8772
  const port = server.address().port;
8428
8773
  const url = uiUrl({ host: HOST, port });
8429
8774
  console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
8775
+ if (REMOTE_MODE) {
8776
+ const who = identityCheck ? `identity: ${REMOTE_ACCESS.identity.provider} (${REMOTE_ACCESS.identity.teamDomain})` : 'identity: NOT CHECKED';
8777
+ console.log(`[worca-ui] remote access on for ${REMOTE_ACCESS.allowedHosts.join(', ')}; ${who}`);
8778
+ }
8430
8779
  uiControl.token = newUiToken();
8431
8780
  uiControl.onShutdown = shutdown;
8432
8781
  uiControl.startedAt = new Date().toISOString();
@@ -8471,6 +8820,7 @@ export const _testing = {
8471
8820
  wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, wireScriptBench, startScriptBench,
8472
8821
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, answerRun,
8473
8822
  chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
8823
+ startCloneJob, followCloneCard, CLONE_JOBS,
8474
8824
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
8475
8825
  askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
8476
8826
  uiControl, bearerMatches,