@worca/app 1.4.0 → 1.5.0-rc.2
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
|
@@ -52,6 +52,8 @@ import { writeFile, mkdir, appendFile, readFile, access, readdir } from 'node:fs
|
|
|
52
52
|
import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
53
53
|
import { dirname, join } from 'node:path';
|
|
54
54
|
import { tmpdir } from 'node:os';
|
|
55
|
+
import { stripGithubCredentials } from './github-credentials.mjs';
|
|
56
|
+
import { agentIdentity, agentSpawn, killAgentGroup, shareWithAgent } from './agent-user.mjs';
|
|
55
57
|
|
|
56
58
|
const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
|
|
57
59
|
|
|
@@ -395,6 +397,9 @@ export async function runClaude(o = {}) {
|
|
|
395
397
|
appendSubagentSystemPrompt,
|
|
396
398
|
addDirs,
|
|
397
399
|
argvInlineLimit,
|
|
400
|
+
// A pipeline agent (phases.mjs): runs as WORCA_AGENT_USER when the container set one
|
|
401
|
+
// up (agent-user.mjs). Server-side helpers and Ask Worca leave it unset.
|
|
402
|
+
asAgent,
|
|
398
403
|
bin = DEFAULT_BIN,
|
|
399
404
|
} = o;
|
|
400
405
|
|
|
@@ -441,6 +446,7 @@ export async function runClaude(o = {}) {
|
|
|
441
446
|
appendSubagentSystemPrompt,
|
|
442
447
|
addDirs,
|
|
443
448
|
argvInlineLimit,
|
|
449
|
+
asAgent,
|
|
444
450
|
});
|
|
445
451
|
}
|
|
446
452
|
|
|
@@ -505,7 +511,7 @@ export function buildClaudeArgs({
|
|
|
505
511
|
if (tools.length) {
|
|
506
512
|
args.push('--allowedTools', tools.join(','));
|
|
507
513
|
}
|
|
508
|
-
// Model bridge (model-bridge-design.md §5.3): a translated (openai-chat)
|
|
514
|
+
// Model bridge (model-bridge-design.md §5.3): a translated (openai-chat / openai-responses)
|
|
509
515
|
// model has no server-side web tools, so the runner withholds them outright —
|
|
510
516
|
// a deny outranks any allow, frontmatter grant included. Absent/empty ⇒
|
|
511
517
|
// nothing emitted, so every other argv stays byte-identical.
|
|
@@ -595,7 +601,7 @@ export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_IN
|
|
|
595
601
|
return { ...plan, dir };
|
|
596
602
|
}
|
|
597
603
|
|
|
598
|
-
function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, disallowedTools, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, addDirs, argvInlineLimit }) {
|
|
604
|
+
function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, disallowedTools, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, addDirs, argvInlineLimit, asAgent }) {
|
|
599
605
|
return new Promise((resolveP, rejectP) => {
|
|
600
606
|
// Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
|
|
601
607
|
// are re-dropped here defensively — the write path already rejects them, so
|
|
@@ -704,6 +710,10 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
704
710
|
// drop it — WORCA_ is not an allowlisted prefix — so it is added AFTER).
|
|
705
711
|
if (guardOn) spawnEnv = { ...(spawnEnv ?? process.env), WORCA_HOST_PID: String(process.pid) };
|
|
706
712
|
|
|
713
|
+
// No GitHub credential reaches claude, in any guardrail tier, from a per-project allowlist or a
|
|
714
|
+
// model env alike (src/core/github-credentials.mjs): pushes and PRs are worca's own calls.
|
|
715
|
+
spawnEnv = stripGithubCredentials(spawnEnv ?? process.env);
|
|
716
|
+
|
|
707
717
|
// Opt-in spawn diagnostics (WORCA_DEBUG_SPAWN, default off — byte-identical spawn
|
|
708
718
|
// path when unset). Everything here is derived from values already computed above
|
|
709
719
|
// (safeModelEnv is null or non-empty, so the routing field is either the described
|
|
@@ -723,10 +733,20 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
723
733
|
safeEmit(onEvent, { type: 'stderr', stream: 'err', text: summary });
|
|
724
734
|
}
|
|
725
735
|
|
|
736
|
+
// Agent isolation (agent-user.mjs): the same command under the agent's uid, via sudo, in its
|
|
737
|
+
// own process group so a stuck agent can still be SIGKILLed through sudo.
|
|
738
|
+
const agentId = asAgent ? agentIdentity() : null;
|
|
726
739
|
let child;
|
|
727
740
|
try {
|
|
728
|
-
|
|
741
|
+
let file = resolved.bin;
|
|
742
|
+
let argv = args;
|
|
743
|
+
if (agentId) {
|
|
744
|
+
if (plan.dir) shareWithAgent(plan.dir, agentId);
|
|
745
|
+
({ file, args: argv, env: spawnEnv } = agentSpawn(resolved.bin, args, spawnEnv, agentId));
|
|
746
|
+
}
|
|
747
|
+
child = spawn(file, argv, {
|
|
729
748
|
cwd, stdio: [plan.stdin != null ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}),
|
|
749
|
+
...(agentId ? { detached: true } : {}),
|
|
730
750
|
});
|
|
731
751
|
} catch (err) {
|
|
732
752
|
cleanupStaged();
|
|
@@ -761,11 +781,14 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
761
781
|
}
|
|
762
782
|
// Escalate if it ignores SIGTERM.
|
|
763
783
|
setTimeout(() => {
|
|
784
|
+
// Only a sudo still running holds the group: after it exits the pid may be reused.
|
|
785
|
+
const stuck = child.exitCode === null && child.signalCode === null;
|
|
764
786
|
try {
|
|
765
787
|
child.kill('SIGKILL');
|
|
766
788
|
} catch {
|
|
767
789
|
/* ignore */
|
|
768
790
|
}
|
|
791
|
+
if (agentId && stuck) killAgentGroup(child.pid, agentId);
|
|
769
792
|
}, sigkillGraceMs()).unref?.();
|
|
770
793
|
};
|
|
771
794
|
if (signal) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Add a project by cloning a repository into the projects folder (docs/deploy-railway.md).
|
|
2
|
+
// On a hosted worca this replaces a `railway ssh` session: the clone runs in the server,
|
|
3
|
+
// as the server's user, with the read credential for that one command
|
|
4
|
+
// (github-credentials.mjs#githubEnv), and lands in a NEW folder directly under the
|
|
5
|
+
// projects root. Registration is the ordinary add-project step.
|
|
6
|
+
//
|
|
7
|
+
// URLs https only; no credentials inside the URL (use the token variables)
|
|
8
|
+
// allow WORCA_CLONE_ALLOW, e.g. "github.com/acme/*,github.com/you/app"; unset = any
|
|
9
|
+
// where <projects root>/<name>; name defaults to the repository name, is sanitised,
|
|
10
|
+
// and an existing folder is refused, never overwritten
|
|
11
|
+
// size --filter=blob:none (partial clone), a timeout (default 10 minutes), and a
|
|
12
|
+
// failed clone removes its folder
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { rm } from 'node:fs/promises';
|
|
16
|
+
import { join, resolve, sep } from 'node:path';
|
|
17
|
+
import { githubEnv, stripGithubCredentials } from './github-credentials.mjs';
|
|
18
|
+
|
|
19
|
+
export const CLONE_TIMEOUT_MS = 10 * 60_000;
|
|
20
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
21
|
+
const SEG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
const BRANCH_RE = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[A-Za-z0-9._\/-]{1,200}(?<![./])$/;
|
|
23
|
+
|
|
24
|
+
/** A clone refusal: `code` is one of invalid | not-allowed | exists | auth-failed | not-found | timeout | failed. */
|
|
25
|
+
export class CloneError extends Error {
|
|
26
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** WORCA_CLONE_ALLOW -> ["github.com/acme/*", "github.com/you/app"] (lowercased). */
|
|
30
|
+
export function parseCloneAllow(value) {
|
|
31
|
+
return String(value || '').split(',').map((s) => s.trim().toLowerCase().replace(/\/+$/, '')).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when host/owner/repo matches an allow entry ("host/owner/*" or "host/owner/repo"). Empty list = any. */
|
|
35
|
+
export function cloneAllowed(allow, { host, owner, repo }) {
|
|
36
|
+
if (!allow.length) return true;
|
|
37
|
+
const full = `${host}/${owner}/${repo}`.toLowerCase();
|
|
38
|
+
return allow.some((p) => (p.endsWith('/*') ? full.startsWith(p.slice(0, -1)) : full === p));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a clone request. Pure (besides existsSync on the target). Returns
|
|
43
|
+
* { url, host, owner, repo, name, dir, branch } or throws CloneError('invalid'|'not-allowed'|'exists').
|
|
44
|
+
*/
|
|
45
|
+
export function planClone({ url, branch = null, name = null } = {}, { projectsRoot, env = process.env, exists = existsSync } = {}) {
|
|
46
|
+
if (typeof url !== 'string' || !url.trim()) throw new CloneError('invalid', 'a repository URL is required');
|
|
47
|
+
let u;
|
|
48
|
+
try { u = new URL(url.trim()); } catch { throw new CloneError('invalid', 'not a valid URL'); }
|
|
49
|
+
if (u.protocol !== 'https:') throw new CloneError('invalid', 'only https:// repository URLs are supported');
|
|
50
|
+
if (u.username || u.password) {
|
|
51
|
+
throw new CloneError('invalid', 'the URL contains credentials; remove them and set the GitHub token or App variables where worca is deployed');
|
|
52
|
+
}
|
|
53
|
+
if (u.search || u.hash) throw new CloneError('invalid', 'the URL must not have a query or fragment');
|
|
54
|
+
if (u.port) throw new CloneError('invalid', 'the URL must not name a port');
|
|
55
|
+
const host = u.hostname.toLowerCase();
|
|
56
|
+
const segs = u.pathname.replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);
|
|
57
|
+
if (segs.length !== 2 || !segs.every((s) => SEG_RE.test(s))) {
|
|
58
|
+
throw new CloneError('invalid', 'the URL must name one repository, like https://github.com/owner/repo');
|
|
59
|
+
}
|
|
60
|
+
const [owner, repo] = segs;
|
|
61
|
+
if (!cloneAllowed(parseCloneAllow(env.WORCA_CLONE_ALLOW), { host, owner, repo })) {
|
|
62
|
+
throw new CloneError('not-allowed', `${host}/${owner}/${repo} is not in WORCA_CLONE_ALLOW`);
|
|
63
|
+
}
|
|
64
|
+
if (branch != null && branch !== '' && (typeof branch !== 'string' || !BRANCH_RE.test(branch.trim()))) {
|
|
65
|
+
throw new CloneError('invalid', 'not a valid branch name');
|
|
66
|
+
}
|
|
67
|
+
const folder = name == null || name === '' ? repo : String(name).trim();
|
|
68
|
+
if (!NAME_RE.test(folder) || folder === '.' || folder === '..') {
|
|
69
|
+
throw new CloneError('invalid', 'the folder name may use letters, digits, ".", "_" and "-" (up to 100)');
|
|
70
|
+
}
|
|
71
|
+
if (!projectsRoot) throw new CloneError('invalid', 'no projects folder is configured');
|
|
72
|
+
const root = resolve(projectsRoot);
|
|
73
|
+
const dir = join(root, folder);
|
|
74
|
+
if (!dir.startsWith(root + sep)) throw new CloneError('invalid', 'the folder must be directly under the projects folder');
|
|
75
|
+
if (exists(dir)) throw new CloneError('exists', `${dir} already exists; pick another folder name`);
|
|
76
|
+
return {
|
|
77
|
+
url: `https://${host}/${owner}/${repo}.git`, host, owner, repo, name: folder, dir,
|
|
78
|
+
branch: branch ? branch.trim() : null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function classify(stderr) {
|
|
83
|
+
const s = String(stderr || '');
|
|
84
|
+
if (/Authentication failed|could not read Username|terminal prompts disabled|403/i.test(s)) {
|
|
85
|
+
return new CloneError('auth-failed', 'GitHub refused the credential: check the token or that the App is installed on this repository');
|
|
86
|
+
}
|
|
87
|
+
if (/Remote branch .* not found/i.test(s)) return new CloneError('not-found', 'that branch does not exist');
|
|
88
|
+
if (/Repository not found|not found|404/i.test(s)) {
|
|
89
|
+
return new CloneError('not-found', 'repository not found, or the credential cannot see it');
|
|
90
|
+
}
|
|
91
|
+
const last = s.trim().split('\n').filter(Boolean).pop() || 'git clone failed';
|
|
92
|
+
return new CloneError('failed', last.replace(/https:\/\/[^@\s]+@/g, 'https://'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Run `git clone` for a plan. Resolves the plan; rejects with CloneError after removing the folder. */
|
|
96
|
+
export async function runClone(plan, { timeoutMs = CLONE_TIMEOUT_MS, credential = githubEnv, spawnImpl = spawn } = {}) {
|
|
97
|
+
// GitHub gets the read credential for this one command; any other host gets no GitHub credential.
|
|
98
|
+
const cred = plan.host === 'github.com'
|
|
99
|
+
? await credential('read', { repo: `${plan.owner}/${plan.repo}` })
|
|
100
|
+
: { env: stripGithubCredentials(process.env), error: null };
|
|
101
|
+
if (cred.error) throw new CloneError('auth-failed', cred.error);
|
|
102
|
+
const args = ['clone', '--filter=blob:none', ...(plan.branch ? ['--branch', plan.branch] : []), '--', plan.url, plan.dir];
|
|
103
|
+
const env = { ...cred.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', LC_ALL: 'C' };
|
|
104
|
+
const { code, stderr, timedOut } = await new Promise((done) => {
|
|
105
|
+
let err = '';
|
|
106
|
+
let child;
|
|
107
|
+
try { child = spawnImpl('git', args, { env, stdio: ['ignore', 'ignore', 'pipe'] }); }
|
|
108
|
+
catch (e) { done({ code: -1, stderr: e.message, timedOut: false }); return; }
|
|
109
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } done({ code: -1, stderr: err, timedOut: true }); }, timeoutMs);
|
|
110
|
+
child.stderr?.on('data', (b) => { if (err.length < 64_000) err += b.toString(); });
|
|
111
|
+
child.on('error', (e) => { clearTimeout(timer); done({ code: -1, stderr: err || e.message, timedOut: false }); });
|
|
112
|
+
child.on('close', (c) => { clearTimeout(timer); done({ code: c ?? -1, stderr: err, timedOut: false }); });
|
|
113
|
+
});
|
|
114
|
+
if (code === 0) return plan;
|
|
115
|
+
await rm(plan.dir, { recursive: true, force: true }).catch(() => {});
|
|
116
|
+
if (timedOut) throw new CloneError('timeout', `the clone took longer than ${Math.round(timeoutMs / 60_000)} minutes`);
|
|
117
|
+
throw classify(stderr);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Plan, clone, register: the one path the API, the CLI and Ask Worca's card share. The
|
|
122
|
+
* project name (= folder name) is checked against registered projects BEFORE cloning, so
|
|
123
|
+
* a clash never leaves a stray folder. Resolves { project, plan }; rejects with CloneError.
|
|
124
|
+
*/
|
|
125
|
+
export async function cloneProject(req, { projectsRoot, env = process.env, listProjects, addProject, run = runClone } = {}) {
|
|
126
|
+
const plan = planClone(req, { projectsRoot, env });
|
|
127
|
+
const existing = await listProjects();
|
|
128
|
+
if (existing.some((p) => p && typeof p.name === 'string' && p.name.toLowerCase() === plan.name.toLowerCase())) {
|
|
129
|
+
throw new CloneError('exists', `a project named "${plan.name}" already exists; pick another folder name`);
|
|
130
|
+
}
|
|
131
|
+
await run(plan);
|
|
132
|
+
try {
|
|
133
|
+
const projects = await addProject({ name: plan.name, path: plan.dir });
|
|
134
|
+
return { project: projects.find((p) => p.path === plan.dir) || { name: plan.name, path: plan.dir }, plan };
|
|
135
|
+
} catch (e) {
|
|
136
|
+
await rm(plan.dir, { recursive: true, force: true }).catch(() => {});
|
|
137
|
+
throw new CloneError('failed', e.message);
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/core/config.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { getDb, prepare, tx } from './db.mjs';
|
|
|
16
16
|
import { projectKey } from './store.mjs';
|
|
17
17
|
import { AUTO_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
|
|
18
18
|
import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
|
|
19
|
-
import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue, BRIDGE_ROUTING_KEYS, bridgeExcludedTools } from './model-env.mjs';
|
|
19
|
+
import { EFFORTS, prepareModelEnv, withTierModelEnv, withProviderModesOff, PROVIDER_MODE_ENV_KEYS, isSubagentModelValue, subagentModelIssue, BRIDGE_ROUTING_KEYS, bridgeExcludedTools, isTranslatedApi } from './model-env.mjs';
|
|
20
20
|
import { findBridgedEntry, providerReadiness } from './bridge/registry.mjs';
|
|
21
21
|
import { bridgeBaseUrl, bridgeSecret } from './bridge/server.mjs';
|
|
22
22
|
import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels, readSettings, memoryDefragModel, setMemoryDefragModel } from './settings.mjs';
|
|
@@ -70,11 +70,13 @@ export { EFFORTS };
|
|
|
70
70
|
* stored pin on the retired id to the successor, so nothing keeps it here. Opus 5.5
|
|
71
71
|
* (`claude-opus-5-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
|
|
72
72
|
* carry no `[1m]` twin. Opus 5.5 replaced Opus 5 (`claude-opus-5`) on 2026-09-22
|
|
73
|
-
* (verified to resolve via `claude --model`, CLI 2.1.280); db.mjs V35
|
|
74
|
-
* stored pins the same way.
|
|
73
|
+
* (verified to resolve via `claude --model`, CLI 2.1.280); db.mjs V35 moved the
|
|
74
|
+
* stored pins the same way. Opus 5 came back beside it on 2026-09-23 so both can
|
|
75
|
+
* be picked; V35 is shipped and stays, so pins it already moved stay on Opus 5.5.
|
|
75
76
|
*/
|
|
76
77
|
export const PREDEFINED_MODELS = [
|
|
77
78
|
{ id: 'claude-opus-5-5', label: 'Opus 5.5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
79
|
+
{ id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
78
80
|
{ id: 'claude-fable-5-1', label: 'Fable 5.1 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
79
81
|
{ id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
80
82
|
{ id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
@@ -291,7 +293,7 @@ export function modelHasBaseUrlRouting(modelId) {
|
|
|
291
293
|
* The bridge facts for a model id (model-bridge-design.md §4.2/§8.5), or null
|
|
292
294
|
* when it is not a bridged entry: `{id, provider, api, upstreamModel,
|
|
293
295
|
* excludeTools, ready, reason?, message?}`. `excludeTools` are the CLI built-ins
|
|
294
|
-
* the runner must withhold (web tools have no chat/completions equivalent);
|
|
296
|
+
* the runner must withhold (web tools have no chat/completions or Responses API equivalent);
|
|
295
297
|
* `ready` is the provider's sign-in state — a spawn fails fast on it instead
|
|
296
298
|
* of with an opaque 401 mid-run. Synchronous; never throws.
|
|
297
299
|
*/
|
|
@@ -498,6 +500,7 @@ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined
|
|
|
498
500
|
export const PREDEFINED_LIST_PRICES = Object.freeze({
|
|
499
501
|
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
|
|
500
502
|
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5, cacheWrite1h: 8 },
|
|
503
|
+
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
501
504
|
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
502
505
|
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
503
506
|
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
@@ -582,7 +585,9 @@ export function resolveModelEnv(modelId, { tag } = {}) {
|
|
|
582
585
|
for (const k of dropped) {
|
|
583
586
|
console.warn(`[worca] model ${JSON.stringify(bridged.id)}: dropping env key ${JSON.stringify(k)} (reserved or unresolvable \${VAR} ref)`);
|
|
584
587
|
}
|
|
585
|
-
|
|
588
|
+
// The bridge owns the CLI's transport too: an entry-level cloud switch would
|
|
589
|
+
// send the CLI past the loopback bridge, so it is dropped and forced off below.
|
|
590
|
+
for (const k of [...BRIDGE_ROUTING_KEYS, ...PROVIDER_MODE_ENV_KEYS]) delete extra[k];
|
|
586
591
|
const env = {
|
|
587
592
|
...extra,
|
|
588
593
|
ANTHROPIC_BASE_URL: bridgeBaseUrl(bridged.id, { tag }),
|
|
@@ -595,7 +600,7 @@ export function resolveModelEnv(modelId, { tag } = {}) {
|
|
|
595
600
|
// fails its first call). Its ToolSearch is client-side (schemas come back
|
|
596
601
|
// as tool_result text), so it works through the translation layer; the
|
|
597
602
|
// entry's own env may still turn it off.
|
|
598
|
-
if (bridged.upstream.api
|
|
603
|
+
if (isTranslatedApi(bridged.upstream.api) && !('ENABLE_TOOL_SEARCH' in env)) env.ENABLE_TOOL_SEARCH = 'true';
|
|
599
604
|
// A bridged id is never a model name the CLI knows, so it assumes a 200k
|
|
600
605
|
// window and compacts only once the upstream rejects a request — on a 32k
|
|
601
606
|
// local model that means turns whose reply is cut to a few hundred tokens
|
|
@@ -603,7 +608,7 @@ export function resolveModelEnv(modelId, { tag } = {}) {
|
|
|
603
608
|
const caps = bridged.upstream.capabilities || {};
|
|
604
609
|
if (caps.maxPromptTokens && !('CLAUDE_CODE_MAX_CONTEXT_TOKENS' in env)) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(caps.maxPromptTokens);
|
|
605
610
|
if (caps.maxOutputTokens && !('CLAUDE_CODE_MAX_OUTPUT_TOKENS' in env)) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(caps.maxOutputTokens);
|
|
606
|
-
return withTierModelEnv(env, bridged.id);
|
|
611
|
+
return withProviderModesOff(withTierModelEnv(env, bridged.id));
|
|
607
612
|
}
|
|
608
613
|
if (entry && entry.env) {
|
|
609
614
|
rawEnv = entry.env;
|
|
@@ -639,8 +644,10 @@ export function resolveModelEnv(modelId, { tag } = {}) {
|
|
|
639
644
|
// Endpoint-routed entries also carry the CLI's internal tier keys, pointed at
|
|
640
645
|
// this entry's own wire id (#422, model-env.mjs#withTierModelEnv) — so the
|
|
641
646
|
// CLI's session-title / alias / probe calls never fall back to a first-party
|
|
642
|
-
// id the endpoint has never heard of.
|
|
643
|
-
|
|
647
|
+
// id the endpoint has never heard of. They also turn the CLI's cloud
|
|
648
|
+
// transports off (withProviderModesOff), which would bypass the base URL.
|
|
649
|
+
// Keys the entry sets itself win.
|
|
650
|
+
return withProviderModesOff(withTierModelEnv(env, canonicalId));
|
|
644
651
|
}
|
|
645
652
|
|
|
646
653
|
/**
|
package/src/core/db.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const OPEN_BACKOFF_MS = 15;
|
|
|
55
55
|
/** Latest schema version. Bump + append a new migration step when the DDL grows.
|
|
56
56
|
* Exported so migration tests assert "reached the module's current version"
|
|
57
57
|
* instead of hardcoding the number — a schema bump then touches no test file. */
|
|
58
|
-
export const SCHEMA_VERSION =
|
|
58
|
+
export const SCHEMA_VERSION = 39;
|
|
59
59
|
|
|
60
60
|
/** Absolute path to the database file: <worcaHome>/worca-cc.db. */
|
|
61
61
|
export function dbPath() {
|
|
@@ -834,7 +834,8 @@ const INCREMENTAL_COLUMNS = {
|
|
|
834
834
|
outcome: 'TEXT',
|
|
835
835
|
scheduled_for: 'TEXT', schedule_id: 'TEXT', // v31: scheduled-run provenance (NULL = started by hand)
|
|
836
836
|
policy_state: 'TEXT', // v32: team-policy run state (JSON: home, sha, overrides, exceeded, deviations, reason)
|
|
837
|
-
human_hours: 'REAL NOT NULL DEFAULT 0'
|
|
837
|
+
human_hours: 'REAL NOT NULL DEFAULT 0', // v33: Σ pipeline_steps.human_hours (money-saved design §6)
|
|
838
|
+
started_by: 'TEXT' }, // v36: who started the run (identity.mjs; NULL = before attribution)
|
|
838
839
|
pipeline_steps: { session_id: 'TEXT', skills: 'TEXT', graphify_count: 'INTEGER',
|
|
839
840
|
execution_id: 'TEXT', exec_kind: 'TEXT', agent_key: 'TEXT', ended_at: 'TEXT',
|
|
840
841
|
exec_trigger: 'TEXT', exec_result: 'TEXT', exec_meta: 'TEXT',
|
|
@@ -848,12 +849,17 @@ const INCREMENTAL_COLUMNS = {
|
|
|
848
849
|
ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
|
|
849
850
|
mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
|
|
850
851
|
project_config: { human_in_loop: 'INTEGER NOT NULL DEFAULT 1' }, // v28: the Auto entry's human-in-the-loop switch
|
|
851
|
-
diff_comments: { parent_id: 'TEXT REFERENCES diff_comments(id) ON DELETE CASCADE'
|
|
852
|
+
diff_comments: { parent_id: 'TEXT REFERENCES diff_comments(id) ON DELETE CASCADE', // v29: reply threads; NULL = thread root
|
|
853
|
+
author_name: 'TEXT' }, // v37: who wrote it (identity.mjs actor); NULL = before attribution / Ask
|
|
854
|
+
ask_threads: { created_by: 'TEXT' }, // v37: the thread's owner (identity.mjs actor); NULL = ownerless (legacy)
|
|
855
|
+
pipeline_events: { actor: 'TEXT' }, // v38: who did it (identity.mjs actor); NULL = the run itself / before attribution
|
|
852
856
|
workspaces: { metrics_project: 'TEXT', // v30: team-metrics home (member absolute path); NULL = no home
|
|
853
857
|
policy_project: 'TEXT' }, // v32: team-policy home (member absolute path); NULL = no home
|
|
854
|
-
schedules: { ask_thread_id: 'TEXT', ask_card_id: 'TEXT'
|
|
858
|
+
schedules: { ask_thread_id: 'TEXT', ask_card_id: 'TEXT', // v31: the Ask Worca card a series came from
|
|
859
|
+
created_by: 'TEXT', updated_by: 'TEXT' }, // v39: who made / last changed it (identity.mjs actor)
|
|
855
860
|
scheduled_runs: { after_kind: 'TEXT', after_id: 'TEXT', after_policy: "TEXT NOT NULL DEFAULT 'done'",
|
|
856
|
-
source_from_previous: 'INTEGER NOT NULL DEFAULT 0'
|
|
861
|
+
source_from_previous: 'INTEGER NOT NULL DEFAULT 0', // v34: run chains
|
|
862
|
+
created_by: 'TEXT', updated_by: 'TEXT' }, // v39: who made / last changed it
|
|
857
863
|
};
|
|
858
864
|
|
|
859
865
|
/** v23: per-loop-wire cycle budgets, the graph-engine twin of
|
|
@@ -879,6 +885,17 @@ CREATE TABLE IF NOT EXISTS config_workflow_wires (
|
|
|
879
885
|
* several tables (ASK_DDL, DIFF_COMMENTS_DDL) is listed under EACH of them, so a
|
|
880
886
|
* DB missing only one is healed; repairSchemaGaps de-duplicates at exec time.
|
|
881
887
|
*/
|
|
888
|
+
/** v37: per-person read state of notifications, used only on a shared deployment (a real
|
|
889
|
+
* per-person sign-in, identity.mjs#isSharedIdentity); local installs keep notifications.read_at. */
|
|
890
|
+
const NOTIFICATION_READS_DDL = `
|
|
891
|
+
CREATE TABLE IF NOT EXISTS notification_reads (
|
|
892
|
+
notification_id INTEGER NOT NULL,
|
|
893
|
+
reader TEXT NOT NULL,
|
|
894
|
+
read_at TEXT NOT NULL,
|
|
895
|
+
PRIMARY KEY (notification_id, reader)
|
|
896
|
+
);
|
|
897
|
+
`;
|
|
898
|
+
|
|
882
899
|
const INCREMENTAL_TABLES = {
|
|
883
900
|
config_workflow_wires: CONFIG_WORKFLOW_WIRES_DDL,
|
|
884
901
|
step_questions: STEP_QUESTIONS_DDL,
|
|
@@ -897,6 +914,7 @@ const INCREMENTAL_TABLES = {
|
|
|
897
914
|
schedules: SCHEDULED_RUNS_DDL,
|
|
898
915
|
scheduled_runs: SCHEDULED_RUNS_DDL,
|
|
899
916
|
notifications: SCHEDULED_RUNS_DDL,
|
|
917
|
+
notification_reads: NOTIFICATION_READS_DDL,
|
|
900
918
|
};
|
|
901
919
|
|
|
902
920
|
/**
|
|
@@ -1306,6 +1324,39 @@ function applySchemaV35(db) {
|
|
|
1306
1324
|
for (const [from, to] of V35_MODEL_RENAMES) renameStoredModelPins(db, from, to);
|
|
1307
1325
|
}
|
|
1308
1326
|
|
|
1327
|
+
/** v36 (attribution): pipelines.started_by, a plain additive column declared in
|
|
1328
|
+
* INCREMENTAL_COLUMNS, applySchemaV30's shape. NULL on every existing row = started
|
|
1329
|
+
* before attribution existed. */
|
|
1330
|
+
function applySchemaV36(db) {
|
|
1331
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** v37 (attribution, step 1): diff_comments.author_name, ask_threads.created_by
|
|
1335
|
+
* (INCREMENTAL_COLUMNS) and notification_reads (INCREMENTAL_TABLES), applySchemaV30's
|
|
1336
|
+
* shape. NULL / no rows everywhere existing = before attribution. */
|
|
1337
|
+
function applySchemaV37(db) {
|
|
1338
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** v38 (attribution, step 3): pipeline_events.actor — who did a human action on a run
|
|
1342
|
+
* (INCREMENTAL_COLUMNS), applySchemaV30's shape. NULL on every existing row. */
|
|
1343
|
+
function applySchemaV38(db) {
|
|
1344
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** v39 (attribution): schedules/scheduled_runs created_by + updated_by (INCREMENTAL_COLUMNS, the v36
|
|
1348
|
+
* shape), then a backfill: a series or ticket whose stored request carries internal.startedBy (who
|
|
1349
|
+
* scheduled it, recorded since attribution) gets that as created_by. Fenced like v33's backfill. */
|
|
1350
|
+
function applySchemaV39(db) {
|
|
1351
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1352
|
+
for (const t of ['schedules', 'scheduled_runs']) {
|
|
1353
|
+
try {
|
|
1354
|
+
db.exec(`UPDATE ${t} SET created_by = json_extract(request, '$.internal.startedBy')
|
|
1355
|
+
WHERE created_by IS NULL AND json_valid(request) AND json_type(request, '$.internal.startedBy') = 'text'`);
|
|
1356
|
+
} catch { /* a hand-seeded table without request/JSON1: attribution is decoration */ }
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1309
1360
|
/** Move every stored pin on model id `from` (lower-case) to `to`. Each table
|
|
1310
1361
|
* is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
|
|
1311
1362
|
* fs->db import) reach this step without some of them. */
|
|
@@ -1697,6 +1748,10 @@ export function migrate(db) {
|
|
|
1697
1748
|
if (current < 33) applySchemaV33(db); // money saved: human_hours columns
|
|
1698
1749
|
if (current < 34) applySchemaV34(db); // run chains: scheduled_runs.after_* + source_from_previous
|
|
1699
1750
|
if (current < 35) applySchemaV35(db); // Opus 5 pins -> Opus 5.5 (catalog swap)
|
|
1751
|
+
if (current < 36) applySchemaV36(db); // attribution: pipelines.started_by
|
|
1752
|
+
if (current < 37) applySchemaV37(db); // attribution: comment authors, thread owners, per-person reads
|
|
1753
|
+
if (current < 38) applySchemaV38(db); // attribution: who did each human action on a run
|
|
1754
|
+
if (current < 39) applySchemaV39(db); // attribution: schedules/tickets created_by + updated_by
|
|
1700
1755
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
1701
1756
|
db.exec('COMMIT');
|
|
1702
1757
|
} catch (err) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// How this worca runs, for Ask Worca's context and rules (docs/deploy-railway.md).
|
|
2
|
+
//
|
|
3
|
+
// local a desktop install: the default, and what nothing else says otherwise
|
|
4
|
+
// container the worca image (WORCA_CONTAINER=1 from docker/Dockerfile, or
|
|
5
|
+
// WORCA_DATA_DIR from a single-volume host) without remote access
|
|
6
|
+
// hosted remote access is on (WORCA_ALLOWED_HOSTS names a public host):
|
|
7
|
+
// people reach it through an identity proxy, not from this machine
|
|
8
|
+
//
|
|
9
|
+
// Pure: reads only `env` and the remote-mode flag the server already computed.
|
|
10
|
+
import { readGithubCredentials } from './github-credentials.mjs';
|
|
11
|
+
|
|
12
|
+
const on = (v) => /^(1|true|yes|on)$/i.test(String(v || '').trim());
|
|
13
|
+
|
|
14
|
+
/** 'local' | 'container' | 'hosted'. */
|
|
15
|
+
export function detectDeployment(env = process.env, { remoteMode = false } = {}) {
|
|
16
|
+
if (remoteMode) return 'hosted';
|
|
17
|
+
if (on(env.WORCA_CONTAINER) || String(env.WORCA_DATA_DIR || '').trim()) return 'container';
|
|
18
|
+
return 'local';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Which GitHub credential worca holds: 'none' | 'single' | 'split' (src/core/github-credentials.mjs).
|
|
22
|
+
* Names the mode only; never the token. */
|
|
23
|
+
export function githubMode(env = process.env) {
|
|
24
|
+
return readGithubCredentials(env).mode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The facts Ask Worca's context header shows about this deployment, or null for a
|
|
28
|
+
* local install (whose header stays exactly as before). */
|
|
29
|
+
export function deploymentFacts(env = process.env, { remoteMode = false, projectsRoot = null } = {}) {
|
|
30
|
+
const deployment = detectDeployment(env, { remoteMode });
|
|
31
|
+
if (deployment === 'local') return null;
|
|
32
|
+
return { deployment, projectsRoot: projectsRoot || null, github: githubMode(env) };
|
|
33
|
+
}
|
|
@@ -28,12 +28,21 @@ export class DiffCommentError extends Error {
|
|
|
28
28
|
constructor(message) { super(message); this.name = 'DiffCommentError'; }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A person's name on a 'user' comment only (one line, bounded); Ask comments carry none. */
|
|
32
|
+
function authorNameOf(author, name) {
|
|
33
|
+
if (author !== 'user' || typeof name !== 'string') return null;
|
|
34
|
+
const t = name.trim();
|
|
35
|
+
return t && t.length <= 200 && !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(t) ? t : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
function rowToComment(r) {
|
|
32
39
|
return {
|
|
33
40
|
id: r.id, storeKey: r.store_key, pipelineId: r.pipeline_id,
|
|
34
41
|
projectKey: r.project_key ?? null, path: r.path, oldPath: r.old_path ?? null,
|
|
35
42
|
side: r.side, line: r.line_no, lineText: r.line_text ?? '',
|
|
36
43
|
body: r.body, author: r.author,
|
|
44
|
+
// Who wrote it (identity.mjs actor) for a 'user' comment; null for Ask and pre-v37 rows.
|
|
45
|
+
authorName: r.author_name ?? null,
|
|
37
46
|
resolved: !!r.resolved, resolvedAt: r.resolved_at ?? null,
|
|
38
47
|
sentRunId: r.sent_run_id ?? null, createdAt: r.created_at,
|
|
39
48
|
parentId: r.parent_id ?? null,
|
|
@@ -128,7 +137,7 @@ function cleanBody(body) {
|
|
|
128
137
|
* have one error type to map onto 400 / tool-error.
|
|
129
138
|
*/
|
|
130
139
|
export function addDiffComment({
|
|
131
|
-
storeKey, pipelineId, patchText, project = null, path, side, line, body, author,
|
|
140
|
+
storeKey, pipelineId, patchText, project = null, path, side, line, body, author, authorName = null,
|
|
132
141
|
} = {}) {
|
|
133
142
|
if (patchText == null || String(patchText) === '') {
|
|
134
143
|
throw new DiffCommentError('this run has no stored diff — comments cannot be created on it');
|
|
@@ -148,10 +157,10 @@ export function addDiffComment({
|
|
|
148
157
|
getDb();
|
|
149
158
|
prepare(`INSERT INTO diff_comments
|
|
150
159
|
(id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
|
|
151
|
-
body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at)
|
|
152
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, NULL, NULL, ?)`)
|
|
160
|
+
body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, author_name)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, NULL, NULL, ?, ?)`)
|
|
153
162
|
.run(id, String(storeKey), String(pipelineId), anchor.project, anchor.path, anchor.oldPath,
|
|
154
|
-
anchor.side, anchor.line, anchor.lineText, text, author, ts);
|
|
163
|
+
anchor.side, anchor.line, anchor.lineText, text, author, ts, authorNameOf(author, authorName));
|
|
155
164
|
notify(String(storeKey), String(pipelineId));
|
|
156
165
|
return getDiffComment(id);
|
|
157
166
|
}
|
|
@@ -166,7 +175,7 @@ export function addDiffComment({
|
|
|
166
175
|
* archiving deletes a run's comments, pipeline-delete.mjs.)
|
|
167
176
|
* @throws {DiffCommentError}
|
|
168
177
|
*/
|
|
169
|
-
export function addDiffCommentReply({ parentId, body, author } = {}) {
|
|
178
|
+
export function addDiffCommentReply({ parentId, body, author, authorName = null } = {}) {
|
|
170
179
|
const parent = getDiffComment(parentId);
|
|
171
180
|
if (!parent) throw new DiffCommentError('comment not found');
|
|
172
181
|
if (parent.parentId) throw new DiffCommentError('replies cannot be nested — reply to the thread\'s first comment');
|
|
@@ -177,11 +186,11 @@ export function addDiffCommentReply({ parentId, body, author } = {}) {
|
|
|
177
186
|
getDb();
|
|
178
187
|
prepare(`INSERT INTO diff_comments
|
|
179
188
|
(id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
|
|
180
|
-
body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, parent_id)
|
|
181
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`)
|
|
189
|
+
body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, parent_id, author_name)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)`)
|
|
182
191
|
.run(id, parent.storeKey, parent.pipelineId, parent.projectKey, parent.path, parent.oldPath,
|
|
183
192
|
parent.side, parent.line, parent.lineText, text, author,
|
|
184
|
-
parent.resolved ? 1 : 0, parent.resolvedAt, ts, parent.id);
|
|
193
|
+
parent.resolved ? 1 : 0, parent.resolvedAt, ts, parent.id, authorNameOf(author, authorName));
|
|
185
194
|
notify(parent.storeKey, parent.pipelineId);
|
|
186
195
|
return getDiffComment(id);
|
|
187
196
|
}
|
package/src/core/git-info.mjs
CHANGED
|
@@ -9,13 +9,14 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
+
import { githubEnv, readGithubCredentials } from './github-credentials.mjs';
|
|
12
13
|
|
|
13
14
|
/** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
|
|
14
|
-
function defaultRun(cmd, args, { cwd, timeout = 0 } = {}) {
|
|
15
|
+
function defaultRun(cmd, args, { cwd, timeout = 0, env = null } = {}) {
|
|
15
16
|
return new Promise((resolve) => {
|
|
16
17
|
let child;
|
|
17
18
|
try {
|
|
18
|
-
child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
19
|
+
child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(env ? { env } : {}) });
|
|
19
20
|
} catch (err) {
|
|
20
21
|
resolve({ ok: false, stdout: '', stderr: err.message, code: -1 });
|
|
21
22
|
return;
|
|
@@ -160,9 +161,26 @@ export async function hasGh() {
|
|
|
160
161
|
return _ghCache;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/** "owner/name" of `remote` on github.com in App mode (helps the App find its installation), else null. */
|
|
165
|
+
async function githubRepoOf(projectDir, remote) {
|
|
166
|
+
if (readGithubCredentials().mode !== 'app') return null;
|
|
167
|
+
const r = await _run('git', ['remote', 'get-url', remote], { cwd: projectDir });
|
|
168
|
+
const p = r.ok ? parseRemoteUrl(r.stdout.trim()) : null;
|
|
169
|
+
return p && p.host === 'github.com' ? `${p.owner}/${p.repo}` : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** "owner/name" of a github.com PR URL, or null. */
|
|
173
|
+
const ownerRepoOfPrUrl = (url) => { const m = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(String(url || '')); return m ? m[1] : null; };
|
|
174
|
+
|
|
175
|
+
/** "owner/name" from gh's `[HOST/]OWNER/REPO`, or null. */
|
|
176
|
+
const ownerRepo = (repo) => (repo ? String(repo).split('/').slice(-2).join('/') : null);
|
|
177
|
+
|
|
163
178
|
/** Push the branch to `remote` (default origin) and set upstream. Idempotent; surfaces stderr. */
|
|
164
179
|
export async function pushBranch(projectDir, branch, remote = 'origin') {
|
|
165
|
-
const
|
|
180
|
+
const r0 = remote || 'origin';
|
|
181
|
+
const cred = await githubEnv('write', { repo: await githubRepoOf(projectDir, r0) });
|
|
182
|
+
if (cred.error) return { ok: false, stderr: cred.error };
|
|
183
|
+
const r = await _run('git', ['push', '-u', r0, branch], { cwd: projectDir, env: cred.env });
|
|
166
184
|
return { ok: r.ok, stderr: (r.stderr || '').trim() };
|
|
167
185
|
}
|
|
168
186
|
|
|
@@ -182,14 +200,16 @@ export async function createPr({ projectDir, base, head, title, body = '', repo
|
|
|
182
200
|
const repoArgs = repo ? ['--repo', repo] : [];
|
|
183
201
|
const args = ['pr', 'create', ...repoArgs, '--base', base, '--head', headRef,
|
|
184
202
|
'--title', title || head, '--body', body || title || head];
|
|
185
|
-
const
|
|
203
|
+
const cred = await githubEnv('write', { repo: ownerRepo(repo) });
|
|
204
|
+
if (cred.error) return { ok: false, error: cred.error };
|
|
205
|
+
const r = await _run('gh', args, { cwd: projectDir, env: cred.env });
|
|
186
206
|
if (r.ok) {
|
|
187
207
|
// gh prints the PR URL as the last stdout line.
|
|
188
208
|
const url = (r.stdout.trim().split(/\r?\n/).pop() || '').trim();
|
|
189
209
|
return { ok: true, url, existed: false };
|
|
190
210
|
}
|
|
191
211
|
if (/already exists/i.test(r.stderr || '')) {
|
|
192
|
-
const v = await _run('gh', ['pr', 'view', headRef, ...repoArgs, '--json', 'url', '-q', '.url'], { cwd: projectDir });
|
|
212
|
+
const v = await _run('gh', ['pr', 'view', headRef, ...repoArgs, '--json', 'url', '-q', '.url'], { cwd: projectDir, env: (await githubEnv('read', { repo: ownerRepo(repo) })).env });
|
|
193
213
|
if (v.ok && v.stdout.trim()) return { ok: true, url: v.stdout.trim(), existed: true };
|
|
194
214
|
// gh's message ends with the existing PR's URL ("… already exists:\n<url>");
|
|
195
215
|
// use it when the view selector cannot resolve (e.g. a PR opened from another fork).
|
|
@@ -285,7 +305,7 @@ export async function prMergeable({ projectDir, head, repo = null, headOwner = n
|
|
|
285
305
|
const selector = prUrl || (head ? prHeadRef(head, headOwner) : '');
|
|
286
306
|
if (!selector) return 'UNKNOWN';
|
|
287
307
|
const repoArgs = !prUrl && repo ? ['--repo', repo] : [];
|
|
288
|
-
const r = await _run('gh', ['pr', 'view', selector, ...repoArgs, '--json', 'mergeable', '-q', '.mergeable'], { cwd: projectDir });
|
|
308
|
+
const r = await _run('gh', ['pr', 'view', selector, ...repoArgs, '--json', 'mergeable', '-q', '.mergeable'], { cwd: projectDir, env: (await githubEnv('read', { repo: prUrl ? ownerRepoOfPrUrl(prUrl) : ownerRepo(repo) })).env });
|
|
289
309
|
if (!r.ok) return 'UNKNOWN';
|
|
290
310
|
return normalizeMergeable(r.stdout.trim());
|
|
291
311
|
}
|
|
@@ -316,7 +336,7 @@ const normalizePr = (pr) => ({
|
|
|
316
336
|
export async function findPrForBranch({ projectDir, head, prUrl = null } = {}) {
|
|
317
337
|
if (!projectDir || !head) return null;
|
|
318
338
|
if (prUrl) {
|
|
319
|
-
const v = await _run('gh', ['pr', 'view', prUrl, '--json', 'number,state,url'], { cwd: projectDir });
|
|
339
|
+
const v = await _run('gh', ['pr', 'view', prUrl, '--json', 'number,state,url'], { cwd: projectDir, env: (await githubEnv('read', { repo: ownerRepoOfPrUrl(prUrl) })).env });
|
|
320
340
|
if (v.ok) {
|
|
321
341
|
let obj = null;
|
|
322
342
|
try { obj = JSON.parse(v.stdout || 'null'); } catch { obj = null; }
|