@worca/app 1.5.0 → 1.6.0-rc.1
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 +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
|
@@ -89,7 +89,8 @@ async function defaultGit(cwd, args, { timeoutMs = 60_000, env = null } = {}) {
|
|
|
89
89
|
// Local commands (commit, worktree, read-tree…) get no credential at all.
|
|
90
90
|
let base;
|
|
91
91
|
if (NETWORK_GIT.has(args[0])) {
|
|
92
|
-
|
|
92
|
+
// Team data, not a person's work: always worca's own credential, never "push as me".
|
|
93
|
+
const cred = await githubEnv('write', { repo: await originRepo(cwd), asPerson: false });
|
|
93
94
|
if (cred.error) console.warn(`[worca] metrics/policy git ${args[0]}: ${cred.error}`);
|
|
94
95
|
base = cred.env;
|
|
95
96
|
} else {
|
package/src/core/model-env.mjs
CHANGED
|
@@ -406,6 +406,73 @@ export function isLocalBaseUrl(v) {
|
|
|
406
406
|
return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Whether a base URL is OpenRouter's (openrouter.ai or a subdomain). The
|
|
411
|
+
* bridge then speaks OpenRouter's dialect of chat completions: usage
|
|
412
|
+
* accounting, unified `reasoning`, provider routing and attribution headers.
|
|
413
|
+
*/
|
|
414
|
+
export function isOpenRouterBaseUrl(v) {
|
|
415
|
+
if (!isUpstreamBaseUrl(v)) return false;
|
|
416
|
+
const host = new URL(v.trim()).hostname.toLowerCase();
|
|
417
|
+
return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** OpenRouter's provider sort orders (provider routing). */
|
|
421
|
+
export const OPENROUTER_SORTS = Object.freeze(['price', 'throughput', 'latency']);
|
|
422
|
+
|
|
423
|
+
/** A list of non-empty trimmed strings, or a throw naming the field. */
|
|
424
|
+
function stringList(v, field) {
|
|
425
|
+
if (!Array.isArray(v)) throw new Error(`${field} must be an array of strings`);
|
|
426
|
+
const out = [];
|
|
427
|
+
for (const s of v) {
|
|
428
|
+
if (typeof s !== 'string') throw new Error(`${field} must be an array of strings`);
|
|
429
|
+
if (s.trim()) out.push(s.trim());
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Validate an `upstream.openrouter` block — OpenRouter's request options,
|
|
436
|
+
* sent only when the entry's base URL is OpenRouter's:
|
|
437
|
+
* `{ models?: string[], provider?: { order?: string[], allow_fallbacks?: boolean, sort?: 'price'|'throughput'|'latency' } }`.
|
|
438
|
+
* `models` is the fallback list tried after the entry's own model. Returns the
|
|
439
|
+
* normalized block, or undefined when nothing is set. Throws.
|
|
440
|
+
*/
|
|
441
|
+
export function assertOpenRouterOptions(o) {
|
|
442
|
+
if (o === undefined || o === null) return undefined;
|
|
443
|
+
if (typeof o !== 'object' || Array.isArray(o)) throw new Error('upstream.openrouter must be an object');
|
|
444
|
+
const out = {};
|
|
445
|
+
for (const k of Object.keys(o)) {
|
|
446
|
+
if (k !== 'models' && k !== 'provider') throw new Error(`unknown upstream.openrouter key ${JSON.stringify(k)} — allowed: models, provider`);
|
|
447
|
+
}
|
|
448
|
+
if (o.models !== undefined && o.models !== null) {
|
|
449
|
+
const models = stringList(o.models, 'upstream.openrouter.models');
|
|
450
|
+
if (models.length) out.models = models;
|
|
451
|
+
}
|
|
452
|
+
const p = o.provider;
|
|
453
|
+
if (p !== undefined && p !== null) {
|
|
454
|
+
if (typeof p !== 'object' || Array.isArray(p)) throw new Error('upstream.openrouter.provider must be an object');
|
|
455
|
+
const provider = {};
|
|
456
|
+
for (const k of Object.keys(p)) {
|
|
457
|
+
if (!['order', 'allow_fallbacks', 'sort'].includes(k)) throw new Error(`unknown upstream.openrouter.provider key ${JSON.stringify(k)} — allowed: order, allow_fallbacks, sort`);
|
|
458
|
+
}
|
|
459
|
+
if (p.order !== undefined && p.order !== null) {
|
|
460
|
+
const order = stringList(p.order, 'upstream.openrouter.provider.order');
|
|
461
|
+
if (order.length) provider.order = order;
|
|
462
|
+
}
|
|
463
|
+
if (p.allow_fallbacks !== undefined && p.allow_fallbacks !== null) {
|
|
464
|
+
if (typeof p.allow_fallbacks !== 'boolean') throw new Error('upstream.openrouter.provider.allow_fallbacks must be true or false');
|
|
465
|
+
provider.allow_fallbacks = p.allow_fallbacks;
|
|
466
|
+
}
|
|
467
|
+
if (p.sort !== undefined && p.sort !== null && p.sort !== '') {
|
|
468
|
+
if (!OPENROUTER_SORTS.includes(p.sort)) throw new Error(`upstream.openrouter.provider.sort must be one of ${OPENROUTER_SORTS.join(' | ')}`);
|
|
469
|
+
provider.sort = p.sort;
|
|
470
|
+
}
|
|
471
|
+
if (Object.keys(provider).length) out.provider = provider;
|
|
472
|
+
}
|
|
473
|
+
return Object.keys(out).length ? out : undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
409
476
|
/**
|
|
410
477
|
* Validate a model `upstream` block. Returns the normalized shape or undefined
|
|
411
478
|
* (for null/undefined); THROWS on malformed input with a message naming the
|
|
@@ -453,6 +520,11 @@ export function assertModelUpstream(upstream) {
|
|
|
453
520
|
}
|
|
454
521
|
const caps = assertModelCapabilities(upstream.capabilities);
|
|
455
522
|
if (caps) out.capabilities = caps;
|
|
523
|
+
if (upstream.openrouter !== undefined && upstream.openrouter !== null) {
|
|
524
|
+
if (provider !== 'openai') throw new Error('upstream.openrouter is only for the openai provider (an OpenRouter base URL)');
|
|
525
|
+
const or = assertOpenRouterOptions(upstream.openrouter);
|
|
526
|
+
if (or) out.openrouter = or;
|
|
527
|
+
}
|
|
456
528
|
return out;
|
|
457
529
|
}
|
|
458
530
|
|
package/src/core/model-test.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { runClaude } from './claude-runner.mjs';
|
|
9
9
|
import { resolveModelEnv } from './config.mjs';
|
|
10
10
|
import { AUX_EFFORT } from './model-env.mjs';
|
|
11
|
-
import { classifyError } from './recoverable-error.mjs';
|
|
11
|
+
import { classifyError, isFreeDailyLimit, freeDailyHint } from './recoverable-error.mjs';
|
|
12
|
+
import { failedBecauseSignedOut } from './claude-auth.mjs';
|
|
12
13
|
import { bridgeEvents } from './bridge/telemetry.mjs';
|
|
13
14
|
|
|
14
15
|
const TEST_TIMEOUT_MS = 60_000;
|
|
@@ -30,6 +31,8 @@ export function hintFor(errorClass) {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export const CLAUDE_SIGNED_OUT_HINT = "Claude Code isn't signed in — run `claude` in a terminal and type /login";
|
|
35
|
+
|
|
33
36
|
/** Actionable hint for a bridge readiness failure (config.mjs resolveModelEnv). Pure. */
|
|
34
37
|
export function bridgeHintFor(reason, provider = 'the provider') {
|
|
35
38
|
switch (reason) {
|
|
@@ -44,11 +47,11 @@ export function bridgeHintFor(reason, provider = 'the provider') {
|
|
|
44
47
|
* Live connectivity check for a catalog model id (global or plugin — the
|
|
45
48
|
* resolution precedence is resolveModelEnv's). Explicit user action only.
|
|
46
49
|
* @param {string} id catalog model id
|
|
47
|
-
* @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude}} [opts]
|
|
48
|
-
* `run`
|
|
50
|
+
* @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude, signedOut?:typeof failedBecauseSignedOut}} [opts]
|
|
51
|
+
* `run` / `signedOut` are injectable for unit tests.
|
|
49
52
|
* @returns {Promise<{ok:true, text:string}|{ok:false, errorClass:(string|null), message:string, hint?:string}>}
|
|
50
53
|
*/
|
|
51
|
-
export async function testModel(id, { signal, bin, run = runClaude } = {}) {
|
|
54
|
+
export async function testModel(id, { signal, bin, run = runClaude, signedOut = failedBecauseSignedOut } = {}) {
|
|
52
55
|
const ctrl = new AbortController();
|
|
53
56
|
const onOuterAbort = () => ctrl.abort();
|
|
54
57
|
if (signal) {
|
|
@@ -81,6 +84,7 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
|
|
|
81
84
|
signal: ctrl.signal,
|
|
82
85
|
bin,
|
|
83
86
|
onEvent: () => {},
|
|
87
|
+
spawnKind: 'test', // credential broker: a 10-minute token (broker-client.mjs)
|
|
84
88
|
});
|
|
85
89
|
const reply = String(text || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l) || '';
|
|
86
90
|
if (!reply) {
|
|
@@ -106,8 +110,16 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
|
|
|
106
110
|
// names the fix instead of the generic credential advice. A bridge failure
|
|
107
111
|
// classed `network` ("endpoint unreachable", "upstream error (500)") is not
|
|
108
112
|
// an ANTHROPIC_BASE_URL problem: no hint, so the UI shows the message.
|
|
113
|
+
// A signed-out CLI is not this model's token: a first-party model needs the
|
|
114
|
+
// Claude Code sign-in (and signed out the CLI may only say `unrecognized_model`),
|
|
115
|
+
// so say that instead of the generic advice.
|
|
116
|
+
const cliSignedOut = !(err && err.bridgeReason) && !bridgeFailure
|
|
117
|
+
&& await signedOut({ message, model: id, ...(bin ? { bin } : {}) });
|
|
109
118
|
const hint = err && err.bridgeReason ? bridgeHintFor(err.bridgeReason, err.bridgeProvider)
|
|
110
|
-
: bridgeFailure && bridgeFailure.message && errorClass === 'network' ? ''
|
|
119
|
+
: bridgeFailure && bridgeFailure.message && errorClass === 'network' ? ''
|
|
120
|
+
: cliSignedOut ? CLAUDE_SIGNED_OUT_HINT
|
|
121
|
+
: isFreeDailyLimit(message) ? freeDailyHint(message)
|
|
122
|
+
: hintFor(errorClass);
|
|
111
123
|
return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
|
|
112
124
|
} finally {
|
|
113
125
|
bridgeEvents.off('failure', onBridgeFailure);
|
package/src/core/onboarding.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { countWorkspaces } from './workspaces.mjs';
|
|
|
13
13
|
import { countThreads } from './ask/store.mjs';
|
|
14
14
|
import { listScopes } from './metrics/read.mjs';
|
|
15
15
|
import { listPolicyScopes } from './policy/sync.mjs';
|
|
16
|
-
import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
|
|
16
|
+
import { explainUnspawnableClaude, probeClaudeAuth, resolveClaudeBin } from './preflight.mjs';
|
|
17
17
|
import { onboardingPrefs } from './settings.mjs';
|
|
18
18
|
|
|
19
19
|
/** Step ids in shelf order. The UI (ui/public/getting-started.mjs) carries the
|
|
@@ -70,14 +70,20 @@ export function claudeReady(bin = configuredClaudeBin(), opts = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
* The nine ticks plus the two stored flags, in one call.
|
|
73
|
+
* The nine ticks plus the two stored flags, in one call. The Claude step needs
|
|
74
|
+
* the CLI found AND not signed out (`auth` is preflight's probeClaudeAuth state;
|
|
75
|
+
* 'unknown' — mock, an older CLI — never un-ticks it). `recheck` skips the
|
|
76
|
+
* probe's remembered answer (the Connect dialog's Check again).
|
|
77
|
+
* @param {{recheck?:boolean}} [opts]
|
|
74
78
|
* @returns {Promise<{steps:Record<string,boolean>, done:number, total:number,
|
|
75
|
-
* claude:{bin:string, hint:string|null
|
|
79
|
+
* claude:{bin:string, hint:string|null, auth:'signed-in'|'signed-out'|'unknown'|null},
|
|
80
|
+
* hidden:boolean, welcomeSeen:boolean}>}
|
|
76
81
|
*/
|
|
77
|
-
export async function onboardingStatus() {
|
|
82
|
+
export async function onboardingStatus({ recheck = false } = {}) {
|
|
78
83
|
const db = getDb();
|
|
79
84
|
const count = (sql) => { const row = db.prepare(sql).get(); return row ? Number(row.n) : 0; };
|
|
80
85
|
const claude = claudeReady();
|
|
86
|
+
const auth = claude.ready ? (await probeClaudeAuth({ bin: configuredClaudeBin(), force: recheck })).state : null;
|
|
81
87
|
let teamMetrics = false;
|
|
82
88
|
// Cached status only (no discovery): this is read at boot and after every change
|
|
83
89
|
// broadcast, and the Team metrics page owns the expensive refresh.
|
|
@@ -87,7 +93,7 @@ export async function onboardingStatus() {
|
|
|
87
93
|
let teamPolicy = false;
|
|
88
94
|
try { teamPolicy = !!(await listPolicyScopes()).anyEnabled; } catch { /* offline / no git: not enabled */ }
|
|
89
95
|
const steps = {
|
|
90
|
-
claude: claude.ready,
|
|
96
|
+
claude: claude.ready && auth !== 'signed-out',
|
|
91
97
|
project: countProjects() > 0,
|
|
92
98
|
run: count("SELECT COUNT(*) AS n FROM pipelines WHERE status = 'done'") > 0,
|
|
93
99
|
realRun: count('SELECT COUNT(*) AS n FROM pipelines WHERE total_cost_usd > 0') > 0,
|
|
@@ -101,5 +107,5 @@ export async function onboardingStatus() {
|
|
|
101
107
|
teamPolicy,
|
|
102
108
|
};
|
|
103
109
|
const done = ONBOARDING_STEPS.filter((id) => steps[id]).length;
|
|
104
|
-
return { steps, done, total: ONBOARDING_STEPS.length, claude: { bin: claude.bin, hint: claude.hint }, ...onboardingPrefs() };
|
|
110
|
+
return { steps, done, total: ONBOARDING_STEPS.length, claude: { bin: claude.bin, hint: claude.hint, auth }, ...onboardingPrefs() };
|
|
105
111
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/core/openrouter-free.mjs
|
|
2
|
+
// OpenRouter's daily allowance of `:free` requests (docs/models.md › OpenRouter): how many
|
|
3
|
+
// are left today. 1000 a day on an account that bought $10 of credit, 50 below; every model
|
|
4
|
+
// call is one request (each agent turn, each tool round trip, each retry), and a small
|
|
5
|
+
// pipeline run takes about 90.
|
|
6
|
+
//
|
|
7
|
+
// OpenRouter's GET /key is the truth (free_model_daily_requests). Between readings every
|
|
8
|
+
// `:free` call the bridge forwards lowers the count, and a 429 for the daily limit sets it to
|
|
9
|
+
// 0; the next reading corrects any drift. A reading is kept for READ_TTL_MS.
|
|
10
|
+
//
|
|
11
|
+
// Whose allowance: without the credential broker, the key on Settings › Providers (one per
|
|
12
|
+
// install). With the broker, each person's own key, read through the broker with a
|
|
13
|
+
// short-lived token billed to them; the bridge can't tell whose key a call used there, so
|
|
14
|
+
// the count refreshes more often instead of being lowered per call.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { bridgeEvents } from './bridge/telemetry.mjs';
|
|
17
|
+
import { openRouterKeyInfo } from './bridge/provider-ops.mjs';
|
|
18
|
+
import { isOpenRouter } from './bridge/openrouter.mjs';
|
|
19
|
+
import { providerConfig, resolveProviderSecret, listGlobalModels } from './settings.mjs';
|
|
20
|
+
import { listPluginModels } from './plugin-models.mjs';
|
|
21
|
+
import { brokerEnabled, brokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl } from './broker-client.mjs';
|
|
22
|
+
import { isFreeDailyLimit } from './recoverable-error.mjs';
|
|
23
|
+
|
|
24
|
+
const READ_TTL_MS = 5 * 60_000;
|
|
25
|
+
const READ_TTL_BROKER_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
/** Whether an upstream (upstreamSettings shape) is an OpenRouter `:free` model. Pure. */
|
|
28
|
+
export function isOpenRouterFree(us) {
|
|
29
|
+
return !!us && /:free$/i.test(String(us.model || '')) && isOpenRouter(String(us.baseUrl || ''));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A stable, non-reversible name for a key: which allowance a call spent. */
|
|
33
|
+
export function keyAccount(apiKey) {
|
|
34
|
+
return apiKey ? `key:${createHash('sha256').update(String(apiKey)).digest('hex').slice(0, 16)}` : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The next 00:00 UTC after `now` (ms): when OpenRouter resets the daily allowance. Pure. */
|
|
38
|
+
export function nextUtcMidnight(now = Date.now()) {
|
|
39
|
+
const d = new Date(now);
|
|
40
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The catalog's OpenRouter `:free` models (ids). Never throws. */
|
|
44
|
+
export function freeModelIds() {
|
|
45
|
+
const openaiBase = (() => { try { return providerConfig('openai').baseUrl || ''; } catch { return ''; } })();
|
|
46
|
+
const out = [];
|
|
47
|
+
let all = [];
|
|
48
|
+
try { all = [...listGlobalModels(), ...listPluginModels()]; } catch { all = []; }
|
|
49
|
+
for (const m of all) {
|
|
50
|
+
const u = m && m.upstream;
|
|
51
|
+
if (!u || u.provider !== 'openai') continue;
|
|
52
|
+
if (isOpenRouterFree({ model: u.model, baseUrl: u.baseUrl || openaiBase })) out.push(m.id);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// account -> { used, limit, remaining, readAt, resetAt }
|
|
58
|
+
const state = new Map();
|
|
59
|
+
const inflight = new Map();
|
|
60
|
+
|
|
61
|
+
function remember(account, info, now) {
|
|
62
|
+
const fd = info && info.freeDaily;
|
|
63
|
+
if (!fd) { state.set(account, { none: true, readAt: now }); return state.get(account); }
|
|
64
|
+
const s = { used: fd.used, limit: fd.limit, remaining: fd.remaining, readAt: now, resetAt: nextUtcMidnight(now) };
|
|
65
|
+
state.set(account, s);
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Lower an account's count by one call (a `:free` call the bridge forwarded). */
|
|
70
|
+
export function tallyFreeCall(account, now = Date.now()) {
|
|
71
|
+
const s = account && state.get(account);
|
|
72
|
+
if (!s || s.none) return;
|
|
73
|
+
if (now >= s.resetAt) { state.delete(account); return; } // a new day: the next reading says
|
|
74
|
+
s.used = Math.min(s.limit, s.used + 1);
|
|
75
|
+
s.remaining = Math.max(0, s.remaining - 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The daily limit was hit: nothing is left until the reset. */
|
|
79
|
+
export function markFreeDailySpent(account) {
|
|
80
|
+
const s = account && state.get(account);
|
|
81
|
+
if (!s || s.none) return;
|
|
82
|
+
s.used = s.limit;
|
|
83
|
+
s.remaining = 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The bridge's telemetry: every forwarded `:free` call lowers the count of the key it used;
|
|
87
|
+
// a daily-limit refusal empties it.
|
|
88
|
+
bridgeEvents.on('call', (e) => { if (e && e.free && e.account) tallyFreeCall(e.account); });
|
|
89
|
+
bridgeEvents.on('failure', (e) => { if (e && e.account && isFreeDailyLimit(e.message)) markFreeDailySpent(e.account); });
|
|
90
|
+
|
|
91
|
+
/** Which allowance `person` spends from, and how to read it: {account, read:(fetch)=>info}. */
|
|
92
|
+
async function source(person) {
|
|
93
|
+
if (brokerEnabled()) {
|
|
94
|
+
const info = await brokerInfo();
|
|
95
|
+
const who = info.mode === 'multi' ? person : 'local';
|
|
96
|
+
if (!who || who === 'local' && info.mode === 'multi') return null;
|
|
97
|
+
const base = slotBaseUrl('openrouter');
|
|
98
|
+
if (!base || !(info.slots || []).some((s) => s.id === 'openrouter')) return null;
|
|
99
|
+
return {
|
|
100
|
+
account: `person:${who}`,
|
|
101
|
+
ttl: READ_TTL_BROKER_MS,
|
|
102
|
+
read: async (f) => {
|
|
103
|
+
const minted = await mintSpawnToken({ billTo: who, slots: ['openrouter'], kind: 'aux' });
|
|
104
|
+
try { return await openRouterKeyInfo(`${base}/api/v1`, minted.token, { fetch: f }); } finally { revokeSpawnToken(minted.spawnId); }
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const cfg = providerConfig('openai');
|
|
109
|
+
const base = String(cfg.baseUrl || '').replace(/\/+$/, '');
|
|
110
|
+
const key = resolveProviderSecret(cfg.apiKey);
|
|
111
|
+
if (!isOpenRouter(base) || !key) return null;
|
|
112
|
+
return { account: keyAccount(key), ttl: READ_TTL_MS, read: (f) => openRouterKeyInfo(base, key, { fetch: f }) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Today's free-model allowance for `person` (the signed-in viewer; ignored without the broker).
|
|
117
|
+
* `models`: the catalog's OpenRouter `:free` model ids (the new-run form checks its nodes against them).
|
|
118
|
+
* @returns {Promise<{enabled:false}|{enabled:true, known:false, models:string[], reason?:string}|
|
|
119
|
+
* {enabled:true, known:true, models:string[], used:number, limit:number, remaining:number, resetAt:string, readAt:string}>}
|
|
120
|
+
*/
|
|
121
|
+
export async function freeDailyStatus({ person = null, force = false, fetch: f = globalThis.fetch, now = Date.now } = {}) {
|
|
122
|
+
const models = freeModelIds();
|
|
123
|
+
if (!models.length) return { enabled: false };
|
|
124
|
+
let src;
|
|
125
|
+
try { src = await source(person); } catch (err) { return { enabled: true, known: false, models, reason: err.message }; }
|
|
126
|
+
if (!src) return { enabled: true, known: false, models, reason: 'no OpenRouter key to read' };
|
|
127
|
+
let s = state.get(src.account);
|
|
128
|
+
const t = now();
|
|
129
|
+
if (force || !s || t - s.readAt > src.ttl || (s.resetAt && t >= s.resetAt)) {
|
|
130
|
+
if (!inflight.has(src.account)) {
|
|
131
|
+
inflight.set(src.account, (async () => {
|
|
132
|
+
try { return remember(src.account, await src.read(f), now()); } finally { inflight.delete(src.account); }
|
|
133
|
+
})());
|
|
134
|
+
}
|
|
135
|
+
try { s = await inflight.get(src.account); } catch { /* keep the last reading */ }
|
|
136
|
+
}
|
|
137
|
+
if (!s || s.none) return { enabled: true, known: false, models, reason: 'OpenRouter did not report a daily allowance for this key' };
|
|
138
|
+
return {
|
|
139
|
+
enabled: true, known: true, models, used: s.used, limit: s.limit, remaining: s.remaining,
|
|
140
|
+
resetAt: new Date(s.resetAt).toISOString(), readAt: new Date(s.readAt).toISOString(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The last reading for `person`'s allowance, without a network call: {used, limit} or {}. */
|
|
145
|
+
export function cachedFreeDailyCounts(person = null) {
|
|
146
|
+
let account = null;
|
|
147
|
+
if (brokerEnabled()) account = person ? `person:${person}` : 'person:local';
|
|
148
|
+
else {
|
|
149
|
+
try {
|
|
150
|
+
const cfg = providerConfig('openai');
|
|
151
|
+
if (isOpenRouter(String(cfg.baseUrl || ''))) account = keyAccount(resolveProviderSecret(cfg.apiKey));
|
|
152
|
+
} catch { account = null; }
|
|
153
|
+
}
|
|
154
|
+
const s = account && state.get(account);
|
|
155
|
+
return s && !s.none ? { used: s.used, limit: s.limit } : {};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Test hook. */
|
|
159
|
+
export function _resetFreeDaily() { state.clear(); inflight.clear(); }
|
|
@@ -36,12 +36,15 @@ import { humanEstimateOverrides, memoryDefragModel } from './settings.mjs';
|
|
|
36
36
|
import { renderPromptArtifact } from './phases.mjs';
|
|
37
37
|
import { listModels, modelHasBaseUrlRouting, resolveRunConfig } from './config.mjs';
|
|
38
38
|
import { resolveDefragModel, agentPairText } from './memory-defrag-model.mjs';
|
|
39
|
-
import { assembleShape, ShapeError } from '../shared/graph/assemble.mjs';
|
|
39
|
+
import { assembleShape, normalizeShape, ShapeError } from '../shared/graph/assemble.mjs';
|
|
40
|
+
import { RECIPE_SHAPES } from './auto/recipes.mjs';
|
|
40
41
|
import { fingerprintProject } from './auto/fingerprint.mjs';
|
|
41
42
|
import { classifyTask, ClassifierError } from './auto/classify.mjs';
|
|
42
43
|
import { autoCandidates, findEquivalentWorkflow } from './auto/match.mjs';
|
|
43
44
|
import { buildProposal, sanitizeProposalAnswer, remapTunables, mintAutoWorkflowId } from './auto/proposal.mjs';
|
|
44
45
|
import { resolveAutoModel } from './auto/model.mjs';
|
|
46
|
+
import { autoModelsFor } from './auto/runnable.mjs';
|
|
47
|
+
import { probeClaudeAuth } from './preflight.mjs';
|
|
45
48
|
import {
|
|
46
49
|
appendAudit, writeReview, reviewKindOf, writeDecomposition, updateTaskStatus,
|
|
47
50
|
updatePhaseStatus, writeStepQuestions, readStepQuestions,
|
|
@@ -49,6 +52,7 @@ import {
|
|
|
49
52
|
import { readAskFile } from './protocol.mjs';
|
|
50
53
|
import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from './ask-forms.mjs';
|
|
51
54
|
import { classifyError } from './recoverable-error.mjs';
|
|
55
|
+
import { withRecoveryRetry, RETRYABLE_CLASSES, HELPER_RETRY_ATTEMPTS } from './recovery-backoff.mjs';
|
|
52
56
|
import { resolveFailure, markTerminal, isTerminal } from './failure-policy.mjs';
|
|
53
57
|
import { byActor } from './identity.mjs';
|
|
54
58
|
|
|
@@ -97,6 +101,9 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
97
101
|
// `classify` is the test seam.
|
|
98
102
|
this._auto = { feedback: [], round: 0, prior: null, costUsd: 0, pending: null };
|
|
99
103
|
this._classify = typeof opts?.classify === 'function' ? opts.classify : null;
|
|
104
|
+
// Whether Claude Code is signed in decides which models Auto may design with
|
|
105
|
+
// (auto/runnable.mjs). `claudeAuth` is the test seam.
|
|
106
|
+
this._claudeAuth = typeof opts?.claudeAuth === 'function' ? opts.claudeAuth : () => probeClaudeAuth({ bin: this.claude.bin || undefined });
|
|
100
107
|
Object.assign(this.state, {
|
|
101
108
|
engine: 2,
|
|
102
109
|
active: [], // [{nodeId, executionId}]
|
|
@@ -170,7 +177,7 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
170
177
|
* @returns {Promise<{pair: ({model:string, effort:(string|null)}|null), warning: (string|null)}>}
|
|
171
178
|
*/
|
|
172
179
|
async _defragAgentPair() {
|
|
173
|
-
const explicit = { model: this.claude.model, effort: this.
|
|
180
|
+
const explicit = { model: this.claude.model, effort: this.claude.effort };
|
|
174
181
|
const stored = memoryDefragModel();
|
|
175
182
|
// The catalog read only when the setting is the one that decides.
|
|
176
183
|
const models = !(typeof explicit.model === 'string' && explicit.model.trim()) && stored.model
|
|
@@ -233,7 +240,14 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
233
240
|
|
|
234
241
|
async _decideTopologyInner() {
|
|
235
242
|
const registry = this.registry;
|
|
236
|
-
|
|
243
|
+
// Only models this install can run (auto/runnable.mjs): signed out, a first-party pick —
|
|
244
|
+
// or the CLI default a stage without a model falls back to — dies at its first spawn.
|
|
245
|
+
let auth = 'unknown';
|
|
246
|
+
try { auth = (await this._claudeAuth())?.state || 'unknown'; } catch { /* unknown narrows nothing */ }
|
|
247
|
+
const runnable = autoModelsFor(await listModels(this.projectDir), { auth, routed: modelHasBaseUrlRouting });
|
|
248
|
+
const models = runnable.models;
|
|
249
|
+
const requireModel = runnable.requireModel;
|
|
250
|
+
if (runnable.note) this._log('orchestrator', requireModel ? 'info' : 'warn', `auto: ${runnable.note}`);
|
|
237
251
|
const model = resolveAutoModel(models);
|
|
238
252
|
const fingerprint = await fingerprintProject(this.projectDir);
|
|
239
253
|
this._log('orchestrator', 'info', `auto: fingerprint ${Buffer.byteLength(fingerprint, 'utf8')} B`);
|
|
@@ -260,11 +274,11 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
260
274
|
} else {
|
|
261
275
|
this._auto.round += 1;
|
|
262
276
|
round = this._auto.round;
|
|
263
|
-
classifyFor = classify;
|
|
277
|
+
classifyFor = (input) => this._classifyWithRetry(classify, input);
|
|
264
278
|
}
|
|
265
279
|
let outcome;
|
|
266
280
|
try {
|
|
267
|
-
outcome = await this._autoRound({ registry, models, model, fingerprint, extras, taskText, classify: classifyFor, round });
|
|
281
|
+
outcome = await this._autoRound({ registry, models, requireModel, model, fingerprint, extras, taskText, classify: classifyFor, round });
|
|
268
282
|
} catch (err) {
|
|
269
283
|
if (isAbort(err) || isPause(err)) throw err;
|
|
270
284
|
if (pending && err instanceof ShapeError) {
|
|
@@ -274,6 +288,13 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
274
288
|
this._auto.pending = null;
|
|
275
289
|
continue;
|
|
276
290
|
}
|
|
291
|
+
if (err instanceof ClassifierError && !pending && RETRYABLE_CLASSES.includes(classifyError(err))) {
|
|
292
|
+
// A provider 429 / dropped connection that outlasted _classifyWithRetry says
|
|
293
|
+
// nothing about the task: run the default workflow rather than park a run
|
|
294
|
+
// that has not started. Only a transient cause falls back — an unusable reply
|
|
295
|
+
// or a timeout keeps the D17 pause below.
|
|
296
|
+
return await this._autoFallbackDefault({ err, registry, round, nodeModel: requireModel ? model : null });
|
|
297
|
+
}
|
|
277
298
|
if (err instanceof ClassifierError || err instanceof ShapeError) {
|
|
278
299
|
// spec D17 / §5.6: the shell's failure policy parks the run (setup site ⇒
|
|
279
300
|
// pause, reason 'error', detail = the message) and the resume point keeps
|
|
@@ -318,9 +339,9 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
318
339
|
}
|
|
319
340
|
|
|
320
341
|
/** One round: classifier call (one assembler-driven retry), match, proposal. */
|
|
321
|
-
async _autoRound({ registry, models, model, fingerprint, extras, taskText, classify, round }) {
|
|
342
|
+
async _autoRound({ registry, models, requireModel = false, model, fingerprint, extras, taskText, classify, round }) {
|
|
322
343
|
const input = {
|
|
323
|
-
taskText, extras, fingerprint, models, registry,
|
|
344
|
+
taskText, extras, fingerprint, models, requireModel, registry,
|
|
324
345
|
domain: 'coding', // the domain the assembler stamps: coding + shared + general agents are offered
|
|
325
346
|
humanInLoop: this.humanInLoop, feedback: [...this._auto.feedback], priorShape: this._auto.prior,
|
|
326
347
|
// D6 amendment (2026-09-07): the classifier may Grep/Glob/Read the RUN'S OWN checkout to
|
|
@@ -408,8 +429,9 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
408
429
|
return raw && raw.decision ? raw : validate(raw); // auto mode answers { decision: 'accept' } without the validator
|
|
409
430
|
}
|
|
410
431
|
|
|
411
|
-
/** Reuse the twin or save a new row, resolve it with the accepted tunables as the ONLY overlay, re-stamp the manifest.
|
|
412
|
-
|
|
432
|
+
/** Reuse the twin or save a new row, resolve it with the accepted tunables as the ONLY overlay, re-stamp the manifest.
|
|
433
|
+
* `fallback` = the classifier failure a default-workflow fallback adopts in place of a proposal (_autoFallbackDefault). */
|
|
434
|
+
async _autoAdopt({ template, match, tunables, shape, answer, registry, round, fallback = null }) {
|
|
413
435
|
const name = answer.name || shape.name;
|
|
414
436
|
if (!match) {
|
|
415
437
|
// B3: the twin search ran at proposal time; another Auto run, the composer or the chat may
|
|
@@ -450,7 +472,10 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
450
472
|
const manifest = buildGraphManifest(this.resolved.template, this.resolved.agentsByKey, {
|
|
451
473
|
overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires }, scripts: this.resolved.scriptsByKey,
|
|
452
474
|
});
|
|
453
|
-
|
|
475
|
+
// A fallback records what it saved (`saved`) and why (`reason`) beside via 'fallback'.
|
|
476
|
+
manifest.auto = fallback
|
|
477
|
+
? { status: 'decided', via: 'fallback', saved: via, reason: fallback, rounds: round, humanInLoop: this.humanInLoop, workflowId }
|
|
478
|
+
: { status: 'decided', via, rounds: round, humanInLoop: this.humanInLoop, workflowId };
|
|
454
479
|
this._preflightAgentKeys(this.resolved.agentKeys);
|
|
455
480
|
this._preflightScriptKeys(this.resolved.scriptKeys);
|
|
456
481
|
await this._preflightScriptRuntimes();
|
|
@@ -467,11 +492,69 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
467
492
|
this.state.resumePoint = null; // decided: the engine's onSnapshot owns the point from here
|
|
468
493
|
this._emit('state', this.getState());
|
|
469
494
|
await this._persist();
|
|
470
|
-
|
|
471
|
-
|
|
495
|
+
if (fallback) {
|
|
496
|
+
await appendAudit(this.pipeline.dir, `Auto workflow: the classifier was unreachable (${fallback}) — running the default workflow **${name}** (${via === 'reused' ? `saved workflow ${workflowId}` : `saved as ${workflowId}`}).`).catch(() => {});
|
|
497
|
+
} else {
|
|
498
|
+
this._log('orchestrator', 'info', `auto: accepted → "${name}" (${workflowId}, ${via})`);
|
|
499
|
+
await appendAudit(this.pipeline.dir, `Auto workflow: **${name}** — ${via === 'reused' ? `reusing saved workflow ${workflowId}` : `saved as ${workflowId}`}.`).catch(() => {});
|
|
500
|
+
}
|
|
472
501
|
return { manifest, agentKeys: new Set(this.resolved.agentKeys), workflow: { id: workflowId, name: this.resolved.template.name || name } };
|
|
473
502
|
}
|
|
474
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The Auto classifier call with the shared recovery backoff (recovery-backoff.mjs):
|
|
506
|
+
* a rate_limit / network failure — a provider 429 the CLI already retried, a dropped
|
|
507
|
+
* connection — is retried before the round gives up. What the failed attempts spent
|
|
508
|
+
* rides the result (or the final error), so the round books it once (D14).
|
|
509
|
+
*/
|
|
510
|
+
async _classifyWithRetry(classify, input) {
|
|
511
|
+
let spent = 0;
|
|
512
|
+
try {
|
|
513
|
+
const res = await withRecoveryRetry(() => classify(input), {
|
|
514
|
+
signal: input.signal,
|
|
515
|
+
onRetry: ({ attempt, cls, delayMs, err }) => {
|
|
516
|
+
spent += Number(err?.costUsd) || 0;
|
|
517
|
+
this._log('orchestrator', 'warn',
|
|
518
|
+
`auto: classifier ${cls} — retrying in ${Math.round(delayMs / 100) / 10}s (retry ${attempt}/${HELPER_RETRY_ATTEMPTS}): ${firstLine(err?.detail || err?.message)}`);
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
if (spent) res.costUsd = (Number(res.costUsd) || 0) + spent;
|
|
522
|
+
return res;
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (spent && err && typeof err === 'object') err.costUsd = (Number(err.costUsd) || 0) + spent;
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The classifier stayed unreachable (a transient class after its retries): run the
|
|
530
|
+
* default workflow — adopted like an accepted proposal, so it is saying why in the run
|
|
531
|
+
* log, the audit and the manifest (auto.via 'fallback') — instead of parking a run
|
|
532
|
+
* that has not started. With a human in the loop that is the standard recipe, whose
|
|
533
|
+
* exact twin is the built-in Default (wf_default); with nobody in the loop it is the
|
|
534
|
+
* same recipe without Clarify, because spec D3 forbids stopping the run to ask. */
|
|
535
|
+
async _autoFallbackDefault({ err, registry, round, nodeModel = null }) {
|
|
536
|
+
const cause = firstLine(err?.detail || err?.message);
|
|
537
|
+
const cls = classifyError(err);
|
|
538
|
+
const recipe = RECIPE_SHAPES.find((r) => r.id === (this.humanInLoop ? 'prompt' : 'plan-partial'));
|
|
539
|
+
const assembled = assembleShape(normalizeShape(jsonClone(recipe.shape)), { registry, humanInLoop: this.humanInLoop });
|
|
540
|
+
// Signed out (auto/runnable.mjs), the recipe's nodes have no model to fall back to: run
|
|
541
|
+
// every one on the model Auto itself runs on — routed by construction.
|
|
542
|
+
if (nodeModel) {
|
|
543
|
+
for (const n of assembled.template.nodes || []) {
|
|
544
|
+
if (n.kind === 'agent') assembled.tunables[n.id] = { ...(assembled.tunables[n.id] || {}), model: nodeModel, effort: '' };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const match = findEquivalentWorkflow(assembled.template, await autoCandidates());
|
|
548
|
+
this._log('orchestrator', 'warn',
|
|
549
|
+
`auto: the workflow classifier failed (${cls}) after ${HELPER_RETRY_ATTEMPTS} retries — running the default workflow "${match ? match.candidate.name : assembled.shape.name}" instead: ${cause}`);
|
|
550
|
+
return this._autoAdopt({
|
|
551
|
+
template: match ? match.candidate : assembled.template, match,
|
|
552
|
+
tunables: match ? remapTunables(assembled.tunables, match.nodeMap) : assembled.tunables,
|
|
553
|
+
shape: assembled.shape, answer: { decision: 'accept', name: assembled.shape.name, nodes: {} },
|
|
554
|
+
registry, round, fallback: `${cls}: ${cause}`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
475
558
|
/** Attached files as the classifier sees them: names, plus the first 2 KB of text files. */
|
|
476
559
|
async _autoExtras() {
|
|
477
560
|
const out = [];
|
|
@@ -752,6 +835,11 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
752
835
|
// advance it), so a resume credits the paused execution's pre-pause work at its terminal.
|
|
753
836
|
humanCursor: this._humanCursorReady ? (this._humanCursor ?? { files: 0, insertions: 0, deletions: 0 }) : null,
|
|
754
837
|
stepModels: this.stepModels,
|
|
838
|
+
// The run-level model the run was started with (restored by the harness
|
|
839
|
+
// constructor); only the fields that are set, so an older reader sees none.
|
|
840
|
+
...(this.claude.model || this.claude.effort
|
|
841
|
+
? { claude: { ...(this.claude.model ? { model: this.claude.model } : {}), ...(this.claude.effort ? { effort: this.claude.effort } : {}) } }
|
|
842
|
+
: {}),
|
|
755
843
|
workflowId: this.workflowId,
|
|
756
844
|
// Auto workflow: the decision state while UNDECIDED (spec §5.6); null once
|
|
757
845
|
// the graph is adopted (workflowId is then the real id) and on saved workflows.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// snapshot of local settings comes from policy/local.mjs, the document from policy/sync.mjs.
|
|
5
5
|
|
|
6
6
|
import { FIELDS, effectiveKind, tierRank, semverAtLeast } from './registry.mjs';
|
|
7
|
+
import { capDomainList } from '../web-allowlist.mjs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The team entries that apply to ONE run kind: `workspaceRuns` replaces `fields` for
|
|
@@ -45,6 +46,14 @@ export function effectiveDefault({ local = null, team = null } = {}) {
|
|
|
45
46
|
return { value: local ? local.value : null, source: local ? 'default' : 'none' };
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** Ask web access on/off: only the developer switches it on; a team value can only switch it off. */
|
|
50
|
+
export function effectiveWebEnabled({ local = null, team = null } = {}) {
|
|
51
|
+
if (team && team.value === false) return false;
|
|
52
|
+
return !!(local && local.value === true);
|
|
53
|
+
}
|
|
54
|
+
/** The Ask web allowlist: the developer's own list, capped by the team's when the policy sets one. */
|
|
55
|
+
export function capWebDomains(local, team) { return capDomainList(local, Array.isArray(team) ? team : null); }
|
|
56
|
+
|
|
48
57
|
const fmtUsd = (n) => `$${Number(n).toFixed(2)}`;
|
|
49
58
|
// Built-in guardrail ids render as their display names everywhere (`secure` shows as "Strict",
|
|
50
59
|
// the guardrail-store.mjs BUILTIN_META rule); a policy set reads "gp:<id>".
|
|
@@ -75,7 +84,15 @@ export function effectiveRows({ doc = null, workspaceRun = false, local = {} } =
|
|
|
75
84
|
const t = team[meta.key] || null;
|
|
76
85
|
const l = local[meta.key] || null;
|
|
77
86
|
let effective; let note = null;
|
|
78
|
-
if (meta.
|
|
87
|
+
if (meta.key === 'ask.webEnabled') {
|
|
88
|
+
const v = effectiveWebEnabled({ local: l, team: t });
|
|
89
|
+
effective = { value: v, display: fmtValue(meta, v), source: t && t.value === false ? 'team' : 'local' };
|
|
90
|
+
} else if (meta.key === 'ask.webAllowedDomains') {
|
|
91
|
+
const v = capWebDomains(l?.value, t?.value);
|
|
92
|
+
effective = { value: v, display: fmtValue(meta, v), source: t ? 'team' : 'local' };
|
|
93
|
+
const dropped = (Array.isArray(l?.value) ? l.value.length : 0) - v.length;
|
|
94
|
+
if (t && dropped > 0) note = `${dropped} of yours ${dropped === 1 ? 'is' : 'are'} outside the team list`;
|
|
95
|
+
} else if (meta.cap) {
|
|
79
96
|
const r = effectiveCap({ local: l?.set ? l.value : null, team: t });
|
|
80
97
|
effective = { value: r.cap, display: fmtValue(meta, r.cap), source: r.binding || 'none' };
|
|
81
98
|
if (r.binding === 'team' && l?.set && l.value > t.value) note = `yours (${fmtUsd(l.value)}) is looser; the team cap applies`;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import {
|
|
9
|
-
readSettings, pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod, humanRateUsdPerHour, askMaxTurns, askMaxBudgetUsd, hideBuiltinModels,
|
|
9
|
+
readSettings, pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod, humanRateUsdPerHour, askMaxTurns, askMaxBudgetUsd, hideBuiltinModels, askWeb,
|
|
10
10
|
} from '../settings.mjs';
|
|
11
11
|
import { readConfigRow, readTeamMetricsPrefs } from '../config.mjs';
|
|
12
12
|
import { projectKey } from '../store.mjs';
|
|
@@ -27,6 +27,7 @@ const parseJson = (s, fallback) => { try { const v = JSON.parse(s); return v ??
|
|
|
27
27
|
export function localSnapshot(projectDir = null) {
|
|
28
28
|
const raw = readSettings();
|
|
29
29
|
const has = (k) => raw[k] !== undefined && raw[k] !== null && raw[k] !== '';
|
|
30
|
+
const web = askWeb();
|
|
30
31
|
const out = {
|
|
31
32
|
'cost.pipelineLimitUsd': { value: pipelineCostLimitUsd(), set: pipelineCostLimitUsd() != null },
|
|
32
33
|
'cost.totalLimitUsd': { value: totalCostLimitUsd(), set: totalCostLimitUsd() != null },
|
|
@@ -34,6 +35,8 @@ export function localSnapshot(projectDir = null) {
|
|
|
34
35
|
'cost.humanRateUsd': { value: humanRateUsdPerHour(), set: humanRateUsdPerHour() != null },
|
|
35
36
|
'ask.maxTurns': { value: askMaxTurns(), set: has('askMaxTurns') },
|
|
36
37
|
'ask.maxBudgetUsd': { value: askMaxBudgetUsd(), set: raw.askMaxBudgetUsd !== undefined }, // literal null = "no cap", a choice
|
|
38
|
+
'ask.webEnabled': { value: web.enabled, set: typeof raw.askWeb?.enabled === 'boolean' },
|
|
39
|
+
'ask.webAllowedDomains': { value: web.allowedDomains, set: web.allowedDomains.length > 0 },
|
|
37
40
|
'models.hideBuiltins': { value: hideBuiltinModels(), set: has('hideBuiltinModels') || raw.hideBuiltinModelsChosen === true },
|
|
38
41
|
'plugins.marketplaces': { value: Object.values(readMarketplaces().marketplaces).map((m) => m.url), set: true },
|
|
39
42
|
'plugins.required': { value: Object.entries(readPluginsLock()).map(([name, e]) => ({ name, version: e?.version ?? null, enabled: e?.enabled !== false })), set: true },
|