@chatpanel/gateway 0.6.87 → 0.6.90
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/package.json +1 -1
- package/src/budget.js +117 -0
- package/src/engine-ledger-store.js +150 -0
- package/src/engine.js +132 -0
- package/src/gate.js +75 -0
- package/src/job.js +149 -0
- package/src/model-ledger.js +205 -0
- package/src/project-store.js +139 -0
- package/src/project.js +171 -0
- package/src/scorecard-store.js +1 -1
- package/src/scorecard.js +148 -4
- package/src/server.js +123 -9
- package/src/team-store.js +4 -1
- package/src/team.js +303 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.90",
|
|
4
4
|
"description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/budget.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/budget.js — edit there, then copy over.
|
|
2
|
+
// A budget — the number a run may not exceed, and the record of what it spent.
|
|
3
|
+
//
|
|
4
|
+
// There was no spend cap anywhere. A live monitor is declared class C and starts model turns
|
|
5
|
+
// for the length of a meeting; a spoken "keep an eye on X" arms that with nothing bounding
|
|
6
|
+
// it; `jobs.js` caps a per-day job COUNT, not spend. The class declarations on every intent
|
|
7
|
+
// and rule were made for exactly this, and nothing read them.
|
|
8
|
+
//
|
|
9
|
+
// A budget is a VALUE: declared on the thing that spends (a team, a schedule, a monitor),
|
|
10
|
+
// charged by whatever runs it, and carried on the run record so the ledger can say what a
|
|
11
|
+
// run cost in the same units it was capped in. Four dimensions, because the expensive thing
|
|
12
|
+
// differs by executor: tokens and calls for a model, wall time for an agent that thinks for
|
|
13
|
+
// minutes, cost when the gateway can report it. Any dimension may be absent; an absent one is
|
|
14
|
+
// not enforced. A budget with NO dimension is not a budget — `validateBudget` refuses it, and
|
|
15
|
+
// a team without one does not run (F8, O1).
|
|
16
|
+
//
|
|
17
|
+
// Pure. `now` is injected so a wall-time cap is testable.
|
|
18
|
+
|
|
19
|
+
export const BUDGET_DIMENSIONS = Object.freeze(['tokens', 'calls', 'ms', 'usd']);
|
|
20
|
+
|
|
21
|
+
export class BudgetError extends Error {
|
|
22
|
+
constructor(code, message) { super(message); this.name = 'BudgetError'; this.code = code; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `{ ok, errors }` — a budget must cap at least one thing, and every cap must be a positive number. */
|
|
26
|
+
export function validateBudget(b) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
if (!b || typeof b !== 'object') return { ok: false, errors: ['budget must be an object'] };
|
|
29
|
+
let any = false;
|
|
30
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
31
|
+
if (b[k] === undefined || b[k] === null) continue;
|
|
32
|
+
const n = Number(b[k]);
|
|
33
|
+
if (!Number.isFinite(n) || n <= 0) errors.push(`${k}: a positive number`);
|
|
34
|
+
else any = true;
|
|
35
|
+
}
|
|
36
|
+
for (const k of Object.keys(b)) if (!BUDGET_DIMENSIONS.includes(k)) errors.push(`${k}: not a budget dimension (${BUDGET_DIMENSIONS.join(', ')})`);
|
|
37
|
+
if (!any) errors.push('a budget must cap at least one of tokens, calls, ms, usd');
|
|
38
|
+
return { ok: errors.length === 0, errors };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Only the declared dimensions, as numbers. */
|
|
42
|
+
export function normalizeBudget(b) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
45
|
+
const n = Number(b?.[k]);
|
|
46
|
+
if (Number.isFinite(n) && n > 0) out[k] = n;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The usage a model call reports, in the shapes the providers use, as one record:
|
|
53
|
+
* `{ tokens, calls, usd }`. `ms` is measured by the budget itself.
|
|
54
|
+
*/
|
|
55
|
+
export function usageOf(u = {}) {
|
|
56
|
+
const tokens = Number(u.tokens ?? u.total_tokens ?? ((Number(u.input_tokens ?? u.prompt_tokens) || 0) + (Number(u.output_tokens ?? u.completion_tokens) || 0)));
|
|
57
|
+
return { tokens: Number.isFinite(tokens) ? tokens : 0, calls: Number(u.calls ?? 1) || 0, usd: Number(u.usd ?? u.cost) || 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A live budget for one run.
|
|
62
|
+
*
|
|
63
|
+
* charge(usage) add a model call's usage; returns what is left
|
|
64
|
+
* canAfford(estimate) false when an estimated call would cross a cap — ask BEFORE calling
|
|
65
|
+
* exhausted() the dimension that ran out, or null
|
|
66
|
+
* snapshot() { cap, spent, remaining, exhausted } for the run record and the meter
|
|
67
|
+
*/
|
|
68
|
+
export function createBudget(declared, { now = () => Date.now() } = {}) {
|
|
69
|
+
const v = validateBudget(declared);
|
|
70
|
+
if (!v.ok) throw new BudgetError('INVALID', v.errors.join('; '));
|
|
71
|
+
const cap = { ...normalizeBudget(declared) };
|
|
72
|
+
const startedAt = now();
|
|
73
|
+
const spent = { tokens: 0, calls: 0, usd: 0 };
|
|
74
|
+
const elapsed = () => now() - startedAt;
|
|
75
|
+
const remaining = () => {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
78
|
+
if (cap[k] === undefined) continue;
|
|
79
|
+
out[k] = Math.max(0, cap[k] - (k === 'ms' ? elapsed() : spent[k]));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
const exhausted = () => {
|
|
84
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
85
|
+
if (cap[k] === undefined) continue;
|
|
86
|
+
if ((k === 'ms' ? elapsed() : spent[k]) >= cap[k]) return k;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
cap,
|
|
92
|
+
charge(usage) {
|
|
93
|
+
const u = usageOf(usage);
|
|
94
|
+
spent.tokens += u.tokens; spent.calls += u.calls; spent.usd += u.usd;
|
|
95
|
+
return remaining();
|
|
96
|
+
},
|
|
97
|
+
canAfford(estimate = {}) {
|
|
98
|
+
if (exhausted()) return false;
|
|
99
|
+
const e = usageOf({ calls: 1, ...estimate });
|
|
100
|
+
for (const k of ['tokens', 'calls', 'usd']) {
|
|
101
|
+
if (cap[k] !== undefined && spent[k] + e[k] > cap[k]) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
remaining,
|
|
106
|
+
exhausted,
|
|
107
|
+
/** A person raised the cap mid-run (a budget ask answered "allow"): by a factor, once. */
|
|
108
|
+
raise(factor = 1.5) {
|
|
109
|
+
const f = Math.max(1, Number(factor) || 1);
|
|
110
|
+
for (const k of Object.keys(cap)) if (cap[k] !== undefined) cap[k] = Math.ceil(cap[k] * f);
|
|
111
|
+
return { ...cap };
|
|
112
|
+
},
|
|
113
|
+
snapshot() {
|
|
114
|
+
return { cap, spent: { ...spent, ms: elapsed() }, remaining: remaining(), exhausted: exhausted() };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The engines' ledgers — every fact the runner and a host observed about a MODEL or a
|
|
2
|
+
// HARNESS, chained and attested here (model-ledger.js), one chain per engine key.
|
|
3
|
+
//
|
|
4
|
+
// The scorecard store's twin. A scorecard says what an AGENT did; a ledger says how an
|
|
5
|
+
// ENGINE behaved while doing it — did it answer, how fast, how much, was the JSON valid, was
|
|
6
|
+
// the verdict good. The facts come from the run store's fold (a finished task is a `call`,
|
|
7
|
+
// a re-appointment is a `declined` on the engine that was left, a hand-off a
|
|
8
|
+
// `rotated-from`), from a host that timed its own chat calls (`POST /v1/engines/:key/entries`),
|
|
9
|
+
// and from a person (a rating on a task lands on the engine that served it; a price they
|
|
10
|
+
// typed). Nothing is ever edited. The same store key attests both stores, under its own
|
|
11
|
+
// label, so a scorecard's mark cannot be replayed as a ledger's.
|
|
12
|
+
//
|
|
13
|
+
// Read: `GET /v1/engines` (every card), `GET /v1/engines/:key/card` (the card, the chain
|
|
14
|
+
// on request, whether it verifies). The card is what a client feeds `applyCard` — observed
|
|
15
|
+
// quality, latency and cost over the name-based guess once there is enough history.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { createHmac, webcrypto } from 'node:crypto';
|
|
21
|
+
import { makeLedgerEntry, verifyChain, attest, verifyAttested, summarizeEngine, ledgerKey, LEDGER_ENTRY_KINDS, DECLINE_REASONS } from './model-ledger.js';
|
|
22
|
+
import { normalizeEngine } from './scorecard.js';
|
|
23
|
+
|
|
24
|
+
const DIR = join(os.homedir(), '.chatpanel');
|
|
25
|
+
const STORE_PATH = process.env.CHATPANEL_ENGINES_STORE || join(DIR, 'engines.json');
|
|
26
|
+
const MAX_ENTRIES_PER_ENGINE = 20000;
|
|
27
|
+
|
|
28
|
+
/** Why an engine declined, read from the error the runner recorded. */
|
|
29
|
+
export function declineReasonOf(error) {
|
|
30
|
+
const m = String(error || '');
|
|
31
|
+
if (/rate|429|overloaded|capacity|too many/i.test(m)) return 'rate';
|
|
32
|
+
if (/401|403|unauthori[sz]ed|no api key|invalid.*key|forbidden|not configured/i.test(m)) return 'auth';
|
|
33
|
+
if (/credit|quota|billing|insufficient|402/i.test(m)) return 'credits';
|
|
34
|
+
if (/timed? ?out|ETIMEDOUT|deadline/i.test(m)) return 'timeout';
|
|
35
|
+
if (/context|too long|maximum.*tokens|token limit/i.test(m)) return 'context';
|
|
36
|
+
if (/not[_ ]found|404|not deployed|unavailable|does not exist|unknown model|ECONNREFUSED|could ?n.t reach|closed the connection|exited|502|503|500/i.test(m)) return 'unavailable';
|
|
37
|
+
return 'other';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class EngineLedgerStore {
|
|
41
|
+
constructor({ storePath = STORE_PATH, key = null, now = () => Date.now() } = {}) {
|
|
42
|
+
this.path = storePath;
|
|
43
|
+
this.now = now;
|
|
44
|
+
this._mark = key ? createHmac('sha256', key).update('chatpanel:engine-ledger:attest:v1').digest() : null;
|
|
45
|
+
this.chains = new Map(); // key -> [entries]
|
|
46
|
+
this._routed = new Map(); // `${runId}/${taskId}` -> the engine last routed to (for declines and rotations)
|
|
47
|
+
this._queue = Promise.resolve();
|
|
48
|
+
}
|
|
49
|
+
load() {
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(this.path)) {
|
|
52
|
+
const doc = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
53
|
+
for (const [k, entries] of Object.entries(doc?.chains || {})) if (Array.isArray(entries)) this.chains.set(k, entries);
|
|
54
|
+
}
|
|
55
|
+
} catch { this.chains = new Map(); }
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
save() {
|
|
59
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
60
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
61
|
+
writeFileSync(tmp, JSON.stringify({ v: 1, chains: Object.fromEntries(this.chains) }), { mode: 0o600 });
|
|
62
|
+
renameSync(tmp, this.path);
|
|
63
|
+
}
|
|
64
|
+
/** Append one fact to an engine's chain: made, chained, attested, saved. Serialised per store. */
|
|
65
|
+
append(fact) {
|
|
66
|
+
const run = async () => {
|
|
67
|
+
const engine = normalizeEngine(fact?.engine);
|
|
68
|
+
if (!engine) throw new Error('model-ledger: engine required');
|
|
69
|
+
if (!LEDGER_ENTRY_KINDS.includes(fact?.kind)) throw new Error(`model-ledger: kind must be one of ${LEDGER_ENTRY_KINDS.join(', ')}`);
|
|
70
|
+
const key = ledgerKey(engine);
|
|
71
|
+
const chain = this.chains.get(key) || [];
|
|
72
|
+
if (chain.length >= MAX_ENTRIES_PER_ENGINE) throw new Error('model-ledger: chain is full');
|
|
73
|
+
let entry = await makeLedgerEntry({ ...fact, engine, at: fact.at || this.now() }, chain.at(-1) || null, { now: this.now, subtle: webcrypto.subtle });
|
|
74
|
+
if (this._mark) entry = await attest(entry, this._mark, { subtle: webcrypto.subtle });
|
|
75
|
+
chain.push(entry);
|
|
76
|
+
this.chains.set(key, chain);
|
|
77
|
+
this.save();
|
|
78
|
+
return entry;
|
|
79
|
+
};
|
|
80
|
+
const p = this._queue.then(run, run);
|
|
81
|
+
this._queue = p.catch(() => {});
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
84
|
+
/** The card, and on request the chain and whether it verifies. */
|
|
85
|
+
async get(key, { entries = false, minCalls, now } = {}) {
|
|
86
|
+
const chain = this.chains.get(String(key || '')) || [];
|
|
87
|
+
const out = { key: String(key || ''), card: summarizeEngine(chain, { minCalls, now: now || this.now() }) };
|
|
88
|
+
if (entries) {
|
|
89
|
+
out.entries = chain;
|
|
90
|
+
out.verified = await verifyChain(chain, { subtle: webcrypto.subtle });
|
|
91
|
+
out.attested = this._mark ? await verifyAttested(chain, this._mark, { subtle: webcrypto.subtle }) : { ok: false, attested: 0, of: chain.length };
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** Every engine's card, without the chains. */
|
|
96
|
+
list({ minCalls, now } = {}) {
|
|
97
|
+
return [...this.chains.entries()].map(([key, chain]) => summarizeEngine(chain, { minCalls, now: now || this.now() })).filter((c) => c.key);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A run store event, as the fold sees it. `task.routed` is remembered per task; a later
|
|
101
|
+
* `task.reappointed` is a decline on what was routed before it, `task.handoff` a rotation,
|
|
102
|
+
* and `task.scored` the call itself (the harness's or the model's whole task).
|
|
103
|
+
*/
|
|
104
|
+
fromRunEvent(ev, run) {
|
|
105
|
+
const type = String(ev?.type || '');
|
|
106
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
|
|
107
|
+
const runId = run?.id || p.runId || '';
|
|
108
|
+
const slot = `${runId}/${p.taskId || ''}`;
|
|
109
|
+
if (type === 'task.routed') {
|
|
110
|
+
const engine = normalizeEngine(p.engine);
|
|
111
|
+
if (engine) this._routed.set(slot, engine);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (type === 'task.reappointed' || type === 'task.handoff') {
|
|
115
|
+
const from = this._routed.get(slot);
|
|
116
|
+
if (!from) return null;
|
|
117
|
+
const fact = type === 'task.reappointed'
|
|
118
|
+
? { engine: from, kind: 'declined', at: ev.at, runId, taskId: p.taskId, declined: { reason: declineReasonOf(p.error), error: p.error } }
|
|
119
|
+
: { engine: from, kind: 'rotated-from', at: ev.at, runId, taskId: p.taskId, rotated: { to: p.to ? { id: p.to } : undefined, reason: p.reason || `handed off by ${p.by || 'a person'}` } };
|
|
120
|
+
return this.append(fact).catch(() => null);
|
|
121
|
+
}
|
|
122
|
+
if (type === 'task.scored') {
|
|
123
|
+
const engine = normalizeEngine(p.engine);
|
|
124
|
+
if (!engine) return null;
|
|
125
|
+
const ok = p.outcome !== 'task.failed';
|
|
126
|
+
return this.append({
|
|
127
|
+
engine, kind: 'call', at: ev.at, runId, taskId: p.taskId, agentId: p.agentId,
|
|
128
|
+
call: { ok, totalMs: p.size?.ms, tokens: p.size?.tokens || undefined, empty: !ok && /no answer|did not answer|returned nothing|empty/i.test(String(p.error || '')) },
|
|
129
|
+
refs: p.refs,
|
|
130
|
+
}).catch(() => null);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A rating a person gave an AGENT'S task (scorecard-store.js) lands on the engine that
|
|
136
|
+
* served it too: `chain` is the agent's scorecard, `entry` the rating just appended.
|
|
137
|
+
*/
|
|
138
|
+
fromRating(chain, entry) {
|
|
139
|
+
if (!entry?.rating || !Array.isArray(chain)) return null;
|
|
140
|
+
const r = entry.rating;
|
|
141
|
+
const task = r.about != null ? chain.find((e) => e.seq === r.about)
|
|
142
|
+
: entry.taskId ? chain.find((e) => (e.kind === 'task.done' || e.kind === 'task.failed') && e.taskId === entry.taskId && (!entry.runId || e.runId === entry.runId))
|
|
143
|
+
: entry.runId ? (() => { const xs = chain.filter((e) => (e.kind === 'task.done' || e.kind === 'task.failed') && e.runId === entry.runId); return xs.length === 1 ? xs[0] : null; })() : null;
|
|
144
|
+
if (!task?.engine) return null;
|
|
145
|
+
return this.append({ engine: task.engine, kind: 'rating', at: entry.at, runId: task.runId, taskId: task.taskId, agentId: entry.agentId, rating: { by: r.by, score: r.score, jobKind: entry.jobKind, agentId: entry.agentId } }).catch(() => null);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createEngineLedgerStore(opts) { return new EngineLedgerStore(opts).load(); }
|
|
150
|
+
export { DECLINE_REASONS };
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/engine.js — edit there, then copy over.
|
|
2
|
+
// An ENGINE — what actually runs an agent's turns — as a declaration, and its one-line label.
|
|
3
|
+
//
|
|
4
|
+
// The model is a variable, not a constant (architecture-pillars.md §13). An agent card names
|
|
5
|
+
// the engine it runs on, and there are four ways to say it:
|
|
6
|
+
//
|
|
7
|
+
// { kind: 'model', providerId?, model } an endpoint the client calls; `providerId` is
|
|
8
|
+
// the endpoint / gateway destination, because the
|
|
9
|
+
// same model at two providers is two engines
|
|
10
|
+
// { kind: 'harness', harnessId, model? } a CLI coding agent the bridge runs (Claude
|
|
11
|
+
// Code, Codex, …) — ChatPanel delegates a whole
|
|
12
|
+
// task to it; `model` is what it was asked to run
|
|
13
|
+
// { kind: 'auto', policy } the recruiter picks, by policy, from the
|
|
14
|
+
// engine cards (§13.4) — the default
|
|
15
|
+
// { kind: 'assistant' } the built-in Assistant: whatever model the chat
|
|
16
|
+
// is on right now (`engineOf` resolves it)
|
|
17
|
+
//
|
|
18
|
+
// The RECORD keeps a flatter shape — `{ kind: 'model'|'harness', id, model? }`, see
|
|
19
|
+
// scorecard.js `normalizeEngine` — because a record says what DID run, and `auto` and
|
|
20
|
+
// `assistant` never run anything themselves. `engineRef` maps a spec to that shape once a
|
|
21
|
+
// choice was made, so the scorecard's `byEngine` and the model ledger key the same way.
|
|
22
|
+
//
|
|
23
|
+
// Pure, dependency-free; team.js and agent.js both import from here, never from each other.
|
|
24
|
+
|
|
25
|
+
export const ENGINE_KINDS = Object.freeze(['model', 'harness', 'auto', 'assistant']);
|
|
26
|
+
export const ROUTE_PREFERS = Object.freeze(['cheapest-that-clears', 'best-quality', 'fastest', 'balanced']);
|
|
27
|
+
export const HARNESS_ID_RE = /^[a-zA-Z0-9_.:@+-]{1,120}$/;
|
|
28
|
+
|
|
29
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
30
|
+
const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).trim().slice(0, n) || undefined);
|
|
31
|
+
const num = (v) => { const n = Number(v); return v === '' || v == null || !Number.isFinite(n) ? undefined : n; };
|
|
32
|
+
const refs = (xs) => (Array.isArray(xs) ? [...new Set(xs.map((x) => str(typeof x === 'string' ? x : engineKeyOf(x), 200)).filter(Boolean))] : undefined);
|
|
33
|
+
|
|
34
|
+
/** A routing policy, normalized: an unknown preference is `balanced`; floors and ceilings are numbers or absent. */
|
|
35
|
+
export function normalizePolicy(p) {
|
|
36
|
+
const src = isRecord(p) ? p : {};
|
|
37
|
+
const floor = {}; const ceiling = {};
|
|
38
|
+
const q = num(src.floor?.quality); if (q !== undefined) floor.quality = Math.max(0, Math.min(1, q));
|
|
39
|
+
const av = num(src.floor?.availability); if (av !== undefined) floor.availability = Math.max(0, Math.min(1, av));
|
|
40
|
+
const c = num(src.ceiling?.costPerTask); if (c !== undefined && c >= 0) ceiling.costPerTask = c;
|
|
41
|
+
const l = num(src.ceiling?.latencyMs); if (l !== undefined && l >= 0) ceiling.latencyMs = Math.round(l);
|
|
42
|
+
const allow = refs(src.allow); const deny = refs(src.deny);
|
|
43
|
+
return {
|
|
44
|
+
prefer: ROUTE_PREFERS.includes(src.prefer) ? src.prefer : 'balanced',
|
|
45
|
+
...(Object.keys(floor).length ? { floor } : {}),
|
|
46
|
+
...(Object.keys(ceiling).length ? { ceiling } : {}),
|
|
47
|
+
...(allow?.length ? { allow } : {}),
|
|
48
|
+
...(deny?.length ? { deny } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An engine spec as stored. A string is read the obvious way — `assistant`, `auto`, a
|
|
54
|
+
* `harness:<id>` / `model:<id>` prefix, or a bare model id — because a person types these
|
|
55
|
+
* and a model proposes them in prose. Anything unreadable is `auto`, the honest default.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeEngineSpec(e) {
|
|
58
|
+
if (e == null || e === '') return { kind: 'auto', policy: normalizePolicy() };
|
|
59
|
+
if (typeof e === 'string') {
|
|
60
|
+
const s = e.trim();
|
|
61
|
+
if (s === 'assistant' || s === 'auto') return s === 'assistant' ? { kind: 'assistant' } : { kind: 'auto', policy: normalizePolicy() };
|
|
62
|
+
const m = /^(model|harness):(.+)$/.exec(s);
|
|
63
|
+
if (m) return m[1] === 'harness' ? { kind: 'harness', harnessId: m[2].trim() } : { kind: 'model', model: m[2].trim() };
|
|
64
|
+
return { kind: 'model', model: s };
|
|
65
|
+
}
|
|
66
|
+
if (!isRecord(e)) return { kind: 'auto', policy: normalizePolicy() };
|
|
67
|
+
const kind = ENGINE_KINDS.includes(e.kind) ? e.kind : (e.harnessId ? 'harness' : e.model ? 'model' : e.policy ? 'auto' : 'auto');
|
|
68
|
+
if (kind === 'assistant') return { kind };
|
|
69
|
+
if (kind === 'auto') return { kind, policy: normalizePolicy(e.policy) };
|
|
70
|
+
if (kind === 'harness') {
|
|
71
|
+
const harnessId = str(e.harnessId || e.id, 120);
|
|
72
|
+
if (!harnessId) return { kind: 'auto', policy: normalizePolicy() };
|
|
73
|
+
const model = str(e.model, 200);
|
|
74
|
+
return { kind, harnessId, ...(model ? { model } : {}) };
|
|
75
|
+
}
|
|
76
|
+
const model = str(e.model || e.id, 200);
|
|
77
|
+
if (!model) return { kind: 'auto', policy: normalizePolicy() };
|
|
78
|
+
const providerId = str(e.providerId || e.destination || e.endpointId, 120);
|
|
79
|
+
return { kind: 'model', ...(providerId ? { providerId } : {}), model };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Is this a spec a validator should accept? Returns the errors, with a prefix. */
|
|
83
|
+
export function validateEngineSpec(e, where = 'engine') {
|
|
84
|
+
const errors = [];
|
|
85
|
+
if (e == null || e === '') return errors;
|
|
86
|
+
if (typeof e === 'string') return errors; // every string reads as something
|
|
87
|
+
if (!isRecord(e)) return [`${where}: a string or an object`];
|
|
88
|
+
if (e.kind !== undefined && !ENGINE_KINDS.includes(e.kind)) errors.push(`${where}.kind: one of ${ENGINE_KINDS.join(', ')}`);
|
|
89
|
+
if (e.kind === 'harness' && !str(e.harnessId || e.id)) errors.push(`${where}.harnessId: which harness`);
|
|
90
|
+
if (e.kind === 'harness' && str(e.harnessId || e.id) && !HARNESS_ID_RE.test(String(e.harnessId || e.id).trim())) errors.push(`${where}.harnessId: a short identifier`);
|
|
91
|
+
if (e.kind === 'model' && !str(e.model || e.id)) errors.push(`${where}.model: which model`);
|
|
92
|
+
if (e.kind === 'auto' && e.policy !== undefined && !isRecord(e.policy)) errors.push(`${where}.policy: an object`);
|
|
93
|
+
if (e.kind === 'auto' && isRecord(e.policy) && e.policy.prefer !== undefined && !ROUTE_PREFERS.includes(e.policy.prefer)) errors.push(`${where}.policy.prefer: one of ${ROUTE_PREFERS.join(', ')}`);
|
|
94
|
+
return errors;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The record's shape for a spec that names something concrete — `{ kind, id, model? }`,
|
|
99
|
+
* the same fields scorecard.js keys `byEngine` on and the model ledger is keyed by. `auto`
|
|
100
|
+
* and `assistant` have no ref: nothing ran yet.
|
|
101
|
+
*/
|
|
102
|
+
export function engineRef(spec) {
|
|
103
|
+
const s = normalizeEngineSpec(spec);
|
|
104
|
+
if (s.kind === 'harness') return { kind: 'harness', id: s.harnessId, ...(s.model ? { model: s.model } : {}) };
|
|
105
|
+
if (s.kind === 'model') return s.providerId ? { kind: 'model', id: s.providerId, model: s.model } : { kind: 'model', id: s.model };
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The ledger key of a spec, or null when it names nothing concrete. Same key as `engineKey` in scorecard.js. */
|
|
110
|
+
export function engineKeyOf(spec) {
|
|
111
|
+
const r = engineRef(spec);
|
|
112
|
+
return r ? `${r.kind}:${r.id}${r.model && r.model !== r.id ? `/${r.model}` : ''}` : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One phrase a person reads on a card: "Claude Code", "gpt-4o at openrouter", "auto · cheapest that clears", "the chat's model". */
|
|
116
|
+
export function describeEngine(spec, { harnessName = (id) => id, providerName = (id) => id } = {}) {
|
|
117
|
+
const s = normalizeEngineSpec(spec);
|
|
118
|
+
if (s.kind === 'assistant') return 'the chat’s model';
|
|
119
|
+
if (s.kind === 'auto') return `auto · ${s.policy.prefer.replace(/-/g, ' ')}`;
|
|
120
|
+
if (s.kind === 'harness') return `${harnessName(s.harnessId)}${s.model ? ` (${s.model})` : ''}`;
|
|
121
|
+
return `${s.model}${s.providerId ? ` at ${providerName(s.providerId)}` : ''}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The role tier today's appointers understand (`cheap` / `balanced` / `strong`) for a spec:
|
|
126
|
+
* the bridge to `prefer` until the recruiter routes by card (§13.4, step 5).
|
|
127
|
+
*/
|
|
128
|
+
export function tierOf(spec) {
|
|
129
|
+
const s = normalizeEngineSpec(spec);
|
|
130
|
+
if (s.kind !== 'auto') return 'balanced';
|
|
131
|
+
return { 'best-quality': 'strong', 'cheapest-that-clears': 'cheap', fastest: 'cheap', balanced: 'balanced' }[s.policy.prefer] || 'balanced';
|
|
132
|
+
}
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/gate.js — edit there, then copy over.
|
|
2
|
+
// The gate — how far a team may go without a person, as data an organisation configures.
|
|
3
|
+
//
|
|
4
|
+
// ChatPanel's own gate is the strictest setting; an organisation that trusts its pool more
|
|
5
|
+
// flips a flag. Nothing in the runner changes: project-run.js reads the gate at every step
|
|
6
|
+
// where it would otherwise ask, and `gateAllows` is the one question it asks. Lives in
|
|
7
|
+
// `.chatpanel/gate.json` in the org repo (pillars §14.3), optionally overridden per project.
|
|
8
|
+
|
|
9
|
+
export const AUTONOMY = Object.freeze(['propose', 'push', 'merge']);
|
|
10
|
+
export const HUMAN_FLAGS = Object.freeze(['merge', 'push', 'publish', 'budgetRaise', 'newAgent', 'newTool', 'writeBack', 'recruit']);
|
|
11
|
+
export const CHECKS = Object.freeze(['guard', 'review', 'tester', 'scan']);
|
|
12
|
+
|
|
13
|
+
/** ChatPanel's own: a branch push is not a release; everything else waits for a person. */
|
|
14
|
+
export const DEFAULT_GATE = Object.freeze({
|
|
15
|
+
autonomy: 'push',
|
|
16
|
+
human: Object.freeze({ merge: true, push: false, publish: true, budgetRaise: true, newAgent: true, newTool: true, writeBack: true, recruit: false }),
|
|
17
|
+
requiredBeforeMerge: Object.freeze(['guard', 'review', 'tester']),
|
|
18
|
+
branches: Object.freeze({ base: 'main', protected: Object.freeze(['main']) }),
|
|
19
|
+
budget: Object.freeze({ perProjectCap: null, perJobCap: null }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
23
|
+
|
|
24
|
+
export function validateGate(g, { partial = false } = {}) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
if (!isRecord(g)) return { ok: false, errors: ['gate must be an object'] };
|
|
27
|
+
if (g.autonomy !== undefined && !AUTONOMY.includes(g.autonomy)) errors.push(`autonomy: one of ${AUTONOMY.join(', ')}`);
|
|
28
|
+
if (g.human !== undefined) {
|
|
29
|
+
if (!isRecord(g.human)) errors.push('human: an object of flags');
|
|
30
|
+
else for (const [k, v] of Object.entries(g.human)) { if (!HUMAN_FLAGS.includes(k)) errors.push(`human.${k}: unknown flag`); else if (typeof v !== 'boolean') errors.push(`human.${k}: true or false`); }
|
|
31
|
+
}
|
|
32
|
+
if (g.requiredBeforeMerge !== undefined && (!Array.isArray(g.requiredBeforeMerge) || g.requiredBeforeMerge.some((c) => !CHECKS.includes(c)))) errors.push(`requiredBeforeMerge: a list of ${CHECKS.join(', ')}`);
|
|
33
|
+
if (g.branches !== undefined && (!isRecord(g.branches) || (g.branches.protected !== undefined && !Array.isArray(g.branches.protected)))) errors.push('branches: { base, protected[] }');
|
|
34
|
+
if (g.budget !== undefined && !isRecord(g.budget)) errors.push('budget: { perProjectCap, perJobCap }');
|
|
35
|
+
if (!partial && g.autonomy === undefined) errors.push('autonomy: required');
|
|
36
|
+
return { ok: errors.length === 0, errors };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A gate over the default: a partial gate fills in from ChatPanel's own; a full one stands alone. */
|
|
40
|
+
export function normalizeGate(g, { partial = false, base = DEFAULT_GATE } = {}) {
|
|
41
|
+
const v = validateGate(g || {}, { partial: true });
|
|
42
|
+
if (!v.ok) throw new Error(`gate: ${v.errors.join('; ')}`);
|
|
43
|
+
const src = g || {};
|
|
44
|
+
const b = partial ? base : DEFAULT_GATE;
|
|
45
|
+
return {
|
|
46
|
+
autonomy: AUTONOMY.includes(src.autonomy) ? src.autonomy : b.autonomy,
|
|
47
|
+
human: { ...b.human, ...(isRecord(src.human) ? src.human : {}) },
|
|
48
|
+
requiredBeforeMerge: Array.isArray(src.requiredBeforeMerge) ? [...new Set(src.requiredBeforeMerge)] : [...b.requiredBeforeMerge],
|
|
49
|
+
branches: { base: String(src.branches?.base || b.branches.base), protected: Array.isArray(src.branches?.protected) ? [...new Set(src.branches.protected.map(String))] : [...b.branches.protected] },
|
|
50
|
+
budget: { perProjectCap: src.budget?.perProjectCap ?? b.budget.perProjectCap, perJobCap: src.budget?.perJobCap ?? b.budget.perJobCap },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The org's gate with a project's partial one over it. */
|
|
55
|
+
export function effectiveGate(orgGate = null, projectGate = null) {
|
|
56
|
+
const org = normalizeGate(orgGate || {}, { partial: true });
|
|
57
|
+
return projectGate ? normalizeGate(projectGate, { partial: true, base: org }) : org;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The one question the executive loop asks: may a team do `action` on its own?
|
|
62
|
+
* push · merge · publish · budgetRaise · newAgent · newTool · writeBack · recruit
|
|
63
|
+
* Returns `{ allowed, reason }`; a false answer is where the loop asks a person instead.
|
|
64
|
+
*/
|
|
65
|
+
export function gateAllows(gate, action, { branch = null } = {}) {
|
|
66
|
+
const g = normalizeGate(gate || {}, { partial: true });
|
|
67
|
+
if (action === 'push' || action === 'merge') {
|
|
68
|
+
if (branch && g.branches.protected.includes(branch)) return { allowed: false, reason: `${branch} is protected — a person merges` };
|
|
69
|
+
const far = AUTONOMY.indexOf(g.autonomy);
|
|
70
|
+
if (action === 'push' && far < AUTONOMY.indexOf('push')) return { allowed: false, reason: 'the gate allows proposing only' };
|
|
71
|
+
if (action === 'merge' && far < AUTONOMY.indexOf('merge')) return { allowed: false, reason: `the gate allows up to ${g.autonomy}` };
|
|
72
|
+
}
|
|
73
|
+
if (HUMAN_FLAGS.includes(action) && g.human[action]) return { allowed: false, reason: `a person decides ${action}` };
|
|
74
|
+
return { allowed: true, reason: `the gate allows ${action}` };
|
|
75
|
+
}
|
package/src/job.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/job.js — edit there, then copy over.
|
|
2
|
+
// A job — a posting on a project's board that the pool applies to.
|
|
3
|
+
//
|
|
4
|
+
// The executive posts the first jobs; a recruited agent posts more when the work needs more
|
|
5
|
+
// hands, a skill or a tool it does not have; a person posts one by hand. A job says what it
|
|
6
|
+
// needs (skills, tools, grants), what it may cost (carved from the project's budget), and
|
|
7
|
+
// where the work happens (a repo and a base branch — the bridge gives it a worktree). Agents
|
|
8
|
+
// in the pool APPLY by construction (recruit.js scores every type at once); an evaluator
|
|
9
|
+
// picks; the pick is recruited with a budget and the job becomes a role on a run.
|
|
10
|
+
//
|
|
11
|
+
// A job's status is a machine: open → evaluating → recruited → in-progress → done | failed,
|
|
12
|
+
// with withdrawn from any of the first three. Every move is an event on the project's
|
|
13
|
+
// record (project.js foldProject: `job.posted`, `job.updated`).
|
|
14
|
+
|
|
15
|
+
import { validateBudget, normalizeBudget } from './budget.js';
|
|
16
|
+
import { GRANT_RE } from './team.js';
|
|
17
|
+
|
|
18
|
+
export const JOB_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
19
|
+
export const JOB_STATUSES = Object.freeze(['open', 'evaluating', 'recruited', 'in-progress', 'done', 'failed', 'withdrawn']);
|
|
20
|
+
const NEXT = Object.freeze({
|
|
21
|
+
open: ['evaluating', 'recruited', 'withdrawn'], evaluating: ['recruited', 'open', 'withdrawn'], recruited: ['in-progress', 'open', 'withdrawn'],
|
|
22
|
+
'in-progress': ['done', 'failed', 'open'], done: [], failed: ['open'], withdrawn: ['open'],
|
|
23
|
+
});
|
|
24
|
+
export const MAX_NEEDS = 24;
|
|
25
|
+
export const MAX_APPLICATIONS = 64;
|
|
26
|
+
|
|
27
|
+
export class JobError extends Error {
|
|
28
|
+
constructor(code, message) { super(message); this.name = 'JobError'; this.code = code; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
32
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
33
|
+
const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => clip(x, n)).filter(Boolean))].slice(0, max);
|
|
34
|
+
|
|
35
|
+
export function validateJob(j) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
if (!isRecord(j)) return { ok: false, errors: ['job must be an object'] };
|
|
38
|
+
if (!JOB_ID_RE.test(String(j.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
39
|
+
if (!JOB_ID_RE.test(String(j.projectId || ''))) errors.push('projectId: the project this job belongs to');
|
|
40
|
+
if (!clip(j.title, 200)) errors.push('title: what the job is');
|
|
41
|
+
if (!clip(j.brief, 8000)) errors.push('brief: what to do, what done looks like');
|
|
42
|
+
if (j.needs !== undefined) {
|
|
43
|
+
if (!isRecord(j.needs)) errors.push('needs: { skills[], tools[], grants[] }');
|
|
44
|
+
else {
|
|
45
|
+
const badGrants = (Array.isArray(j.needs.grants) ? j.needs.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
46
|
+
if (badGrants.length) errors.push(`needs.grants: not grantable: ${badGrants.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (j.budget !== undefined) { const b = validateBudget(j.budget); if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`)); }
|
|
50
|
+
if (j.status !== undefined && !JOB_STATUSES.includes(j.status)) errors.push(`status: one of ${JOB_STATUSES.join(', ')}`);
|
|
51
|
+
if (j.dependsOn !== undefined && !Array.isArray(j.dependsOn)) errors.push('dependsOn: a list of job ids');
|
|
52
|
+
if (j.workspace !== undefined && j.workspace !== null && !isRecord(j.workspace)) errors.push('workspace: { repoId, base, branch? }');
|
|
53
|
+
if (j.deadline !== undefined && j.deadline !== null && !Number.isFinite(Number(j.deadline))) errors.push('deadline: a time');
|
|
54
|
+
return { ok: errors.length === 0, errors };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeJob(j) {
|
|
58
|
+
const v = validateJob(j);
|
|
59
|
+
if (!v.ok) throw new JobError('INVALID', v.errors.join('; '));
|
|
60
|
+
return {
|
|
61
|
+
id: String(j.id),
|
|
62
|
+
projectId: String(j.projectId),
|
|
63
|
+
title: clip(j.title, 200),
|
|
64
|
+
brief: clip(j.brief, 8000),
|
|
65
|
+
needs: {
|
|
66
|
+
skills: list(j.needs?.skills, 80, MAX_NEEDS),
|
|
67
|
+
tools: list(j.needs?.tools, 120, MAX_NEEDS),
|
|
68
|
+
grants: list(j.needs?.grants, 64, MAX_NEEDS).filter((g) => GRANT_RE.test(g)),
|
|
69
|
+
},
|
|
70
|
+
...(j.budget ? { budget: normalizeBudget(j.budget) } : {}),
|
|
71
|
+
size: { steps: Math.max(0, Math.round(Number(j.size?.steps) || 0)) },
|
|
72
|
+
status: JOB_STATUSES.includes(j.status) ? j.status : 'open',
|
|
73
|
+
postedBy: clip(j.postedBy, 80) || 'person',
|
|
74
|
+
postedAt: Number(j.postedAt) || Date.now(),
|
|
75
|
+
...(j.deadline ? { deadline: Number(j.deadline) } : {}),
|
|
76
|
+
dependsOn: list(j.dependsOn, 64, 32).filter((d) => d !== j.id),
|
|
77
|
+
...(j.workspace ? { workspace: { repoId: clip(j.workspace.repoId, 120), base: clip(j.workspace.base, 120) || 'main', ...(j.workspace.branch ? { branch: clip(j.workspace.branch, 200) } : {}), ...(j.workspace.worktreePath ? { worktreePath: clip(j.workspace.worktreePath, 400) } : {}) } } : {}),
|
|
78
|
+
applications: Array.isArray(j.applications) ? j.applications.slice(0, MAX_APPLICATIONS).map(normalizeApplication).filter(Boolean) : [],
|
|
79
|
+
...(j.recruited ? { recruited: normalizeRecruit(j.recruited) } : {}),
|
|
80
|
+
...(j.runId ? { runId: String(j.runId) } : {}),
|
|
81
|
+
...(j.result ? { result: { text: clip(j.result.text, 8000), by: clip(j.result.by, 80), at: Number(j.result.at) || Date.now(), ...(Array.isArray(j.result.refs) ? { refs: j.result.refs.slice(0, 12) } : {}) } } : {}),
|
|
82
|
+
...(j.origin && isRecord(j.origin) ? { origin: { ...j.origin } } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeApplication(a) {
|
|
87
|
+
if (!isRecord(a) || !a.agentId) return null;
|
|
88
|
+
return { agentId: String(a.agentId), ...(a.engine ? { engine: a.engine } : {}), fit: Math.max(0, Math.min(1, Number(a.fit) || 0)), reasons: Array.isArray(a.reasons) ? a.reasons.map((r) => clip(r, 200)).slice(0, 8) : [], pitch: clip(a.pitch, 600), at: Number(a.at) || Date.now() };
|
|
89
|
+
}
|
|
90
|
+
function normalizeRecruit(r) {
|
|
91
|
+
return { agentId: String(r.agentId), ...(r.engine ? { engine: r.engine } : {}), ...(r.budget ? { budget: normalizeBudget(r.budget) } : {}), by: clip(r.by, 80) || 'evaluator', at: Number(r.at) || Date.now(), ...(r.why ? { why: clip(r.why, 600) } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
|
|
95
|
+
|
|
96
|
+
/** May the job move from `from` to `to`? */
|
|
97
|
+
export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
|
|
98
|
+
|
|
99
|
+
/** Applications are computed, not asked for: every eligible type in the pool applies at once. */
|
|
100
|
+
export function applyAll(job, pool, fitFn, { cards = {} } = {}) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
return (pool || [])
|
|
103
|
+
.filter((a) => a && a.enabled !== false && (a.appliesTo || ['jobs']).includes('jobs'))
|
|
104
|
+
.map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
|
|
105
|
+
.sort((x, y) => y.fit - x.fit)
|
|
106
|
+
.slice(0, MAX_APPLICATIONS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A recruited job as the role a run gives the agent: the brief is the prompt, the needs are the grants. */
|
|
110
|
+
export function jobToRole(job, agent) {
|
|
111
|
+
return {
|
|
112
|
+
id: agent?.id || job.id,
|
|
113
|
+
agent: agent?.id,
|
|
114
|
+
name: agent?.name || job.title,
|
|
115
|
+
prompt: [agent?.prompt, `Job: ${job.title}\n\n${job.brief}`].filter(Boolean).join('\n\n'),
|
|
116
|
+
grants: (job.needs?.grants?.length ? job.needs.grants : agent?.grants) || ['none'],
|
|
117
|
+
...(job.dependsOn?.length ? { dependsOn: job.dependsOn } : {}),
|
|
118
|
+
...(job.workspace ? { workspace: job.workspace } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The jobs that may run now: every dependency done, and not already taken. */
|
|
123
|
+
export function readyJobs(jobs) {
|
|
124
|
+
const byId = new Map((jobs || []).map((j) => [j.id, j]));
|
|
125
|
+
return (jobs || []).filter((j) => j.status === 'open' && (j.dependsOn || []).every((d) => byId.get(d)?.status === 'done'));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A blank posting for the form. */
|
|
129
|
+
export function blankJob(projectId = '') {
|
|
130
|
+
return { id: '', projectId, title: '', brief: '', needs: { skills: [], tools: [], grants: [] }, budget: { tokens: 40000, ms: 900000 }, status: 'open', dependsOn: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function jobFromForm(form) {
|
|
134
|
+
const budget = {};
|
|
135
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
136
|
+
const v = Number(form?.budget?.[k]);
|
|
137
|
+
if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
|
|
138
|
+
}
|
|
139
|
+
const j = {
|
|
140
|
+
id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
|
|
141
|
+
projectId: form?.projectId, title: form?.title, brief: form?.brief,
|
|
142
|
+
needs: { skills: form?.needs?.skills, tools: form?.needs?.tools, grants: form?.needs?.grants },
|
|
143
|
+
...(Object.keys(budget).length ? { budget } : {}),
|
|
144
|
+
status: form?.status || 'open', postedBy: form?.postedBy || 'person', dependsOn: form?.dependsOn,
|
|
145
|
+
...(form?.workspace?.repoId ? { workspace: form.workspace } : {}), ...(form?.size ? { size: form.size } : {}),
|
|
146
|
+
};
|
|
147
|
+
const v = validateJob(j);
|
|
148
|
+
return v.ok ? { ok: true, job: normalizeJob(j) } : { ok: false, errors: v.errors };
|
|
149
|
+
}
|