@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.
- package/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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', (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|