@chatpanel/gateway 0.6.87 → 0.6.91
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/agent.js +250 -0
- 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 +150 -0
- package/src/model-ledger.js +229 -0
- package/src/project-store.js +139 -0
- package/src/project.js +171 -0
- package/src/recruit.js +425 -0
- package/src/recruiting.js +93 -0
- package/src/scorecard-store.js +1 -1
- package/src/scorecard.js +148 -4
- package/src/server.js +156 -9
- package/src/team-store.js +4 -1
- package/src/team.js +303 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The projects — every goal's page and everything done for it, as the record both clients
|
|
2
|
+
// read (F8 §12). The page itself is data in the shared `projects` prefs section; this store
|
|
3
|
+
// holds the RECORD: the jobs posted on it, who applied and who was recruited, the runs it
|
|
4
|
+
// spawned and what they spent, the stakeholder's decisions, the report. Folded from events
|
|
5
|
+
// with the shared fold (project.js `foldProject`, vendored), so the desktop, the extension
|
|
6
|
+
// and this store never disagree on what a project is.
|
|
7
|
+
//
|
|
8
|
+
// Encrypted at rest with the team store's key, like the runs. Events append; nothing is
|
|
9
|
+
// edited; a watcher gets each event as it lands (the live project board).
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
15
|
+
import { emptyProjectRecord, foldProject, projectProgress, PROJECT_ID_RE } from './project.js';
|
|
16
|
+
import { normalizeJob, canTransition as canJobTransition } from './job.js';
|
|
17
|
+
|
|
18
|
+
const DIR = join(os.homedir(), '.chatpanel');
|
|
19
|
+
const STORE_PATH = process.env.CHATPANEL_PROJECTS_STORE || join(DIR, 'projects.enc');
|
|
20
|
+
const MAX_EVENTS_PER_PROJECT = 20_000;
|
|
21
|
+
const MAX_EVENT_BYTES = 64 * 1024;
|
|
22
|
+
|
|
23
|
+
function encrypt(key, buf) {
|
|
24
|
+
const iv = randomBytes(12);
|
|
25
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
26
|
+
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
27
|
+
return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
|
28
|
+
}
|
|
29
|
+
function decrypt(key, env) {
|
|
30
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
31
|
+
d.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
32
|
+
return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
|
|
33
|
+
}
|
|
34
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
35
|
+
|
|
36
|
+
export class ProjectStore {
|
|
37
|
+
constructor({ storePath = STORE_PATH, key = null, now = () => Date.now() } = {}) {
|
|
38
|
+
this.path = storePath;
|
|
39
|
+
this.key = key;
|
|
40
|
+
this.now = now;
|
|
41
|
+
this.projects = new Map(); // id -> { ...record, events: [] }
|
|
42
|
+
this.watchers = new Map(); // id -> Set<fn(ev)>
|
|
43
|
+
}
|
|
44
|
+
load() {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(this.path) && this.key) {
|
|
47
|
+
const env = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
48
|
+
const doc = JSON.parse(decrypt(this.key, env).toString('utf8'));
|
|
49
|
+
for (const p of Array.isArray(doc?.projects) ? doc.projects : []) if (p?.id) this.projects.set(p.id, p);
|
|
50
|
+
}
|
|
51
|
+
} catch { this.projects = new Map(); }
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
save() {
|
|
55
|
+
if (!this.key) return;
|
|
56
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
57
|
+
const env = encrypt(this.key, Buffer.from(JSON.stringify({ v: 1, projects: [...this.projects.values()] }), 'utf8'));
|
|
58
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
|
|
60
|
+
renameSync(tmp, this.path);
|
|
61
|
+
}
|
|
62
|
+
_view(rec, { events = false } = {}) {
|
|
63
|
+
const v = clone({ ...rec, events: undefined });
|
|
64
|
+
delete v.events;
|
|
65
|
+
v.progress = projectProgress(v);
|
|
66
|
+
if (events) v.events = clone(rec.events);
|
|
67
|
+
return v;
|
|
68
|
+
}
|
|
69
|
+
/** Open a record for a page (idempotent: a page saved twice is one record). */
|
|
70
|
+
create({ id, project = null, by = 'person' } = {}) {
|
|
71
|
+
if (!PROJECT_ID_RE.test(String(id || ''))) throw new Error('project id: a short identifier');
|
|
72
|
+
let rec = this.projects.get(id);
|
|
73
|
+
if (!rec) { rec = { ...emptyProjectRecord({ id, now: this.now() }), events: [] }; this.projects.set(id, rec); }
|
|
74
|
+
if (project) this.append(id, [{ type: rec.page ? 'project.updated' : 'project.created', at: this.now(), project: { ...project, id }, by }]);
|
|
75
|
+
else this.save();
|
|
76
|
+
return this._view(rec);
|
|
77
|
+
}
|
|
78
|
+
append(id, events) {
|
|
79
|
+
const rec = this.projects.get(String(id || ''));
|
|
80
|
+
if (!rec) throw new Error(`no project ${id}`);
|
|
81
|
+
const list = Array.isArray(events) ? events : [events];
|
|
82
|
+
let seq = rec.events.length;
|
|
83
|
+
for (const e of list) {
|
|
84
|
+
if (!e || typeof e !== 'object' || !e.type) continue;
|
|
85
|
+
let bytes; try { bytes = Buffer.byteLength(JSON.stringify(e), 'utf8'); } catch { continue; }
|
|
86
|
+
if (bytes > MAX_EVENT_BYTES) continue;
|
|
87
|
+
if (rec.events.length >= MAX_EVENTS_PER_PROJECT) break;
|
|
88
|
+
const { type, at, ...payload } = e;
|
|
89
|
+
const ev = { seq: seq++, type: String(type), at: Number(at) || this.now(), payload };
|
|
90
|
+
rec.events.push(ev);
|
|
91
|
+
foldProject(rec, ev);
|
|
92
|
+
for (const fn of this.watchers.get(rec.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
|
|
93
|
+
}
|
|
94
|
+
this.save();
|
|
95
|
+
return this._view(rec);
|
|
96
|
+
}
|
|
97
|
+
get(id, opts) { const r = this.projects.get(String(id || '')); return r ? this._view(r, opts) : null; }
|
|
98
|
+
/** Newest activity first; the list a board shows — jobs counted, not listed. */
|
|
99
|
+
list({ limit = 50, status = '' } = {}) {
|
|
100
|
+
return [...this.projects.values()]
|
|
101
|
+
.filter((r) => !status || r.status === status)
|
|
102
|
+
.sort((a, b) => b.lastEventAt - a.lastEventAt)
|
|
103
|
+
.slice(0, Math.max(1, Math.min(200, Number(limit) || 50)))
|
|
104
|
+
.map((r) => { const v = this._view(r); return { ...v, jobs: undefined, decisions: undefined, report: undefined, runs: undefined, jobCount: r.jobs.length, runCount: r.runs.length }; });
|
|
105
|
+
}
|
|
106
|
+
/** Every open posting across projects — the job board. */
|
|
107
|
+
openJobs() {
|
|
108
|
+
return [...this.projects.values()].flatMap((r) => r.jobs.filter((j) => ['open', 'evaluating', 'recruited'].includes(j.status)).map((j) => ({ ...j, projectTitle: r.page?.title || r.id })));
|
|
109
|
+
}
|
|
110
|
+
/** Post a job on a project: validated as a posting, appended as an event. */
|
|
111
|
+
postJob(id, job, { by = 'person' } = {}) {
|
|
112
|
+
const rec = this.projects.get(String(id || ''));
|
|
113
|
+
if (!rec) throw new Error(`no project ${id}`);
|
|
114
|
+
const j = normalizeJob({ ...job, projectId: rec.id, postedBy: job?.postedBy || by });
|
|
115
|
+
if (rec.jobs.some((x) => x.id === j.id)) throw new Error(`job ${j.id} already exists`);
|
|
116
|
+
return this.append(rec.id, [{ type: 'job.posted', at: this.now(), job: j, by }]);
|
|
117
|
+
}
|
|
118
|
+
/** Move a job along its machine, or update its fields (applications, recruited, result). */
|
|
119
|
+
updateJob(id, jobId, patch, { by = 'person' } = {}) {
|
|
120
|
+
const rec = this.projects.get(String(id || ''));
|
|
121
|
+
if (!rec) throw new Error(`no project ${id}`);
|
|
122
|
+
const cur = rec.jobs.find((x) => x.id === jobId);
|
|
123
|
+
if (!cur) throw new Error(`no job ${jobId}`);
|
|
124
|
+
if (patch?.status && patch.status !== cur.status && !canJobTransition(cur.status, patch.status)) throw new Error(`a job cannot go from ${cur.status} to ${patch.status}`);
|
|
125
|
+
const allowed = ['status', 'applications', 'recruited', 'runId', 'result', 'workspace', 'budget', 'brief', 'title', 'needs', 'dependsOn'];
|
|
126
|
+
const job = { id: jobId };
|
|
127
|
+
for (const k of allowed) if (patch?.[k] !== undefined) job[k] = patch[k];
|
|
128
|
+
return this.append(rec.id, [{ type: 'job.updated', at: this.now(), job, by }]);
|
|
129
|
+
}
|
|
130
|
+
watch(id, fn) {
|
|
131
|
+
const set = this.watchers.get(String(id)) || new Set();
|
|
132
|
+
set.add(fn); this.watchers.set(String(id), set);
|
|
133
|
+
return () => { set.delete(fn); };
|
|
134
|
+
}
|
|
135
|
+
eventsSince(id, after = -1) { const r = this.projects.get(String(id || '')); return r ? r.events.filter((e) => e.seq > after) : []; }
|
|
136
|
+
remove(id) { const had = this.projects.delete(String(id || '')); if (had) this.save(); return had; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createProjectStore(opts) { return new ProjectStore(opts).load(); }
|
package/src/project.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/project.js — edit there, then copy over.
|
|
2
|
+
// A project — the page a goal starts on, and the record that folds everything done for it.
|
|
3
|
+
//
|
|
4
|
+
// Every job or goal starts here (F8 §12): the goal, defined by its stakeholder — the *chief
|
|
5
|
+
// executive*: a person by default, an agent from the pool when the person delegates it —
|
|
6
|
+
// with a done-when a run can be checked against, a budget the jobs are carved from, the
|
|
7
|
+
// repos the work happens in, and the gate that says how far a team may go without a person.
|
|
8
|
+
// A project is data (a `projects` prefs section, both clients) and a gateway record that
|
|
9
|
+
// folds its jobs, runs and spend from events, the way a run folds (team-record.js).
|
|
10
|
+
//
|
|
11
|
+
// A project's status is a small machine: draft (a goal being written) → open (jobs may be
|
|
12
|
+
// posted) → active (a job was recruited) → done (done-when held, a person closed it) or
|
|
13
|
+
// closed (abandoned). Nothing here runs anything: project-run.js is the executive loop.
|
|
14
|
+
|
|
15
|
+
import { validateBudget, normalizeBudget } from './budget.js';
|
|
16
|
+
import { normalizeGate, validateGate } from './gate.js';
|
|
17
|
+
|
|
18
|
+
export const PROJECT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
19
|
+
export const PROJECT_STATUSES = Object.freeze(['draft', 'open', 'active', 'done', 'closed']);
|
|
20
|
+
const NEXT = Object.freeze({ draft: ['open', 'closed'], open: ['active', 'closed', 'draft'], active: ['done', 'closed', 'open'], done: ['closed', 'open'], closed: ['open'] });
|
|
21
|
+
export const MAX_REPOS = 16;
|
|
22
|
+
|
|
23
|
+
export class ProjectError extends Error {
|
|
24
|
+
constructor(code, message) { super(message); this.name = 'ProjectError'; this.code = code; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
29
|
+
|
|
30
|
+
export function validateProject(p) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (!isRecord(p)) return { ok: false, errors: ['project must be an object'] };
|
|
33
|
+
if (!PROJECT_ID_RE.test(String(p.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
34
|
+
if (!clip(p.title, 200)) errors.push('title: what the project is called');
|
|
35
|
+
if (!clip(p.goal, 4000)) errors.push('goal: what done looks like, in the stakeholder\'s words');
|
|
36
|
+
if (p.doneWhen !== undefined && !clip(p.doneWhen, 2000)) errors.push('doneWhen: a check a run can be held to, or leave it out');
|
|
37
|
+
if (p.stakeholder !== undefined && p.stakeholder !== 'person' && !/^[a-z][a-z0-9_-]{0,63}$/i.test(String(p.stakeholder))) errors.push('stakeholder: "person" or an agent id');
|
|
38
|
+
if (p.status !== undefined && !PROJECT_STATUSES.includes(p.status)) errors.push(`status: one of ${PROJECT_STATUSES.join(', ')}`);
|
|
39
|
+
const b = validateBudget(p.budget);
|
|
40
|
+
if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`));
|
|
41
|
+
if (p.repos !== undefined) {
|
|
42
|
+
if (!Array.isArray(p.repos)) errors.push('repos: a list of repo ids');
|
|
43
|
+
else if (p.repos.length > MAX_REPOS) errors.push(`repos: at most ${MAX_REPOS}`);
|
|
44
|
+
}
|
|
45
|
+
if (p.gate !== undefined && p.gate !== null) errors.push(...validateGate(p.gate, { partial: true }).errors.map((e) => `gate: ${e}`));
|
|
46
|
+
return { ok: errors.length === 0, errors };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The stored form: defaults filled, the budget normalised, the gate (if any) normalised. */
|
|
50
|
+
export function normalizeProject(p) {
|
|
51
|
+
const v = validateProject(p);
|
|
52
|
+
if (!v.ok) throw new ProjectError('INVALID', v.errors.join('; '));
|
|
53
|
+
return {
|
|
54
|
+
id: String(p.id),
|
|
55
|
+
title: clip(p.title, 200),
|
|
56
|
+
goal: clip(p.goal, 4000),
|
|
57
|
+
doneWhen: clip(p.doneWhen, 2000),
|
|
58
|
+
stakeholder: p.stakeholder ? String(p.stakeholder) : 'person',
|
|
59
|
+
budget: normalizeBudget(p.budget),
|
|
60
|
+
status: PROJECT_STATUSES.includes(p.status) ? p.status : 'draft',
|
|
61
|
+
repos: Array.isArray(p.repos) ? [...new Set(p.repos.map((r) => clip(r, 120)).filter(Boolean))].slice(0, MAX_REPOS) : [],
|
|
62
|
+
...(p.gate ? { gate: normalizeGate(p.gate, { partial: true }) } : {}),
|
|
63
|
+
tags: Array.isArray(p.tags) ? [...new Set(p.tags.map((t) => clip(t, 40)).filter(Boolean))].slice(0, 12) : [],
|
|
64
|
+
createdBy: clip(p.createdBy, 80) || 'person',
|
|
65
|
+
createdAt: Number(p.createdAt) || Date.now(),
|
|
66
|
+
...(p.updatedAt ? { updatedAt: Number(p.updatedAt) } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function defineProject(p) { return Object.freeze(normalizeProject(p)); }
|
|
71
|
+
|
|
72
|
+
/** May the project move from `from` to `to`? The machine above; a person's close is always allowed. */
|
|
73
|
+
export function canTransition(from, to) {
|
|
74
|
+
return (NEXT[from] || []).includes(to);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A blank page for the form. */
|
|
78
|
+
export function blankProject() {
|
|
79
|
+
return { id: '', title: '', goal: '', doneWhen: '', stakeholder: 'person', budget: { tokens: 200000, ms: 3600000 }, status: 'draft', repos: [], tags: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The form → a project, or the errors (the same shaping both clients use). */
|
|
83
|
+
export function projectFromForm(form) {
|
|
84
|
+
const budget = {};
|
|
85
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
86
|
+
const v = Number(form?.budget?.[k]);
|
|
87
|
+
if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
|
|
88
|
+
}
|
|
89
|
+
const p = {
|
|
90
|
+
id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
|
|
91
|
+
title: form?.title, goal: form?.goal, doneWhen: form?.doneWhen, stakeholder: form?.stakeholder || 'person',
|
|
92
|
+
budget, status: form?.status || 'draft',
|
|
93
|
+
repos: String(Array.isArray(form?.repos) ? form.repos.join(',') : form?.repos || '').split(/[,\s]+/).filter(Boolean),
|
|
94
|
+
tags: String(Array.isArray(form?.tags) ? form.tags.join(',') : form?.tags || '').split(/[,\s]+/).filter(Boolean),
|
|
95
|
+
...(form?.gate ? { gate: form.gate } : {}), ...(form?.createdAt ? { createdAt: form.createdAt } : {}),
|
|
96
|
+
};
|
|
97
|
+
const v = validateProject(p);
|
|
98
|
+
return v.ok ? { ok: true, project: normalizeProject(p) } : { ok: false, errors: v.errors };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── the record: a project folded from its events ────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** The empty record — what the gateway holds per project and both clients read. */
|
|
104
|
+
export function emptyProjectRecord({ id, now = Date.now() } = {}) {
|
|
105
|
+
return { id, page: null, status: 'draft', jobs: [], runs: [], spend: { tokens: 0, calls: 0, usd: 0, ms: 0 }, decisions: [], report: null, createdAt: now, lastEventAt: now };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fold one event into the record. Events: `project.created` `{ project }` · `project.updated`
|
|
110
|
+
* `{ project }` · `project.status` `{ status, by, note }` · `job.posted` `{ job }` ·
|
|
111
|
+
* `job.updated` `{ job }` (any field: applications, recruited, status) · `run.linked`
|
|
112
|
+
* `{ runId, jobId }` · `run.spent` `{ runId, spent }` · `project.decision` `{ by, kind, text,
|
|
113
|
+
* refs }` · `project.report` `{ text, by }`. Idempotent by job id and run id.
|
|
114
|
+
*/
|
|
115
|
+
export function foldProject(rec, ev) {
|
|
116
|
+
const type = String(ev?.type || '');
|
|
117
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
|
|
118
|
+
const at = Number(ev?.at) || Date.now();
|
|
119
|
+
rec.lastEventAt = at;
|
|
120
|
+
switch (type) {
|
|
121
|
+
case 'project.created':
|
|
122
|
+
case 'project.updated':
|
|
123
|
+
if (p.project && typeof p.project === 'object') { rec.page = { ...p.project, updatedAt: at }; rec.status = p.project.status || rec.status; }
|
|
124
|
+
break;
|
|
125
|
+
case 'project.status':
|
|
126
|
+
if (PROJECT_STATUSES.includes(p.status)) { rec.status = p.status; if (rec.page) rec.page.status = p.status; rec.decisions.push({ at, by: p.by || 'person', kind: 'status', text: `${p.status}${p.note ? ` — ${p.note}` : ''}` }); }
|
|
127
|
+
break;
|
|
128
|
+
case 'job.posted':
|
|
129
|
+
if (p.job?.id && !rec.jobs.some((j) => j.id === p.job.id)) rec.jobs.push({ ...p.job, postedAt: at });
|
|
130
|
+
if (rec.status === 'draft') rec.status = 'open';
|
|
131
|
+
break;
|
|
132
|
+
case 'job.updated': {
|
|
133
|
+
const i = rec.jobs.findIndex((j) => j.id === p.job?.id);
|
|
134
|
+
if (i >= 0) rec.jobs[i] = { ...rec.jobs[i], ...p.job, updatedAt: at };
|
|
135
|
+
if (p.job?.status === 'recruited' || p.job?.status === 'in-progress') { if (rec.status === 'open' || rec.status === 'draft') rec.status = 'active'; }
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'run.linked':
|
|
139
|
+
if (p.runId && !rec.runs.some((r) => r.runId === p.runId)) rec.runs.push({ runId: p.runId, jobId: p.jobId || null, at });
|
|
140
|
+
break;
|
|
141
|
+
case 'run.spent': {
|
|
142
|
+
const r = rec.runs.find((x) => x.runId === p.runId);
|
|
143
|
+
const prev = r?.spent || { tokens: 0, calls: 0, usd: 0, ms: 0 };
|
|
144
|
+
const next = { tokens: Number(p.spent?.tokens) || 0, calls: Number(p.spent?.calls) || 0, usd: Number(p.spent?.usd) || 0, ms: Number(p.spent?.ms) || 0 };
|
|
145
|
+
// A run reports its running total; the project's spend is the sum of every run's latest.
|
|
146
|
+
for (const k of Object.keys(next)) rec.spend[k] = Math.max(0, (rec.spend[k] || 0) - (prev[k] || 0) + next[k]);
|
|
147
|
+
if (r) r.spent = next; else rec.runs.push({ runId: p.runId, jobId: null, at, spent: next });
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'project.decision':
|
|
151
|
+
rec.decisions.push({ at, by: p.by || 'person', kind: p.kind || 'note', text: String(p.text || '').slice(0, 2000), refs: Array.isArray(p.refs) ? p.refs.slice(0, 8) : [] });
|
|
152
|
+
break;
|
|
153
|
+
case 'project.report':
|
|
154
|
+
rec.report = { text: String(p.text || ''), by: p.by || 'runner', at };
|
|
155
|
+
break;
|
|
156
|
+
default: break;
|
|
157
|
+
}
|
|
158
|
+
return rec;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** How far along: jobs by status, spend against the budget, whether done-when is claimed. */
|
|
162
|
+
export function projectProgress(rec) {
|
|
163
|
+
const by = {};
|
|
164
|
+
for (const j of rec?.jobs || []) by[j.status] = (by[j.status] || 0) + 1;
|
|
165
|
+
const cap = rec?.page?.budget || {};
|
|
166
|
+
const spend = rec?.spend || {};
|
|
167
|
+
const pct = cap.tokens ? Math.min(1, (spend.tokens || 0) / cap.tokens) : cap.usd ? Math.min(1, (spend.usd || 0) / cap.usd) : cap.ms ? Math.min(1, (spend.ms || 0) / cap.ms) : null;
|
|
168
|
+
const total = (rec?.jobs || []).length;
|
|
169
|
+
const done = by.done || 0;
|
|
170
|
+
return { jobs: { total, by }, done, open: (by.open || 0) + (by.evaluating || 0) + (by.recruited || 0) + (by['in-progress'] || 0), spend, cap, budgetUsed: pct, hasReport: !!rec?.report, status: rec?.status || 'draft' };
|
|
171
|
+
}
|