@chatpanel/events 0.101.0 → 0.103.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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -348
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/invariants.js
CHANGED
|
@@ -1,174 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// These are what make the claims CHECKABLE rather than aspirational. An unverified
|
|
4
|
-
// invariant decays silently, so each of these becomes a CI assertion the way the
|
|
5
|
-
// `sync:pii --check` drift guard and the CSP assertion in build-editor.mjs already are.
|
|
6
|
-
//
|
|
7
|
-
// I1 every model request is preceded by a context.assembled that reconstructs it
|
|
8
|
-
// I2 every byte leaving the device has a privacy.egress (controlled:false for agents)
|
|
9
|
-
// I3 every non-pure capability.invoked carries an idempotency key
|
|
10
|
-
// I4 every capability.revoked names its activated in causes
|
|
11
|
-
// I5 no durable event exists for a per-item ephemeral stream
|
|
12
|
-
// I6 replay under the linearization rule is stable across runs and across hosts
|
|
13
|
-
// I7 no assistant event carries content — only a Ref to it
|
|
14
|
-
//
|
|
15
|
-
// checkInvariants() returns violations rather than throwing, so a harness can report
|
|
16
|
-
// every problem in one pass instead of one per run.
|
|
17
|
-
|
|
18
|
-
import { linearize } from './order.js';
|
|
19
|
-
import { isRef } from './ref.js';
|
|
20
|
-
|
|
21
|
-
const v = (id, event, message) => ({ invariant: id, eventId: event ? event.id : null, message });
|
|
22
|
-
|
|
23
|
-
/** I1 — a turn that ran must have assembled its context, and every resident Ref is hashed. */
|
|
24
|
-
/**
|
|
25
|
-
* I7 — an assistant event names its content, it never contains it.
|
|
26
|
-
*
|
|
27
|
-
* The temptation is obvious: inlining the text makes the trajectory view trivial. It also
|
|
28
|
-
* makes the log a second copy of every conversation, so exporting the log exports the
|
|
29
|
-
* user's data, and shredding a message leaves it readable in an append-only file forever.
|
|
30
|
-
* Structural, not advisory: the checker fails on any payload field holding a long string,
|
|
31
|
-
* because the failure it prevents is silent — everything keeps working, and privacy is
|
|
32
|
-
* quietly gone.
|
|
33
|
-
*/
|
|
34
|
-
function checkI7(events) {
|
|
35
|
-
const out = [];
|
|
36
|
-
const MAX = 200; // a model, a stop reason, a hash — never a message
|
|
37
|
-
for (const e of events) {
|
|
38
|
-
if (!String(e.type).startsWith('assistant.')) continue;
|
|
39
|
-
if (!isRef(e.payload?.ref)) out.push(v('I7', e, 'assistant event has no content Ref'));
|
|
40
|
-
for (const [k, val] of Object.entries(e.payload || {})) {
|
|
41
|
-
if (k !== 'ref' && typeof val === 'string' && val.length > MAX) {
|
|
42
|
-
out.push(v('I7', e, `payload.${k} carries ${val.length} chars of content — assistant events must reference, never contain`));
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function checkI1(events) {
|
|
50
|
-
const out = [];
|
|
51
|
-
const assembledByTurn = new Set();
|
|
52
|
-
for (const e of events) {
|
|
53
|
-
if (e.type === 'context.assembled') {
|
|
54
|
-
if (e.payload.turnId) assembledByTurn.add(e.payload.turnId);
|
|
55
|
-
for (const r of e.payload.resident) {
|
|
56
|
-
if (!isRef(r)) out.push(v('I1', e, 'resident entry is not a valid Ref'));
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
for (const e of events) {
|
|
61
|
-
if (e.type === 'turn.ended' && e.payload.stepped !== false && !assembledByTurn.has(e.payload.turnId)) {
|
|
62
|
-
out.push(v('I1', e, `turn ${e.payload.turnId} ended with no context.assembled — model-visible input is not reconstructable`));
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** I2 — an invocation that egresses must produce a privacy.egress caused by it. */
|
|
69
|
-
function checkI2(events) {
|
|
70
|
-
const out = [];
|
|
71
|
-
const egressCauses = new Set();
|
|
72
|
-
for (const e of events) {
|
|
73
|
-
if (e.type === 'privacy.egress') for (const c of e.causes) egressCauses.add(c);
|
|
74
|
-
}
|
|
75
|
-
for (const e of events) {
|
|
76
|
-
if (e.type === 'capability.invoked' && e.payload.egress && e.payload.egress !== 'none') {
|
|
77
|
-
if (!egressCauses.has(e.id)) {
|
|
78
|
-
out.push(v('I2', e, `invocation declares egress '${e.payload.egress}' but no privacy.egress references it`));
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return out;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** I3 — non-pure invocations carry an idempotency key. (Also enforced by validateEvent.) */
|
|
86
|
-
function checkI3(events) {
|
|
87
|
-
return events
|
|
88
|
-
.filter((e) => e.type === 'capability.invoked'
|
|
89
|
-
&& e.payload.effects !== 'pure'
|
|
90
|
-
&& !e.payload.idempotencyKey)
|
|
91
|
-
.map((e) => v('I3', e, `'${e.payload.effects}' invocation without an idempotencyKey — a retry would repeat the side effect`));
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** I4 — a revoke names the activation it undoes. */
|
|
95
|
-
function checkI4(events) {
|
|
96
|
-
const activated = new Map();
|
|
97
|
-
for (const e of events) if (e.type === 'capability.activated') activated.set(e.id, e);
|
|
98
|
-
return events
|
|
99
|
-
.filter((e) => e.type === 'capability.revoked' && !e.causes.some((c) => activated.has(c)))
|
|
100
|
-
.map((e) => v('I4', e, 'revoked does not name its activated in causes — the effect has no recorded inverse'));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** I5 — ephemeral stream items never become durable facts. */
|
|
104
|
-
function checkI5(events, { ephemeralBudget = 200 } = {}) {
|
|
105
|
-
const out = [];
|
|
106
|
-
const perTypePerHost = new Map();
|
|
107
|
-
for (const e of events) {
|
|
108
|
-
const k = `${e.host}:${e.type}:${e.payload.streamId || ''}`;
|
|
109
|
-
perTypePerHost.set(k, (perTypePerHost.get(k) || 0) + 1);
|
|
110
|
-
}
|
|
111
|
-
for (const [k, n] of perTypePerHost) {
|
|
112
|
-
if (k.split(':')[2] && n > ephemeralBudget) {
|
|
113
|
-
out.push(v('I5', null, `${n} durable events for stream ${k} — per-item stream data must be windowed, not logged`));
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return out;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** I6 — linearization is a function of the event SET, not of the input array's order. */
|
|
120
|
-
function checkI6(events, { shuffles = 8, rng = mulberry32(0x5EED) } = {}) {
|
|
121
|
-
if (events.length < 2) return [];
|
|
122
|
-
const base = linearize(events).map((e) => e.id).join(',');
|
|
123
|
-
for (let i = 0; i < shuffles; i++) {
|
|
124
|
-
const shuffled = shuffle(events, rng);
|
|
125
|
-
if (linearize(shuffled).map((e) => e.id).join(',') !== base) {
|
|
126
|
-
return [v('I6', null, 'linearize() is order-dependent — replay is not deterministic')];
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return [];
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Run every invariant over an event set. Returns [] when the log is sound. */
|
|
133
|
-
export function checkInvariants(events, opts = {}) {
|
|
134
|
-
return [
|
|
135
|
-
...checkI7(events),
|
|
136
|
-
...checkI1(events),
|
|
137
|
-
...checkI2(events),
|
|
138
|
-
...checkI3(events),
|
|
139
|
-
...checkI4(events),
|
|
140
|
-
...checkI5(events, opts),
|
|
141
|
-
...checkI6(events, opts),
|
|
142
|
-
];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export const INVARIANTS = Object.freeze({
|
|
146
|
-
I1: 'model-visible input is reconstructable from the log',
|
|
147
|
-
I2: 'every egress is recorded',
|
|
148
|
-
I3: 'non-pure invocations are idempotent',
|
|
149
|
-
I4: 'every activation has a recorded inverse',
|
|
150
|
-
I5: 'ephemeral streams never become durable facts',
|
|
151
|
-
I6: 'replay is deterministic',
|
|
152
|
-
I7: 'assistant events reference content, never contain it',
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// Seeded PRNG so a failing I6 reproduces exactly — the package holds no ambient
|
|
156
|
-
// dependency on Math.random.
|
|
157
|
-
function mulberry32(seed) {
|
|
158
|
-
let a = seed >>> 0;
|
|
159
|
-
return () => {
|
|
160
|
-
a = (a + 0x6D2B79F5) >>> 0;
|
|
161
|
-
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
162
|
-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
163
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function shuffle(list, rng) {
|
|
168
|
-
const a = [...list];
|
|
169
|
-
for (let i = a.length - 1; i > 0; i--) {
|
|
170
|
-
const j = Math.floor(rng() * (i + 1));
|
|
171
|
-
[a[i], a[j]] = [a[j], a[i]];
|
|
172
|
-
}
|
|
173
|
-
return a;
|
|
174
|
-
}
|
|
1
|
+
import{linearize as i}from"./order.js";import{isRef as c}from"./ref.js";const s=(o,t,n)=>({invariant:o,eventId:t?t.id:null,message:n});function d(o){const t=[];for(const e of o)if(String(e.type).startsWith("assistant.")){c(e.payload?.ref)||t.push(s("I7",e,"assistant event has no content Ref"));for(const[r,a]of Object.entries(e.payload||{}))r!=="ref"&&typeof a=="string"&&a.length>200&&t.push(s("I7",e,`payload.${r} carries ${a.length} chars of content — assistant events must reference, never contain`))}return t}function l(o){const t=[],n=new Set;for(const e of o)if(e.type==="context.assembled"){e.payload.turnId&&n.add(e.payload.turnId);for(const r of e.payload.resident)c(r)||t.push(s("I1",e,"resident entry is not a valid Ref"))}for(const e of o)e.type==="turn.ended"&&e.payload.stepped!==!1&&!n.has(e.payload.turnId)&&t.push(s("I1",e,`turn ${e.payload.turnId} ended with no context.assembled — model-visible input is not reconstructable`));return t}function u(o){const t=[],n=new Set;for(const e of o)if(e.type==="privacy.egress")for(const r of e.causes)n.add(r);for(const e of o)e.type==="capability.invoked"&&e.payload.egress&&e.payload.egress!=="none"&&(n.has(e.id)||t.push(s("I2",e,`invocation declares egress '${e.payload.egress}' but no privacy.egress references it`)));return t}function p(o){return o.filter(t=>t.type==="capability.invoked"&&t.payload.effects!=="pure"&&!t.payload.idempotencyKey).map(t=>s("I3",t,`'${t.payload.effects}' invocation without an idempotencyKey — a retry would repeat the side effect`))}function y(o){const t=new Map;for(const n of o)n.type==="capability.activated"&&t.set(n.id,n);return o.filter(n=>n.type==="capability.revoked"&&!n.causes.some(e=>t.has(e))).map(n=>s("I4",n,"revoked does not name its activated in causes — the effect has no recorded inverse"))}function h(o,{ephemeralBudget:t=200}={}){const n=[],e=new Map;for(const r of o){const a=`${r.host}:${r.type}:${r.payload.streamId||""}`;e.set(a,(e.get(a)||0)+1)}for(const[r,a]of e)r.split(":")[2]&&a>t&&n.push(s("I5",null,`${a} durable events for stream ${r} — per-item stream data must be windowed, not logged`));return n}function m(o,{shuffles:t=8,rng:n=I(24301)}={}){if(o.length<2)return[];const e=i(o).map(r=>r.id).join(",");for(let r=0;r<t;r++){const a=b(o,n);if(i(a).map(f=>f.id).join(",")!==e)return[s("I6",null,"linearize() is order-dependent — replay is not deterministic")]}return[]}function k(o,t={}){return[...d(o),...l(o),...u(o),...p(o),...y(o),...h(o,t),...m(o,t)]}const $=Object.freeze({I1:"model-visible input is reconstructable from the log",I2:"every egress is recorded",I3:"non-pure invocations are idempotent",I4:"every activation has a recorded inverse",I5:"ephemeral streams never become durable facts",I6:"replay is deterministic",I7:"assistant events reference content, never contain it"});function I(o){let t=o>>>0;return()=>{t=t+1831565813>>>0;let n=Math.imul(t^t>>>15,1|t);return n=n+Math.imul(n^n>>>7,61|n)^n,((n^n>>>14)>>>0)/4294967296}}function b(o,t){const n=[...o];for(let e=n.length-1;e>0;e--){const r=Math.floor(t()*(e+1));[n[e],n[r]]=[n[r],n[e]]}return n}export{$ as INVARIANTS,k as checkInvariants};
|
package/job.js
CHANGED
|
@@ -1,149 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// The executive posts the first jobs; a recruited agent posts more when the work needs more
|
|
4
|
-
// hands, a skill or a tool it does not have; a person posts one by hand. A job says what it
|
|
5
|
-
// needs (skills, tools, grants), what it may cost (carved from the project's budget), and
|
|
6
|
-
// where the work happens (a repo and a base branch — the bridge gives it a worktree). Agents
|
|
7
|
-
// in the pool APPLY by construction (recruit.js scores every type at once); an evaluator
|
|
8
|
-
// picks; the pick is recruited with a budget and the job becomes a role on a run.
|
|
9
|
-
//
|
|
10
|
-
// A job's status is a machine: open → evaluating → recruited → in-progress → done | failed,
|
|
11
|
-
// with withdrawn from any of the first three. Every move is an event on the project's
|
|
12
|
-
// record (project.js foldProject: `job.posted`, `job.updated`).
|
|
1
|
+
import{validateBudget as k,normalizeBudget as c}from"./budget.js";import{GRANT_RE as g}from"./team.js";const b=/^[a-z][a-z0-9_-]{0,63}$/i,l=Object.freeze(["open","evaluating","recruited","in-progress","done","failed","withdrawn"]),y=Object.freeze({open:["evaluating","recruited","withdrawn"],evaluating:["recruited","open","withdrawn"],recruited:["in-progress","open","withdrawn"],"in-progress":["done","failed","open"],done:[],failed:["open"],withdrawn:["open"]}),u=24,f=64;class m extends Error{constructor(t,n){super(n),this.name="JobError",this.code=t}}const a=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),r=(e,t)=>String(e||"").trim().slice(0,t),p=(e,t,n)=>[...new Set((Array.isArray(e)?e:typeof e=="string"?e.split(/[,\s]+/):[]).map(i=>r(i,t)).filter(Boolean))].slice(0,n);function h(e){const t=[];if(!a(e))return{ok:!1,errors:["job must be an object"]};if(b.test(String(e.id||""))||t.push("id: a short identifier (letters, digits, _ -)"),b.test(String(e.projectId||""))||t.push("projectId: the project this job belongs to"),r(e.title,200)||t.push("title: what the job is"),r(e.brief,8e3)||t.push("brief: what to do, what done looks like"),e.needs!==void 0)if(!a(e.needs))t.push("needs: { skills[], tools[], grants[] }");else{const n=(Array.isArray(e.needs.grants)?e.needs.grants:[]).filter(i=>!g.test(String(i)));n.length&&t.push(`needs.grants: not grantable: ${n.join(", ")}`)}if(e.budget!==void 0){const n=k(e.budget);n.ok||t.push(...n.errors.map(i=>`budget: ${i}`))}return e.status!==void 0&&!l.includes(e.status)&&t.push(`status: one of ${l.join(", ")}`),e.dependsOn!==void 0&&!Array.isArray(e.dependsOn)&&t.push("dependsOn: a list of job ids"),e.workspace!==void 0&&e.workspace!==null&&!a(e.workspace)&&t.push("workspace: { repoId, base, branch? }"),e.deadline!==void 0&&e.deadline!==null&&!Number.isFinite(Number(e.deadline))&&t.push("deadline: a time"),{ok:t.length===0,errors:t}}function w(e){const t=h(e);if(!t.ok)throw new m("INVALID",t.errors.join("; "));return{id:String(e.id),projectId:String(e.projectId),title:r(e.title,200),brief:r(e.brief,8e3),needs:{skills:p(e.needs?.skills,80,u),tools:p(e.needs?.tools,120,u),grants:p(e.needs?.grants,64,u).filter(n=>g.test(n))},...e.budget?{budget:c(e.budget)}:{},size:{steps:Math.max(0,Math.round(Number(e.size?.steps)||0))},status:l.includes(e.status)?e.status:"open",postedBy:r(e.postedBy,80)||"person",postedAt:Number(e.postedAt)||Date.now(),...e.deadline?{deadline:Number(e.deadline)}:{},dependsOn:p(e.dependsOn,64,32).filter(n=>n!==e.id),...e.workspace?{workspace:{repoId:r(e.workspace.repoId,120),base:r(e.workspace.base,120)||"main",...e.workspace.branch?{branch:r(e.workspace.branch,200)}:{},...e.workspace.worktreePath?{worktreePath:r(e.workspace.worktreePath,400)}:{}}}:{},applications:Array.isArray(e.applications)?e.applications.slice(0,f).map(A).filter(Boolean):[],...e.recruited?{recruited:I(e.recruited)}:{},...e.runId?{runId:String(e.runId)}:{},...e.result?{result:{text:r(e.result.text,8e3),by:r(e.result.by,80),at:Number(e.result.at)||Date.now(),...Array.isArray(e.result.refs)?{refs:e.result.refs.slice(0,12)}:{}}}:{},...e.origin&&a(e.origin)?{origin:{...e.origin}}:{}}}function A(e){return!a(e)||!e.agentId?null:{agentId:String(e.agentId),...e.engine?{engine:e.engine}:{},fit:Math.max(0,Math.min(1,Number(e.fit)||0)),reasons:Array.isArray(e.reasons)?e.reasons.map(t=>r(t,200)).slice(0,8):[],pitch:r(e.pitch,600),at:Number(e.at)||Date.now()}}function I(e){return{agentId:String(e.agentId),...e.engine?{engine:e.engine}:{},...e.budget?{budget:c(e.budget)}:{},by:r(e.by,80)||"evaluator",at:Number(e.at)||Date.now(),...e.why?{why:r(e.why,600)}:{}}}function N(e){return Object.freeze(w(e))}function S(e,t){return(y[e]||[]).includes(t)}function x(e,t,n,{cards:i={},now:d=Date.now()}={}){return(t||[]).filter(s=>s&&s.enabled!==!1&&(s.appliesTo||["jobs"]).includes("jobs")).map(s=>{const o=n(e,s,i[s.id]||null);return{agentId:s.id,...o.engine?{engine:o.engine}:{},fit:o.score,reasons:o.reasons,pitch:"",at:d}}).sort((s,o)=>!!o.engine-!!s.engine||o.fit-s.fit).slice(0,f)}function B(e,t){return{id:t?.id||e.id,agent:t?.id,name:t?.name||e.title,prompt:[t?.prompt,`Job: ${e.title}
|
|
13
2
|
|
|
14
|
-
|
|
15
|
-
import { GRANT_RE } from './team.js';
|
|
3
|
+
${e.brief}`].filter(Boolean).join(`
|
|
16
4
|
|
|
17
|
-
|
|
18
|
-
export const JOB_STATUSES = Object.freeze(['open', 'evaluating', 'recruited', 'in-progress', 'done', 'failed', 'withdrawn']);
|
|
19
|
-
const NEXT = Object.freeze({
|
|
20
|
-
open: ['evaluating', 'recruited', 'withdrawn'], evaluating: ['recruited', 'open', 'withdrawn'], recruited: ['in-progress', 'open', 'withdrawn'],
|
|
21
|
-
'in-progress': ['done', 'failed', 'open'], done: [], failed: ['open'], withdrawn: ['open'],
|
|
22
|
-
});
|
|
23
|
-
export const MAX_NEEDS = 24;
|
|
24
|
-
export const MAX_APPLICATIONS = 64;
|
|
25
|
-
|
|
26
|
-
export class JobError extends Error {
|
|
27
|
-
constructor(code, message) { super(message); this.name = 'JobError'; this.code = code; }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
31
|
-
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
32
|
-
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);
|
|
33
|
-
|
|
34
|
-
export function validateJob(j) {
|
|
35
|
-
const errors = [];
|
|
36
|
-
if (!isRecord(j)) return { ok: false, errors: ['job must be an object'] };
|
|
37
|
-
if (!JOB_ID_RE.test(String(j.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
38
|
-
if (!JOB_ID_RE.test(String(j.projectId || ''))) errors.push('projectId: the project this job belongs to');
|
|
39
|
-
if (!clip(j.title, 200)) errors.push('title: what the job is');
|
|
40
|
-
if (!clip(j.brief, 8000)) errors.push('brief: what to do, what done looks like');
|
|
41
|
-
if (j.needs !== undefined) {
|
|
42
|
-
if (!isRecord(j.needs)) errors.push('needs: { skills[], tools[], grants[] }');
|
|
43
|
-
else {
|
|
44
|
-
const badGrants = (Array.isArray(j.needs.grants) ? j.needs.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
45
|
-
if (badGrants.length) errors.push(`needs.grants: not grantable: ${badGrants.join(', ')}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (j.budget !== undefined) { const b = validateBudget(j.budget); if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`)); }
|
|
49
|
-
if (j.status !== undefined && !JOB_STATUSES.includes(j.status)) errors.push(`status: one of ${JOB_STATUSES.join(', ')}`);
|
|
50
|
-
if (j.dependsOn !== undefined && !Array.isArray(j.dependsOn)) errors.push('dependsOn: a list of job ids');
|
|
51
|
-
if (j.workspace !== undefined && j.workspace !== null && !isRecord(j.workspace)) errors.push('workspace: { repoId, base, branch? }');
|
|
52
|
-
if (j.deadline !== undefined && j.deadline !== null && !Number.isFinite(Number(j.deadline))) errors.push('deadline: a time');
|
|
53
|
-
return { ok: errors.length === 0, errors };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function normalizeJob(j) {
|
|
57
|
-
const v = validateJob(j);
|
|
58
|
-
if (!v.ok) throw new JobError('INVALID', v.errors.join('; '));
|
|
59
|
-
return {
|
|
60
|
-
id: String(j.id),
|
|
61
|
-
projectId: String(j.projectId),
|
|
62
|
-
title: clip(j.title, 200),
|
|
63
|
-
brief: clip(j.brief, 8000),
|
|
64
|
-
needs: {
|
|
65
|
-
skills: list(j.needs?.skills, 80, MAX_NEEDS),
|
|
66
|
-
tools: list(j.needs?.tools, 120, MAX_NEEDS),
|
|
67
|
-
grants: list(j.needs?.grants, 64, MAX_NEEDS).filter((g) => GRANT_RE.test(g)),
|
|
68
|
-
},
|
|
69
|
-
...(j.budget ? { budget: normalizeBudget(j.budget) } : {}),
|
|
70
|
-
size: { steps: Math.max(0, Math.round(Number(j.size?.steps) || 0)) },
|
|
71
|
-
status: JOB_STATUSES.includes(j.status) ? j.status : 'open',
|
|
72
|
-
postedBy: clip(j.postedBy, 80) || 'person',
|
|
73
|
-
postedAt: Number(j.postedAt) || Date.now(),
|
|
74
|
-
...(j.deadline ? { deadline: Number(j.deadline) } : {}),
|
|
75
|
-
dependsOn: list(j.dependsOn, 64, 32).filter((d) => d !== j.id),
|
|
76
|
-
...(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) } : {}) } } : {}),
|
|
77
|
-
applications: Array.isArray(j.applications) ? j.applications.slice(0, MAX_APPLICATIONS).map(normalizeApplication).filter(Boolean) : [],
|
|
78
|
-
...(j.recruited ? { recruited: normalizeRecruit(j.recruited) } : {}),
|
|
79
|
-
...(j.runId ? { runId: String(j.runId) } : {}),
|
|
80
|
-
...(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) } : {}) } } : {}),
|
|
81
|
-
...(j.origin && isRecord(j.origin) ? { origin: { ...j.origin } } : {}),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizeApplication(a) {
|
|
86
|
-
if (!isRecord(a) || !a.agentId) return null;
|
|
87
|
-
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() };
|
|
88
|
-
}
|
|
89
|
-
function normalizeRecruit(r) {
|
|
90
|
-
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) } : {}) };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
|
|
94
|
-
|
|
95
|
-
/** May the job move from `from` to `to`? */
|
|
96
|
-
export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
|
|
97
|
-
|
|
98
|
-
/** Applications are computed, not asked for: every eligible type in the pool applies at once. */
|
|
99
|
-
export function applyAll(job, pool, fitFn, { cards = {}, now = Date.now() } = {}) {
|
|
100
|
-
// A fit function may also say which ENGINE the agent would run on (recruit.js does); an
|
|
101
|
-
// application without one is not recruitable right now and sorts after those that are.
|
|
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, ...(f.engine ? { engine: f.engine } : {}), fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
|
|
105
|
-
.sort((x, y) => (!!y.engine - !!x.engine) || (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
|
-
}
|
|
5
|
+
`),grants:(e.needs?.grants?.length?e.needs.grants:t?.grants)||["none"],...e.dependsOn?.length?{dependsOn:e.dependsOn}:{},...e.workspace?{workspace:e.workspace}:{}}}function J(e){const t=new Map((e||[]).map(n=>[n.id,n]));return(e||[]).filter(n=>n.status==="open"&&(n.dependsOn||[]).every(i=>t.get(i)?.status==="done"))}function E(e=""){return{id:"",projectId:e,title:"",brief:"",needs:{skills:[],tools:[],grants:[]},budget:{tokens:4e4,ms:9e5},status:"open",dependsOn:[]}}function _(e){const t={};for(const d of["tokens","calls","ms","usd"]){const s=Number(e?.budget?.[d]);e?.budget?.[d]!==""&&e?.budget?.[d]!=null&&Number.isFinite(s)&&s>0&&(t[d]=s)}const n={id:String(e?.id||"").trim().toLowerCase().replace(/[^a-z0-9_-]+/g,"-").replace(/^[^a-z]+/,"").replace(/-+$/,"").slice(0,64),projectId:e?.projectId,title:e?.title,brief:e?.brief,needs:{skills:e?.needs?.skills,tools:e?.needs?.tools,grants:e?.needs?.grants},...Object.keys(t).length?{budget:t}:{},status:e?.status||"open",postedBy:e?.postedBy||"person",dependsOn:e?.dependsOn,...e?.workspace?.repoId?{workspace:e.workspace}:{},...e?.size?{size:e.size}:{}},i=h(n);return i.ok?{ok:!0,job:w(n)}:{ok:!1,errors:i.errors}}export{b as JOB_ID_RE,l as JOB_STATUSES,m as JobError,f as MAX_APPLICATIONS,u as MAX_NEEDS,x as applyAll,E as blankJob,S as canTransition,N as defineJob,_ as jobFromForm,B as jobToRole,w as normalizeJob,J as readyJobs,h as validateJob};
|
package/kernel.js
CHANGED
|
@@ -1,255 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Everything in ChatPanel is a plugin: the loop, tools, skills, storage, renderers, UI
|
|
4
|
-
// slots, sources, model providers, presets, redaction POLICY. This file is what makes
|
|
5
|
-
// that safe to say.
|
|
6
|
-
//
|
|
7
|
-
// "Security is a mandatory plugin" is only meaningful if something that is NOT a plugin
|
|
8
|
-
// enforces the mandate — otherwise mandatory is a config value, and a config value is
|
|
9
|
-
// disableable. So the kernel SHRINKS rather than grows. It does three things, none of
|
|
10
|
-
// them extensible, and deliberately nothing else:
|
|
11
|
-
//
|
|
12
|
-
// 1. load plugins (delegated to the registry — revertible effects, reactive availability);
|
|
13
|
-
// 2. refuse to run when a required plugin is absent or failed;
|
|
14
|
-
// 3. enforce guard monotonicity — a guard may reduce permission, never widen it.
|
|
15
|
-
//
|
|
16
|
-
// POLICY VS MECHANISM. Redaction policy (what counts as sensitive, which dial the user
|
|
17
|
-
// picked, whether an org gets visibility) is a plugin. The redaction mechanism and the
|
|
18
|
-
// egress guard are not. That is what lets privacy be a real on/off dial without ever
|
|
19
|
-
// creating a path where bytes leave unguarded: security is not a dial, privacy is.
|
|
20
|
-
//
|
|
21
|
-
// NO DEPENDENCY ON THE EVENT LOG, matching registry.js — `onEvent` is a plain hook the
|
|
22
|
-
// host maps to schema events if it wants them. That keeps the kernel runnable in the
|
|
23
|
-
// extension, the gateway and the bridge unchanged.
|
|
24
|
-
|
|
25
|
-
import { createRegistry } from './registry.js';
|
|
26
|
-
|
|
27
|
-
export class KernelError extends Error {
|
|
28
|
-
constructor(code, message, detail = null) {
|
|
29
|
-
super(message);
|
|
30
|
-
this.name = 'KernelError';
|
|
31
|
-
this.code = code;
|
|
32
|
-
this.detail = detail;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The default non-negotiables. A host may require more; it may not require fewer. */
|
|
37
|
-
export const REQUIRED_PLUGINS = Object.freeze(['security']);
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* A decision is a permission, not a boolean: `allow` plus the scopes it covers.
|
|
41
|
-
* Kept deliberately small — anything richer invites a lattice we cannot check.
|
|
42
|
-
*/
|
|
43
|
-
export const ALLOW_ALL = Object.freeze({ allow: true, scopes: null, reasons: [] });
|
|
44
|
-
|
|
45
|
-
const asScopeSet = (s) => (s == null ? null : new Set(s));
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The MEET of two decisions — the heart of the monotonicity guarantee.
|
|
49
|
-
*
|
|
50
|
-
* A guard cannot widen because widening is never constructed, not because we inspect the
|
|
51
|
-
* result and complain. Detect-and-reject would leave a window in which the widened value
|
|
52
|
-
* existed and could be read by whatever ran next; a meet has no such window.
|
|
53
|
-
*
|
|
54
|
-
* `scopes: null` means "unscoped / all", so it is the top of the lattice and intersecting
|
|
55
|
-
* with it is identity — that keeps a guard that does not care about scopes from
|
|
56
|
-
* accidentally narrowing to nothing.
|
|
57
|
-
*/
|
|
58
|
-
export function meetDecisions(a, b) {
|
|
59
|
-
const as = asScopeSet(a.scopes);
|
|
60
|
-
const bs = asScopeSet(b.scopes);
|
|
61
|
-
let scopes = null;
|
|
62
|
-
if (as && bs) scopes = [...as].filter((s) => bs.has(s));
|
|
63
|
-
else if (as) scopes = [...as];
|
|
64
|
-
else if (bs) scopes = [...bs];
|
|
65
|
-
return {
|
|
66
|
-
allow: !!a.allow && !!b.allow,
|
|
67
|
-
scopes,
|
|
68
|
-
reasons: [...(a.reasons || []), ...(b.reasons || [])],
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** True when `out` asked for more than `input` allowed — a misbehaving or hostile plugin. */
|
|
73
|
-
function widened(input, out) {
|
|
74
|
-
if (!input.allow && out.allow) return true;
|
|
75
|
-
const is = asScopeSet(input.scopes);
|
|
76
|
-
const os = asScopeSet(out.scopes);
|
|
77
|
-
if (!is) return false; // input was unscoped: nothing to widen past
|
|
78
|
-
if (!os) return true; // guard tried to drop scoping entirely
|
|
79
|
-
return [...os].some((s) => !is.has(s));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* @param required plugin ids that must be active before `start()` resolves. Callers may
|
|
84
|
-
* add to REQUIRED_PLUGINS; they cannot remove from it.
|
|
85
|
-
* @param onEvent `{ event, ...detail }` — 'plugin:activated' | 'plugin:failed' |
|
|
86
|
-
* 'guard:widened' | 'started' | 'stopped'.
|
|
87
|
-
*/
|
|
88
|
-
export function createKernel({ required = [], onEvent = null } = {}) {
|
|
89
|
-
const requiredIds = [...new Set([...REQUIRED_PLUGINS, ...required])];
|
|
90
|
-
const registry = createRegistry({
|
|
91
|
-
onEvent: (d) => emit(`registry:${d.event}`, d),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const declared = new Map(); // id -> declaration
|
|
95
|
-
const handles = new Map(); // id -> registry handle
|
|
96
|
-
const guards = new Map(); // name -> [{ pluginId, fn }]
|
|
97
|
-
let started = false;
|
|
98
|
-
|
|
99
|
-
const emit = (event, detail = {}) => { if (onEvent) onEvent({ event, ...detail }); };
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Declare a plugin. Declaration is NOT activation: manifests are static so the kernel
|
|
103
|
-
* can answer "what is installed" without executing anything, and `activate` is where a
|
|
104
|
-
* host does its `await import()`. A kernel that had to load every plugin to know what
|
|
105
|
-
* it had would put the whole graph on first paint, and first paint is a release gate.
|
|
106
|
-
*/
|
|
107
|
-
function define(plugin) {
|
|
108
|
-
const { id, requires = [], activate, load } = plugin || {};
|
|
109
|
-
if (!id || typeof id !== 'string') throw new KernelError('BAD_PLUGIN', 'plugin.id required');
|
|
110
|
-
if (typeof activate !== 'function' && typeof load !== 'function') {
|
|
111
|
-
throw new KernelError('BAD_PLUGIN', `plugin '${id}': activate or load required`);
|
|
112
|
-
}
|
|
113
|
-
if (declared.has(id)) throw new KernelError('DUPLICATE', `plugin '${id}' is already defined`);
|
|
114
|
-
declared.set(id, { ...plugin, requires: [...requires] });
|
|
115
|
-
return () => remove(id);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function remove(id) {
|
|
119
|
-
if (requiredIds.includes(id)) {
|
|
120
|
-
// Not a permission check that could be satisfied — there is no argument that makes
|
|
121
|
-
// removing the security plugin acceptable, so it is not expressible.
|
|
122
|
-
throw new KernelError('REQUIRED', `plugin '${id}' is required and cannot be removed`);
|
|
123
|
-
}
|
|
124
|
-
declared.delete(id);
|
|
125
|
-
const h = handles.get(id);
|
|
126
|
-
handles.delete(id);
|
|
127
|
-
for (const [name, list] of guards) {
|
|
128
|
-
const kept = list.filter((g) => g.pluginId !== id);
|
|
129
|
-
if (kept.length) guards.set(name, kept); else guards.delete(name);
|
|
130
|
-
}
|
|
131
|
-
return h ? h.dispose() : Promise.resolve();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The scope a plugin's `activate` receives — the registry's, plus guard registration. */
|
|
135
|
-
function scopeFor(id, inner) {
|
|
136
|
-
return {
|
|
137
|
-
...inner,
|
|
138
|
-
/**
|
|
139
|
-
* Register a guard. It receives `(request, decision)` and returns a decision; the
|
|
140
|
-
* kernel meets its answer with what came in, so it can only ever narrow.
|
|
141
|
-
*/
|
|
142
|
-
guard(name, fn) {
|
|
143
|
-
if (typeof fn !== 'function') throw new KernelError('BAD_GUARD', `guard '${name}' must be a function`);
|
|
144
|
-
const list = guards.get(name) || [];
|
|
145
|
-
list.push({ pluginId: id, fn });
|
|
146
|
-
guards.set(name, list);
|
|
147
|
-
return () => {
|
|
148
|
-
const cur = (guards.get(name) || []).filter((g) => g.fn !== fn);
|
|
149
|
-
if (cur.length) guards.set(name, cur); else guards.delete(name);
|
|
150
|
-
};
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Run every guard for `name` and return the narrowest decision any of them permits.
|
|
157
|
-
* Order-independent by construction: meet is associative and commutative, so guards
|
|
158
|
-
* cannot race each other into a different answer — which is what lets them be plugins.
|
|
159
|
-
*/
|
|
160
|
-
function decide(name, request, initial = ALLOW_ALL) {
|
|
161
|
-
let decision = { allow: !!initial.allow, scopes: initial.scopes ?? null, reasons: [...(initial.reasons || [])] };
|
|
162
|
-
for (const { pluginId, fn } of guards.get(name) || []) {
|
|
163
|
-
let out;
|
|
164
|
-
try {
|
|
165
|
-
out = fn(request, decision);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
// A guard that throws is a guard that did not permit. Fail closed: the
|
|
168
|
-
// alternative is that crashing a guard becomes a way to bypass it.
|
|
169
|
-
decision = meetDecisions(decision, { allow: false, scopes: null, reasons: [`${pluginId}: guard threw (${err.message})`] });
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (out == null) continue; // abstained
|
|
173
|
-
if (out === true) continue; // permitted, unchanged
|
|
174
|
-
if (out === false) out = { allow: false, scopes: null, reasons: [`${pluginId}: denied`] };
|
|
175
|
-
const norm = { allow: out.allow !== false, scopes: out.scopes ?? null, reasons: out.reasons || [] };
|
|
176
|
-
if (widened(decision, norm)) emit('guard:widened', { guard: name, pluginId });
|
|
177
|
-
decision = meetDecisions(decision, norm);
|
|
178
|
-
}
|
|
179
|
-
return decision;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Activate everything declared, then verify the required set is genuinely ACTIVE —
|
|
184
|
-
* not merely declared. A required plugin that failed to activate is the same problem
|
|
185
|
-
* as one that was never installed, and the kernel must not be the component that
|
|
186
|
-
* papers over the difference.
|
|
187
|
-
*/
|
|
188
|
-
async function start() {
|
|
189
|
-
if (started) return kernel;
|
|
190
|
-
const missing = requiredIds.filter((id) => !declared.has(id));
|
|
191
|
-
if (missing.length) {
|
|
192
|
-
throw new KernelError('MISSING_REQUIRED', `kernel will not start: required plugin(s) not defined: ${missing.join(', ')}`, missing);
|
|
193
|
-
}
|
|
194
|
-
// Required plugins first, so a dependent that reaches for security during its own
|
|
195
|
-
// activation finds it — the same ordering rule the registry applies to teardown.
|
|
196
|
-
const ordered = [...declared.values()].sort(
|
|
197
|
-
(a, b) => (requiredIds.includes(b.id) ? 1 : 0) - (requiredIds.includes(a.id) ? 1 : 0),
|
|
198
|
-
);
|
|
199
|
-
// Resolve `load` BEFORE registering. Registry activation is synchronous — an effect
|
|
200
|
-
// registered after activation returned would not be in the disposer stack, so the
|
|
201
|
-
// inverse would not run. Awaiting the import here is what keeps a manifest static
|
|
202
|
-
// (the kernel knows what is installed without executing it) while the module itself
|
|
203
|
-
// still only costs anything when it activates.
|
|
204
|
-
for (const p of ordered) {
|
|
205
|
-
if (handles.has(p.id)) continue;
|
|
206
|
-
let activate = p.activate;
|
|
207
|
-
if (!activate) {
|
|
208
|
-
try {
|
|
209
|
-
const mod = await p.load();
|
|
210
|
-
activate = mod?.activate || mod?.default;
|
|
211
|
-
} catch (err) {
|
|
212
|
-
throw new KernelError('LOAD_FAILED', `plugin '${p.id}' failed to load: ${err.message}`, p.id);
|
|
213
|
-
}
|
|
214
|
-
if (typeof activate !== 'function') {
|
|
215
|
-
throw new KernelError('BAD_PLUGIN', `plugin '${p.id}': loaded module exports no activate`, p.id);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
handles.set(p.id, registry.register({
|
|
219
|
-
name: p.id,
|
|
220
|
-
requires: p.requires,
|
|
221
|
-
apply: (inner) => activate(scopeFor(p.id, inner)),
|
|
222
|
-
}));
|
|
223
|
-
}
|
|
224
|
-
const notActive = requiredIds.filter((id) => handles.get(id)?.state !== 'active');
|
|
225
|
-
if (notActive.length) {
|
|
226
|
-
const detail = notActive.map((id) => ({ id, error: handles.get(id)?.error?.message || 'inactive', waitingFor: registry.pending().find((p) => p.name === id)?.waitingFor || [] }));
|
|
227
|
-
await registry.dispose();
|
|
228
|
-
throw new KernelError('REQUIRED_INACTIVE', `kernel will not start: required plugin(s) not active: ${notActive.join(', ')}`, detail);
|
|
229
|
-
}
|
|
230
|
-
started = true;
|
|
231
|
-
emit('started', { plugins: registry.active() });
|
|
232
|
-
return kernel;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const kernel = {
|
|
236
|
-
define,
|
|
237
|
-
remove,
|
|
238
|
-
decide,
|
|
239
|
-
start,
|
|
240
|
-
get started() { return started; },
|
|
241
|
-
/** What is installed, whether or not it activated — a manifest question. */
|
|
242
|
-
list: () => [...declared.keys()].sort(),
|
|
243
|
-
active: () => registry.active(),
|
|
244
|
-
pending: () => registry.pending(),
|
|
245
|
-
required: () => [...requiredIds],
|
|
246
|
-
guards: () => [...guards.keys()].sort(),
|
|
247
|
-
registry,
|
|
248
|
-
async stop() {
|
|
249
|
-
started = false;
|
|
250
|
-
await registry.dispose();
|
|
251
|
-
emit('stopped', {});
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
return kernel;
|
|
255
|
-
}
|
|
1
|
+
import{createRegistry as R}from"./registry.js";class p extends Error{constructor(u,i,r=null){super(i),this.name="KernelError",this.code=u,this.detail=r}}const U=Object.freeze(["security"]),_=Object.freeze({allow:!0,scopes:null,reasons:[]}),v=c=>c==null?null:new Set(c);function E(c,u){const i=v(c.scopes),r=v(u.scopes);let a=null;return i&&r?a=[...i].filter(w=>r.has(w)):i?a=[...i]:r&&(a=[...r]),{allow:!!c.allow&&!!u.allow,scopes:a,reasons:[...c.reasons||[],...u.reasons||[]]}}function x(c,u){if(!c.allow&&u.allow)return!0;const i=v(c.scopes),r=v(u.scopes);return i?r?[...r].some(a=>!i.has(a)):!0:!1}function k({required:c=[],onEvent:u=null}={}){const i=[...new Set([...U,...c])],r=R({onEvent:t=>h(`registry:${t.event}`,t)}),a=new Map,w=new Map,d=new Map;let g=!1;const h=(t,o={})=>{u&&u({event:t,...o})};function D(t){const{id:o,requires:n=[],activate:e,load:s}=t||{};if(!o||typeof o!="string")throw new p("BAD_PLUGIN","plugin.id required");if(typeof e!="function"&&typeof s!="function")throw new p("BAD_PLUGIN",`plugin '${o}': activate or load required`);if(a.has(o))throw new p("DUPLICATE",`plugin '${o}' is already defined`);return a.set(o,{...t,requires:[...n]}),()=>y(o)}function y(t){if(i.includes(t))throw new p("REQUIRED",`plugin '${t}' is required and cannot be removed`);a.delete(t);const o=w.get(t);w.delete(t);for(const[n,e]of d){const s=e.filter(l=>l.pluginId!==t);s.length?d.set(n,s):d.delete(n)}return o?o.dispose():Promise.resolve()}function $(t,o){return{...o,guard(n,e){if(typeof e!="function")throw new p("BAD_GUARD",`guard '${n}' must be a function`);const s=d.get(n)||[];return s.push({pluginId:t,fn:e}),d.set(n,s),()=>{const l=(d.get(n)||[]).filter(f=>f.fn!==e);l.length?d.set(n,l):d.delete(n)}}}}function A(t,o,n=_){let e={allow:!!n.allow,scopes:n.scopes??null,reasons:[...n.reasons||[]]};for(const{pluginId:s,fn:l}of d.get(t)||[]){let f;try{f=l(o,e)}catch(L){e=E(e,{allow:!1,scopes:null,reasons:[`${s}: guard threw (${L.message})`]});continue}if(f==null||f===!0)continue;f===!1&&(f={allow:!1,scopes:null,reasons:[`${s}: denied`]});const m={allow:f.allow!==!1,scopes:f.scopes??null,reasons:f.reasons||[]};x(e,m)&&h("guard:widened",{guard:t,pluginId:s}),e=E(e,m)}return e}async function q(){if(g)return I;const t=i.filter(e=>!a.has(e));if(t.length)throw new p("MISSING_REQUIRED",`kernel will not start: required plugin(s) not defined: ${t.join(", ")}`,t);const o=[...a.values()].sort((e,s)=>(i.includes(s.id)?1:0)-(i.includes(e.id)?1:0));for(const e of o){if(w.has(e.id))continue;let s=e.activate;if(!s){try{const l=await e.load();s=l?.activate||l?.default}catch(l){throw new p("LOAD_FAILED",`plugin '${e.id}' failed to load: ${l.message}`,e.id)}if(typeof s!="function")throw new p("BAD_PLUGIN",`plugin '${e.id}': loaded module exports no activate`,e.id)}w.set(e.id,r.register({name:e.id,requires:e.requires,apply:l=>s($(e.id,l))}))}const n=i.filter(e=>w.get(e)?.state!=="active");if(n.length){const e=n.map(s=>({id:s,error:w.get(s)?.error?.message||"inactive",waitingFor:r.pending().find(l=>l.name===s)?.waitingFor||[]}));throw await r.dispose(),new p("REQUIRED_INACTIVE",`kernel will not start: required plugin(s) not active: ${n.join(", ")}`,e)}return g=!0,h("started",{plugins:r.active()}),I}const I={define:D,remove:y,decide:A,start:q,get started(){return g},list:()=>[...a.keys()].sort(),active:()=>r.active(),pending:()=>r.pending(),required:()=>[...i],guards:()=>[...d.keys()].sort(),registry:r,async stop(){g=!1,await r.dispose(),h("stopped",{})}};return I}export{_ as ALLOW_ALL,p as KernelError,U as REQUIRED_PLUGINS,k as createKernel,E as meetDecisions};
|