@idleware/ringi 0.0.0-stage → 0.1.0

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -2
  3. package/package.json +53 -4
  4. package/src/main/resources/profiles/a2a-1.json +43 -0
  5. package/src/main/resources/skill/ringi/SKILL.md +72 -0
  6. package/target/dts/app/agent.d.ts +104 -0
  7. package/target/dts/app/budget.d.ts +36 -0
  8. package/target/dts/app/context.d.ts +19 -0
  9. package/target/dts/app/hubs.d.ts +38 -0
  10. package/target/dts/app/overview.d.ts +25 -0
  11. package/target/dts/app/room.d.ts +76 -0
  12. package/target/dts/app/rooms.d.ts +26 -0
  13. package/target/dts/app/session.d.ts +60 -0
  14. package/target/dts/app/view.d.ts +64 -0
  15. package/target/dts/cli/agent.d.ts +10 -0
  16. package/target/dts/cli/args.d.ts +13 -0
  17. package/target/dts/cli/context.d.ts +6 -0
  18. package/target/dts/cli/index.d.ts +5 -0
  19. package/target/dts/cli/operator.d.ts +8 -0
  20. package/target/dts/cli/session.d.ts +7 -0
  21. package/target/dts/cli/shared.d.ts +20 -0
  22. package/target/dts/cli/skill.d.ts +4 -0
  23. package/target/dts/cli.d.ts +3 -0
  24. package/target/dts/domain/decisions.d.ts +65 -0
  25. package/target/dts/domain/index.d.ts +2 -0
  26. package/target/dts/domain/profiles.d.ts +39 -0
  27. package/target/dts/domain/signals.d.ts +37 -0
  28. package/target/dts/home/index.d.ts +21 -0
  29. package/target/dts/hub/index.d.ts +66 -0
  30. package/target/dts/hub/signals.d.ts +4 -0
  31. package/target/dts/index.d.ts +8 -0
  32. package/target/dts/lib/index.d.ts +18 -0
  33. package/target/dts/protocol/files.d.ts +9 -0
  34. package/target/dts/protocol/index.d.ts +72 -0
  35. package/target/dts/runtime/reader.d.ts +34 -0
  36. package/target/dts/runtime/session.d.ts +42 -0
  37. package/target/dts/runtime/watch.d.ts +17 -0
  38. package/target/dts/store/git/index.d.ts +64 -0
  39. package/target/dts/store/git/remote.d.ts +70 -0
  40. package/target/dts/store/index.d.ts +102 -0
  41. package/target/dts/store/kafka/index.d.ts +2 -0
  42. package/target/dts/store/temporal/index.d.ts +2 -0
  43. package/target/esm/app/agent.js +84 -0
  44. package/target/esm/app/budget.js +95 -0
  45. package/target/esm/app/context.js +16 -0
  46. package/target/esm/app/hubs.js +76 -0
  47. package/target/esm/app/overview.js +39 -0
  48. package/target/esm/app/room.js +127 -0
  49. package/target/esm/app/rooms.js +120 -0
  50. package/target/esm/app/session.js +97 -0
  51. package/target/esm/app/view.js +52 -0
  52. package/target/esm/cli/agent.js +40 -0
  53. package/target/esm/cli/args.js +61 -0
  54. package/target/esm/cli/context.js +8 -0
  55. package/target/esm/cli/index.js +125 -0
  56. package/target/esm/cli/operator.js +147 -0
  57. package/target/esm/cli/session.js +39 -0
  58. package/target/esm/cli/shared.js +143 -0
  59. package/target/esm/cli/skill.js +54 -0
  60. package/target/esm/cli.js +14 -0
  61. package/target/esm/domain/decisions.js +178 -0
  62. package/target/esm/domain/index.js +16 -0
  63. package/target/esm/domain/profiles.js +96 -0
  64. package/target/esm/domain/signals.js +129 -0
  65. package/target/esm/home/index.js +88 -0
  66. package/target/esm/hub/index.js +212 -0
  67. package/target/esm/hub/signals.js +27 -0
  68. package/target/esm/index.js +9 -0
  69. package/target/esm/lib/index.js +202 -0
  70. package/target/esm/protocol/files.js +19 -0
  71. package/target/esm/protocol/index.js +217 -0
  72. package/target/esm/runtime/reader.js +64 -0
  73. package/target/esm/runtime/session.js +112 -0
  74. package/target/esm/runtime/watch.js +304 -0
  75. package/target/esm/store/git/index.js +346 -0
  76. package/target/esm/store/git/remote.js +700 -0
  77. package/target/esm/store/index.js +15 -0
  78. package/target/esm/store/kafka/index.js +3 -0
  79. package/target/esm/store/temporal/index.js +3 -0
@@ -0,0 +1,96 @@
1
+ // profile: what a task is, as data. ringi is a point of coordination, not a methodology: the states of a piece of
2
+ // work, which of them end it, and who may move it, assign it or label people in it are declared by a profile
3
+ // that lives in the hub (`profiles/<name>.json` on main) and is chosen per room. The A2A task model is one such
4
+ // profile (resources/profiles/a2a-1.json); a team or a framework brings its own (architect, reviewer, tester, …).
5
+ // This file interprets any profile; it knows no state, role or rule by name.
6
+ import { HEARTBEATS, ID_RE, TOKEN_RE, one, ordered } from '../protocol/index.js';
7
+ import { fail, strictJson } from '../lib/index.js';
8
+ export const PROFILE_SCHEMA = 'ringi-profile/1';
9
+ export const DEFAULT_PROFILE = 'a2a-1';
10
+ // A hub whose rooms may follow profiles other than the built-in A2A rules declares this capability, so that an older
11
+ // client, which knows only those rules, refuses it instead of misreading it.
12
+ export const PROFILES_CAPABILITY = 'profiles/1';
13
+ const PRINCIPAL_RE = /^(?:requester|assignee|member|role:[a-z][a-z0-9-]*|agent:[a-z0-9]+(?:[._-][a-z0-9]+)*)$/;
14
+ // A profile is refused, not guessed at, when it is not exactly what this schema describes.
15
+ export const parseProfile = (text, label) => {
16
+ const p = strictJson(text, label);
17
+ const bad = (why) => fail(`${label}: ${why}`);
18
+ if (p.schema !== PROFILE_SCHEMA)
19
+ bad(`schema ${String(p.schema)} is not ${PROFILE_SCHEMA}; upgrade ringi or fix the profile`);
20
+ if (!ID_RE.test(p.name ?? ''))
21
+ bad('a profile needs a name like team-flow-1');
22
+ if (!TOKEN_RE.test(p.opens ?? ''))
23
+ bad('`opens` names the Kind that opens a piece of work');
24
+ if (!Array.isArray(p.states) || !p.states.length || !p.states.every(s => TOKEN_RE.test(s)) || new Set(p.states).size !== p.states.length)
25
+ bad('`states` must be distinct tokens');
26
+ if (!p.states.includes(p.initial))
27
+ bad('`initial` must be one of the states');
28
+ if (!Array.isArray(p.terminal) || !p.terminal.every(s => p.states.includes(s)))
29
+ bad('`terminal` must list states');
30
+ if (p.waiting !== undefined && (!p.states.includes(p.waiting) || p.terminal.includes(p.waiting)))
31
+ bad('`waiting` must be a state that does not end the work');
32
+ if (!p.rules || typeof p.rules !== 'object')
33
+ bad('`rules` is required');
34
+ for (const [k, v] of Object.entries(p.rules)) {
35
+ if (!/^(?:assign|roles|grant|state:(?:\*|[a-z][a-z0-9-]*))$/.test(k))
36
+ bad(`unknown rule ${k}`);
37
+ if (k.startsWith('state:') && k !== 'state:*' && !p.states.includes(k.slice(6)))
38
+ bad(`rule ${k} names an unknown state`);
39
+ if (!Array.isArray(v) || v.length > 64 || !v.every(x => PRINCIPAL_RE.test(x)))
40
+ bad(`rule ${k} must list principals: requester, assignee, member, role:<label>, agent:<id>`);
41
+ }
42
+ return p;
43
+ };
44
+ const allows = (rule, who, t) => !!rule?.some(p => p === 'requester' ? who === t.requester
45
+ : p === 'assignee' ? t.assignees.includes(who)
46
+ : p === 'member' ? true
47
+ : p.startsWith('role:') ? t.roles[who] === p.slice(5)
48
+ : p === `agent:${who}`);
49
+ export const ruleFor = (profile, state) => profile.rules[`state:${state}`] ?? profile.rules['state:*'];
50
+ // A message belongs to the nearest work item on its Re chain, itself included.
51
+ export const taskFor = (all, m, profile) => {
52
+ for (let x = m, i = 0; x && i < 100000; x = x.reId ? all.get(x.reId) : undefined, i++)
53
+ if (x.kind === profile.opens && !x.bad)
54
+ return x;
55
+ return null;
56
+ };
57
+ // A work item folds over its messages in thread order. Every right is judged on the work as it was before the
58
+ // message, so a label given in a message authorizes nothing in that same message, while its holder may still hand it
59
+ // over and reassign in one reply. Opening sets the initial state and assignees (Assign, else To minus the opener; none
60
+ // for To: *). The first terminal state is final; assignment and labels may still change after it.
61
+ export const taskOf = (all, root, profile) => {
62
+ // Heartbeats never take part in work (§6), whatever trailers they carry.
63
+ const msgs = ordered([...all.values()].filter(m => !m.bad && !(m.kind && HEARTBEATS.has(m.kind)) && taskFor(all, m, profile) === root));
64
+ const t = { root, msgs, state: profile.initial, by: root.from, at: root.time, requester: root.from, roles: { ...root.roles },
65
+ assignees: root.assign ?? (root.to.includes('*') ? [] : root.to.filter(x => x !== root.from)), grant: one(root, 'grant'), effects: new Map() };
66
+ t.effects.set(root.id, ['opened', ...(root.assign ? ['assigned'] : [])]);
67
+ for (const m of msgs) {
68
+ if (m === root)
69
+ continue;
70
+ const before = { ...t, roles: { ...t.roles }, assignees: [...t.assignees] };
71
+ const fx = [];
72
+ if (one(m, 'grant') && allows(profile.rules.grant, m.from, before)) {
73
+ t.grant = one(m, 'grant');
74
+ fx.push('grant');
75
+ }
76
+ if (m.roles && allows(profile.rules.roles, m.from, before)) {
77
+ for (const [id, label] of Object.entries(m.roles))
78
+ label === 'none' ? delete t.roles[id] : t.roles[id] = label;
79
+ fx.push('roles');
80
+ }
81
+ if (m.assign && allows(profile.rules.assign, m.from, before)) {
82
+ t.assignees = m.assign;
83
+ fx.push('assigned');
84
+ }
85
+ if (m.state && !profile.terminal.includes(t.state) && profile.states.includes(m.state) && allows(ruleFor(profile, m.state), m.from, before)) {
86
+ Object.assign(t, { state: m.state, by: m.from, at: m.time });
87
+ fx.push(`state.${m.state}`);
88
+ }
89
+ if (fx.length)
90
+ t.effects.set(m.id, fx);
91
+ }
92
+ return t;
93
+ };
94
+ export const tasksIn = (all, profile) => [...all.values()].filter(m => m.kind === profile.opens && !m.bad).map(r => taskOf(all, r, profile));
95
+ export const isOpen = (t, profile) => !profile.terminal.includes(t.state);
96
+ //# sourceMappingURL=profiles.js.map
@@ -0,0 +1,129 @@
1
+ // signals: explicit markers of what happens in a room, beside the messages that carry it. Every effective transition
2
+ // (work opened, assigned, a state reached, a proposal, a counted stamp, the decision, a counted usage receipt) is also
3
+ // a signal in its author's own namespace under the room's genesis, pointing at the message that made it: in a git
4
+ // store an annotated tag `ringi/<genesis>/<author>/<message>.<signal>`, signed on a signed hub. Signals are visible
5
+ // and checkable with the store's own tools (`git tag -v`, a forge's tag list and tag-push webhooks) and cheap to poll.
6
+ //
7
+ // The messages stay the truth. A signal is made only for a transition the fold actually counted (a State the author
8
+ // had no right to makes none). A reader checks every signal against its message and pins the stored object it
9
+ // accepted; a signal that disagrees, or an accepted one that changes or disappears, is an alarm. A signal missing
10
+ // after a crash between message and tag changes nothing and is restored with `signals --repair`.
11
+ //
12
+ // Marks are the second layer, in the same namespace: where attention is, not what was decided. `read` says a message
13
+ // reached its reader (made on delivery); `taken` says a member took a piece of work. A mark changes no state,
14
+ // assignment or budget. It is checked only for being possible: its target is an accepted message of the room, its
15
+ // actor a member; `read` only for a message addressed to the actor or to all, `taken` only for a message that opens
16
+ // work.
17
+ import * as D from './decisions.js';
18
+ import { taskFor, taskOf } from './profiles.js';
19
+ import { HIDDEN, one, visible } from '../protocol/index.js';
20
+ export const MARKS = ['read', 'taken'];
21
+ // The canonical text of a signal or mark: what its author stores and every reader compares.
22
+ export const signalBody = (R, who, id, signal) => `${signal}\n\nRoom: ${R.name}\nGenesis: ${R.genesis}\nActor: ${who}\nMessage: ${id}\nSignal: ${signal}\n`;
23
+ // Why the actor cannot put this mark on this message, or nothing when it can.
24
+ export const markWhy = (R, actor, m, mark) => !R.members[actor] ? `${actor} is not a member of ${R.name}`
25
+ : mark === 'read' ? (m.from === actor ? 'one does not read one\'s own message' : !visible(m, actor, HIDDEN) ? `the message is not delivered to ${actor}` : undefined)
26
+ : mark === 'taken' ? (m.kind !== R.profile.opens || m.bad ? `the message does not open work (${R.profile.opens})` : undefined)
27
+ : `unknown mark ${mark}`;
28
+ // The marks on each message that passed the check: target → mark → actors.
29
+ export const marksOn = (checked) => {
30
+ const by = new Map();
31
+ for (const c of checked) {
32
+ if (!c.ok || !MARKS.includes(c.signal))
33
+ continue;
34
+ const e = by.get(c.target) ?? {};
35
+ (e[c.signal] ??= []).push(c.actor);
36
+ by.set(c.target, e);
37
+ }
38
+ return by;
39
+ };
40
+ // The signals a message made, judged on the room as it is now (thread order is fixed, so this does not change).
41
+ export const effective = (R, all, m) => {
42
+ if (m.bad || m.invalid)
43
+ return [];
44
+ const s = [];
45
+ const work = taskFor(all, m, R.profile);
46
+ if (work)
47
+ for (const fx of taskOf(all, work, R.profile).effects.get(m.id) ?? [])
48
+ if (fx === 'opened' || fx === 'assigned' || fx.startsWith('state.'))
49
+ s.push(fx);
50
+ if (m.kind === 'proposal')
51
+ s.push('proposed');
52
+ if (m.kind === 'usage' && D.counted(all, R.authorityAt, m))
53
+ s.push(`usage.${one(m, 'usage')}`);
54
+ const p = m.reId ? all.get(m.reId) : undefined;
55
+ if (p?.kind === 'proposal' && !p.bad) {
56
+ const info = D.proposalOf(all, p, R.authorityAt);
57
+ if (m.kind === 'stamp' && info.reviewers.includes(m.from))
58
+ s.push(`stamp.${one(m, 'stamp')}`);
59
+ if (m.kind === 'decision' && info.decision?.id === m.id)
60
+ s.push(`decision.${info.decision.decision}`);
61
+ if (m.kind === 'decision' && info.revoked?.id === m.id)
62
+ s.push('decision.revoked');
63
+ }
64
+ return s;
65
+ };
66
+ // A signal holds when its author made it, it is named after and points at a message of its author in this room, and
67
+ // that message made exactly this signal.
68
+ export const check = (R, all, records) => records.map(x => {
69
+ const dot = x.name.indexOf('.');
70
+ const id = x.name.slice(0, dot);
71
+ const signal = x.name.slice(dot + 1);
72
+ const m = all.get(x.target);
73
+ const what = () => {
74
+ if (!x.target)
75
+ return 'is not an annotated tag on a message';
76
+ if (x.tagger !== x.actor)
77
+ return `was made by ${x.tagger}, not ${x.actor}`;
78
+ if (id !== x.target)
79
+ return 'is named after another message';
80
+ if (!m)
81
+ return 'points at no accepted message of this room';
82
+ if (MARKS.includes(signal)) {
83
+ const why = markWhy(R, x.actor, m, signal);
84
+ if (why)
85
+ return why;
86
+ }
87
+ else {
88
+ if (m.from !== x.actor)
89
+ return `points at a message of ${m.from}`;
90
+ if (!effective(R, all, m).includes(signal))
91
+ return `its message did not make ${signal}`;
92
+ }
93
+ // Signals and marks alike carry exactly their canonical body (a signed one, its signature after it).
94
+ if (x.text.split('-----BEGIN SSH SIGNATURE-----')[0].trimEnd() !== signalBody({ ...R, name: R.name.split('@')[0] }, x.actor, x.target, signal).trimEnd())
95
+ return 'its trailers disagree with the signal';
96
+ return undefined;
97
+ };
98
+ const why = what();
99
+ return { ref: x.ref, actor: x.actor, signal, target: x.target, ok: !why, why };
100
+ });
101
+ // A signal's identity in any store and after an archive: `<actor>/<message>.<signal>`, the last two components of its
102
+ // ref; pins an older reader kept under a full ref read the same way.
103
+ const keyOf = (ref) => ref.split('/').slice(-2).join('/');
104
+ // A reader's pins: the stored object of each signal it accepted. A changed or vanished one is an alarm.
105
+ export const pins = (records, checked, accepted = {}) => {
106
+ const now = new Map(records.map(x => [`${x.actor}/${x.name}`, x]));
107
+ const alarms = [];
108
+ const next = {};
109
+ for (const [k, object] of Object.entries(accepted)) {
110
+ const key = keyOf(k), x = now.get(key);
111
+ // Two old pins of one signal (its live and its archived ref) that disagree: keep the first as accepted and the
112
+ // other under its own ref as evidence, so the conflict is reported on every read until it is resolved.
113
+ if (key in next && next[key] !== object) {
114
+ alarms.push(`signal ${key} has two different accepted objects (${next[key]} and ${object})`);
115
+ next[k] = object;
116
+ continue;
117
+ }
118
+ next[key] = object;
119
+ if (!x)
120
+ alarms.push(`signal ${key} was removed after it was accepted`);
121
+ else if (x.object !== object)
122
+ alarms.push(`signal ${x.ref} was replaced after it was accepted`);
123
+ }
124
+ for (const c of checked)
125
+ if (c.ok && !next[keyOf(c.ref)])
126
+ next[keyOf(c.ref)] = now.get(keyOf(c.ref)).object;
127
+ return { accepted: next, alarms };
128
+ };
129
+ //# sourceMappingURL=signals.js.map
@@ -0,0 +1,88 @@
1
+ // home: where ringi keeps its state on this machine. $RINGI_HOME holds hubs (each a descriptor and a store),
2
+ // reader state, session records and locks. Reading state fails closed; a write the sandbox denies fails with one
3
+ // fixed instruction.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { errno, fail, guardWrite, locked as lockFile, mkdirIn, note, writeJsonAtomic } from '../lib/index.js';
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+ // This file sits in src/main/ts/home (source) or target/esm/home (build): three levels below the package root.
12
+ export const ROOT = path.resolve(HERE, '..', '..', '..');
13
+ export const RESOURCES = path.join(ROOT, 'src', 'main', 'resources');
14
+ export const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
15
+ export const HOME = path.resolve(process.env.RINGI_HOME || path.join(os.homedir(), '.ringi'));
16
+ export const HOME_VERSION = 3;
17
+ export const OPERATOR = 'operator';
18
+ // --- writing safely ---
19
+ export const guard = (fn) => guardWrite(HOME, fn);
20
+ export const mkdir = (d) => mkdirIn(HOME, d);
21
+ export const writeJson = (f, v) => guard(() => {
22
+ mkdir(path.dirname(f));
23
+ writeJsonAtomic(f, v);
24
+ });
25
+ // State fails closed: unreadable JSON is an error, never a silent fresh start.
26
+ export const readJson = (f, dflt) => {
27
+ let raw;
28
+ try {
29
+ raw = fs.readFileSync(f, 'utf8');
30
+ }
31
+ catch (e) {
32
+ if (errno(e) === 'ENOENT')
33
+ return dflt;
34
+ throw e;
35
+ }
36
+ try {
37
+ return JSON.parse(raw);
38
+ }
39
+ catch (e) {
40
+ return fail(`${f} is not valid JSON (${e.message}); refusing to continue with lost state`);
41
+ }
42
+ };
43
+ // --- the home ---
44
+ export const hubsDir = () => path.join(HOME, 'hub');
45
+ export const hubHome = (name) => path.join(hubsDir(), name);
46
+ // The home is created on first use; anything else at that path is refused. A home of the previous layout (each hub a
47
+ // bare repository `hub/<name>.git` with its settings in git config) is moved into descriptor and store.
48
+ export const ensureHome = () => {
49
+ const marker = path.join(HOME, 'home.json');
50
+ if (fs.existsSync(marker)) {
51
+ const h = readJson(marker, {});
52
+ if (h.version === 2)
53
+ return migrate2(marker);
54
+ if (h.version !== HOME_VERSION)
55
+ fail(`${HOME} is a ringi home of version ${h.version}; this ringi ${VERSION} needs version ${HOME_VERSION}`);
56
+ return;
57
+ }
58
+ if (fs.existsSync(HOME) && fs.readdirSync(HOME).length)
59
+ fail(`${HOME} exists and is not a ringi home (no home.json); move it away or set RINGI_HOME`);
60
+ mkdir(HOME);
61
+ writeJson(marker, { version: HOME_VERSION });
62
+ };
63
+ const migrate2 = (marker) => {
64
+ for (const f of fs.existsSync(hubsDir()) ? fs.readdirSync(hubsDir()).filter(f => f.endsWith('.git')) : []) {
65
+ const name = f.slice(0, -4);
66
+ const old = path.join(hubsDir(), f);
67
+ const cfg = (k) => String(spawnSync('git', ['--git-dir', old, 'config', '--get', k], { encoding: 'utf8' }).stdout ?? '').trim();
68
+ const signed = cfg('ringi.type') === 'remote';
69
+ const descriptor = { schema: 'ringi-hub/2', name, trust: signed ? 'signed' : 'asserted',
70
+ ...(signed ? { root: cfg('ringi.rootKey') || undefined, genesis: cfg('ringi.genesis') || undefined } : {}),
71
+ store: { type: 'git', path: 'store', ...(cfg('remote.origin.url') ? { remote: cfg('remote.origin.url') } : {}) } };
72
+ mkdir(path.join(hubHome(name), 'store'));
73
+ guard(() => fs.renameSync(old, path.join(hubHome(name), 'store', '.git')));
74
+ writeJson(path.join(hubHome(name), 'hub.json'), descriptor);
75
+ note(`moved hub ${name} to ${hubHome(name)} (descriptor hub.json, store/.git)`);
76
+ }
77
+ writeJson(marker, { version: HOME_VERSION });
78
+ };
79
+ export const locked = (name, fn) => {
80
+ ensureHome();
81
+ const dir = path.join(HOME, 'locks');
82
+ mkdir(dir);
83
+ return guard(() => lockFile(path.join(dir, `${name}.lock`), fn));
84
+ };
85
+ export const configFile = () => path.join(HOME, 'config.json');
86
+ export const config = () => ({ hub: 'local', room: null, ...readJson(configFile(), {}) });
87
+ export const setConfig = (patch) => writeJson(configFile(), { ...config(), ...patch });
88
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,212 @@
1
+ // hub: hubs and rooms on top of any store. A hub is a directory with a descriptor (hub.json: which store, where,
2
+ // what this machine pins) beside its store. Its shared contract lives in the store (contract.json, authority.json,
3
+ // profiles/*.json). This file reads contracts, opens rooms with their profile and authority, and decides what a reader
4
+ // accepts: only history that extends what it accepted before, from members the store verifies (docs/spec/hub-1.md, Trust).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import * as D from '../domain/decisions.js';
8
+ import { CAPABILITIES, unenforceable } from '../domain/index.js';
9
+ import { GitStore } from '../store/git/index.js';
10
+ import { HOME, OPERATOR, RESOURCES, VERSION, ensureHome, hubHome, hubsDir, locked, mkdir, writeJson } from '../home/index.js';
11
+ import { DEFAULT_PROFILE, PROFILES_CAPABILITY, parseProfile } from '../domain/profiles.js';
12
+ import { HIDDEN, compose, decodeText, link, parse } from '../protocol/index.js';
13
+ import { emit } from './signals.js';
14
+ import { STORES } from '../store/index.js';
15
+ import { fail, short, strictJson } from '../lib/index.js';
16
+ export const HUB_MAJOR = 1;
17
+ export const CAPS = new Set(Object.keys(CAPABILITIES));
18
+ export const RESERVED_ROOMS = new Set(['main', 'archive']);
19
+ // --- hubs ---
20
+ export const hubNames = () => {
21
+ try {
22
+ return fs.readdirSync(hubsDir()).filter(n => fs.existsSync(path.join(hubsDir(), n, 'hub.json'))).sort();
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ };
28
+ const HUB_MD = `# ringi hub
29
+
30
+ The shared contract of a ringi hub (docs/spec/hub-1.md): contract.json (schema, version, protocol, trust, capabilities, the
31
+ default profile), authority.json (who may decide what, up to which budget; the root, \`operator\`, always may) and
32
+ profiles/ (what a piece of work is in a room: its states, and who may move it). Use \`ringi\`; never edit by hand.
33
+ `;
34
+ const readProfileResource = (name) => fs.readFileSync(path.join(RESOURCES, 'profiles', `${name}.json`), 'utf8');
35
+ // A new hub: local (asserted senders), or signed, with the operator's root key generated on this machine and pinned.
36
+ export const createHub = (name, { signed = false, url } = {}) => {
37
+ ensureHome();
38
+ const dir = hubHome(name);
39
+ if (fs.existsSync(dir))
40
+ fail(`hub ${name} exists`);
41
+ if (url && !signed)
42
+ fail('--url publishes a hub; only a --signed hub may be published');
43
+ const d = { schema: 'ringi-hub/2', name, trust: signed ? 'signed' : 'asserted', store: { type: 'git', path: 'store', ...(url ? { remote: url } : {}) } };
44
+ try {
45
+ mkdir(dir);
46
+ if (signed)
47
+ d.root = GitStore.rootFor(dir, d, HOME);
48
+ const contract = { schema: 'ringi-hub', version: '1.1', protocol: 1, trust: d.trust, capabilities: [D.CAPABILITY, PROFILES_CAPABILITY], profile: DEFAULT_PROFILE, ...(d.root ? { root: d.root } : {}) };
49
+ const store = GitStore.create(dir, d, HOME, {
50
+ 'contract.json': JSON.stringify(contract, null, 2) + '\n',
51
+ 'authority.json': JSON.stringify({ decide: {} }, null, 2) + '\n',
52
+ 'HUB.md': HUB_MD,
53
+ [`profiles/${DEFAULT_PROFILE}.json`]: readProfileResource(DEFAULT_PROFILE),
54
+ }, compose(`hub ${name}`, [['To', '*'], ['Kind', 'hub']]));
55
+ if (signed)
56
+ d.genesis = store.contractHead();
57
+ writeJson(path.join(dir, 'hub.json'), d);
58
+ }
59
+ catch (e) {
60
+ if (!fs.existsSync(path.join(dir, 'hub.json')))
61
+ fs.rmSync(dir, { recursive: true, force: true });
62
+ throw e;
63
+ }
64
+ };
65
+ export const readDescriptor = (name) => {
66
+ const f = path.join(hubHome(name), 'hub.json');
67
+ if (!fs.existsSync(f))
68
+ fail(`no hub ${name}; hubs: ${hubNames().join(', ') || 'none'}`);
69
+ const d = strictJson(fs.readFileSync(f, 'utf8'), f);
70
+ if (d.schema !== 'ringi-hub/2' || d.name !== name || !['signed', 'asserted'].includes(d.trust) || !d.store?.type || !d.store.path)
71
+ fail(`${f} is not a ringi hub descriptor this ringi ${VERSION} reads`);
72
+ return d;
73
+ };
74
+ // The contract a tool must understand before it touches a hub or a room: known schema, major and capabilities.
75
+ export const contractAt = (store, name, rev) => {
76
+ const text = store.contractFile(rev, 'contract.json') ?? store.contractFile(rev, 'hub.json') ?? fail(`hub ${name} has no contract at ${short(rev)}`);
77
+ const c = strictJson(text, `hub ${name}: contract`);
78
+ if (c.schema !== 'ringi-hub')
79
+ fail(`hub ${name} declares schema ${String(c.schema)}, not ringi-hub`);
80
+ const major = Number(String(c.version).split('.')[0]);
81
+ if (major !== HUB_MAJOR)
82
+ fail(`hub ${name} follows contract version ${c.version}; this ringi ${VERSION} supports ${HUB_MAJOR}.x. Upgrade ringi`);
83
+ const why = unenforceable(c.capabilities ?? []);
84
+ if (why)
85
+ fail(`hub ${name} ${why}, which this ringi ${VERSION} cannot enforce. Upgrade ringi`);
86
+ const a = store.contractFile(rev, 'authority.json');
87
+ return { ...c, authority: a === null ? {} : strictJson(a, `hub ${name}: authority.json at ${short(rev)}`) };
88
+ };
89
+ // A hub is opened once per process; its store syncs then (a signed store fetches and verifies), and again on refresh.
90
+ const opened = new Map();
91
+ export const openHub = (name, { refresh = false } = {}) => {
92
+ ensureHome();
93
+ if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(name))
94
+ fail(`bad hub name ${name}`);
95
+ if (opened.has(name) && !refresh)
96
+ return opened.get(name);
97
+ if (name === 'local' && !fs.existsSync(path.join(hubHome(name), 'hub.json')))
98
+ createHub('local');
99
+ const d = readDescriptor(name);
100
+ const factory = STORES[d.store.type] ?? fail(`hub ${name} uses a ${d.store.type} store; this ringi ${VERSION} knows ${Object.keys(STORES).join(', ')}`);
101
+ const store = factory(hubHome(name), d, HOME);
102
+ store.sync();
103
+ const main = store.contractHead();
104
+ const H = { name, dir: hubHome(name), d, store, main, contract: contractAt(store, name, main), signed: d.trust === 'signed' };
105
+ opened.set(name, H);
106
+ return H;
107
+ };
108
+ // --- rooms ---
109
+ export const roomNames = (H, archived = false) => H.store.roomNames(archived);
110
+ export const openRoom = (H, name, { quiet = false } = {}) => {
111
+ if (!name)
112
+ return fail('no room: the operator creates one with ringi room create <id>, or picks one with ringi room switch <id>');
113
+ const s = H.store.room(name);
114
+ if (!s) {
115
+ if (quiet)
116
+ return null;
117
+ const gone = roomNames(H, true).find(a => a.startsWith(`${name}@`));
118
+ return fail(gone ? `room ${name} is archived as ${gone}; read it with --room ${gone}` : `no room ${name} in hub ${H.name}; rooms: ${roomNames(H).join(', ') || 'none'}`);
119
+ }
120
+ // The genesis names its room, in the root's hand: a history offered under another name is not that room.
121
+ const named = s.archived ? name.slice(0, name.lastIndexOf('@')) : name;
122
+ const subject = s.control[0]?.rev === s.genesis ? s.control[0].text.split('\n')[0] : '(none)';
123
+ if (subject !== `room ${named}` || (s.archived && name !== `${named}@${s.genesis.slice(0, 8)}`))
124
+ fail(`room ${name}: its genesis is "${subject}"; refusing a room's history under another name`);
125
+ // The contract in effect at each control entry: the hub contract the room was created on, then each `Contract:`
126
+ // the operator moved it to. A decision names the control entry it was made under (decide.ts).
127
+ const atControl = new Map();
128
+ let current = s.base;
129
+ for (const rec of s.control) {
130
+ const m = /^Contract: ([0-9a-f]{40,64})$/m.exec(rec.text);
131
+ if (m)
132
+ current = m[1];
133
+ atControl.set(rec.rev, current);
134
+ }
135
+ const contract = current === H.main ? H.contract : contractAt(H.store, H.name, current);
136
+ const tables = new Map();
137
+ const authorityAt = (control) => {
138
+ const rev = control ? atControl.get(control) : undefined;
139
+ if (!rev)
140
+ return null;
141
+ if (!tables.has(rev))
142
+ tables.set(rev, contractAt(H.store, H.name, rev).authority);
143
+ return tables.get(rev);
144
+ };
145
+ // The room's profile is fixed when the room is made: its content is copied into the genesis control entry, so no
146
+ // later change of the hub's profiles or contract reinterprets the room's history. Rooms made before this carry
147
+ // none and use the default profile of the contract they were created on.
148
+ const baseProfile = contractAt(H.store, H.name, s.base).profile ?? DEFAULT_PROFILE;
149
+ const profileText = H.store.controlFile(s, 'profile.json', s.genesis)
150
+ ?? H.store.contractFile(s.base, `profiles/${baseProfile}.json`)
151
+ ?? (baseProfile === DEFAULT_PROFILE ? readProfileResource(DEFAULT_PROFILE) : fail(`room ${name}: unknown profile ${baseProfile}`));
152
+ const roster = H.store.roster(s);
153
+ const members = roster?.members ?? strictJson(H.store.controlFile(s, 'members.json') ?? '{}', `room ${name}: members.json`);
154
+ return { hub: H, s, name, archived: s.archived, head: s.head, genesis: s.genesis, contractRev: current, contract, authorityAt,
155
+ profile: parseProfile(profileText, `room ${name}: profile`), members };
156
+ };
157
+ export const room = (H, name) => openRoom(H, name);
158
+ export const ROOM_MD = (name) => `# room ${name}\n\nA ringi room: members.json lists who takes part; profile.json is what a piece of work is here, fixed when the room was made.\n`;
159
+ export const profileText = (H, name) => H.store.contractFile(H.main, `profiles/${name}.json`) ?? (name === DEFAULT_PROFILE ? readProfileResource(DEFAULT_PROFILE) : null);
160
+ // A control change under the hub lock. In a signed room membership changes only through `room admit`.
161
+ export const controlChange = (R, who, subject, mutate, extra = []) => locked(`hub-${R.hub.name}`, () => {
162
+ const s = R.hub.store.room(R.name) ?? fail(`room ${R.name} is gone`);
163
+ const members = strictJson(R.hub.store.controlFile(s, 'members.json') ?? '{}', `room ${R.name}: members.json`);
164
+ const before = JSON.stringify(members);
165
+ mutate(members);
166
+ if (R.hub.signed && JSON.stringify(members) !== before)
167
+ fail('in a signed room, membership changes only by ringi room admit');
168
+ R.head = R.hub.store.updateControl(s, who, compose(subject, [['To', '*'], ['Kind', 'room'], ...extra]), { 'members.json': JSON.stringify(members, null, 2) + '\n' });
169
+ R.members = members;
170
+ });
171
+ // --- messages and what a reader accepts ---
172
+ export const toMessage = (R, from, x, seq) => {
173
+ const trust = R.hub.signed ? 'verified' : 'asserted';
174
+ const files = x.files.map(f => ({ path: f.path, size: null }));
175
+ const base = { id: x.id, from, trust, time: x.time, seq, files, refused: x.refused, reId: null, hopEff: 0 };
176
+ const d = decodeText(x.text);
177
+ if (d.invalid !== undefined) {
178
+ return { ...base, invalid: d.invalid, subject: '', body: '', to: [], re: null, kind: null, state: null, assign: null, roles: null, hop: 0, bad: d.invalid, trailers: {} };
179
+ }
180
+ const p = parse(d.text, s => R.hub.store.isRef(s));
181
+ // An extension's trailers and the room profile's states are part of a message's validity.
182
+ const ext = D.check(p, s => R.hub.store.isRef(s));
183
+ const state = !p.bad && p.state && !R.profile.states.includes(p.state) ? `State ${p.state} is not in profile ${R.profile.name}` : null;
184
+ return { ...base, invalid: null, ...p, bad: p.bad ?? ext ?? state };
185
+ };
186
+ // The room as stored, read without a reader's lock or state: for an author working out the signals of its own message
187
+ // (append runs inside reader locks too). Readers check signals themselves.
188
+ export const asStored = (R) => {
189
+ const s = R.hub.store.room(R.name) ?? R.s;
190
+ return link(new Map(R.hub.store.logIds(s).map(id => [id, R.hub.store.messages(s, id, R.hub.store.logHead(s, id)).map((x, seq) => toMessage(R, id, x, seq))])));
191
+ };
192
+ const CARRIES = (p, R) => !p.bad && (p.kind === R.profile.opens || !!p.assign || !!p.state || ['proposal', 'stamp', 'decision', 'usage'].includes(p.kind ?? ''));
193
+ // A message on the author's log, and the signals it made (signals.ts).
194
+ export const append = (R, who, text, entries = []) => {
195
+ const id = R.hub.store.append(R.s, who, text, entries);
196
+ if (CARRIES(parse(text, s => R.hub.store.isRef(s)), R)) {
197
+ const all = asStored(R);
198
+ const m = all.get(id);
199
+ if (m)
200
+ emit(R, who, all, m);
201
+ }
202
+ return id;
203
+ };
204
+ export const withSizes = (R, m) => {
205
+ if (!m.files.length)
206
+ return [];
207
+ const sizes = R.hub.store.sizes(m.id, m.files.map(f => f.path));
208
+ return m.files.map(f => ({ ...f, size: sizes.get(f.path) ?? null }));
209
+ };
210
+ export const pongText = (me, m, what) => compose(`pong: ${me} ${what}`, [['To', m.from], ['Re', m.id], ['Kind', 'pong'], ['Hop', String(m.hopEff + 1)]]);
211
+ export { OPERATOR, HIDDEN };
212
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,27 @@
1
+ // signals (write side): an author stores the signals its message made and the marks it puts; readers check them
2
+ // (domain/signals.ts).
3
+ import { effective, markWhy, signalBody as body } from '../domain/signals.js';
4
+ import { fail } from '../lib/index.js';
5
+ // Puts one of the actor's marks on a message; false if it was already there.
6
+ export const mark = (R, who, m, name) => {
7
+ const why = markWhy(R, who, m, name);
8
+ if (why)
9
+ fail(`cannot mark ${m.id.slice(0, 12)} ${name}: ${why}`);
10
+ if (R.hub.store.signals(R.s).some(x => x.actor === who && x.name === `${m.id}.${name}`))
11
+ return false;
12
+ R.hub.store.signal(R.s, who, m.id, `${m.id}.${name}`, body(R, who, m.id, name));
13
+ return true;
14
+ };
15
+ // Writes the signals of one of the author's own messages that are not stored yet.
16
+ export const emit = (R, who, all, m) => {
17
+ const have = new Set(R.hub.store.signals(R.s).filter(x => x.actor === who).map(x => x.name));
18
+ let n = 0;
19
+ for (const signal of effective(R, all, m)) {
20
+ if (have.has(`${m.id}.${signal}`))
21
+ continue;
22
+ R.hub.store.signal(R.s, who, m.id, `${m.id}.${signal}`, body(R, who, m.id, signal));
23
+ n++;
24
+ }
25
+ return n;
26
+ };
27
+ //# sourceMappingURL=signals.js.map
@@ -0,0 +1,9 @@
1
+ // The library: the message protocol, profiles, decisions, signals and the Store port, for programs that coordinate
2
+ // through ringi without its command line (a framework's own runner, another store, another client).
3
+ export * from './protocol/index.js';
4
+ export * from './domain/profiles.js';
5
+ export * as decide from './domain/decisions.js';
6
+ export * as signals from './domain/signals.js';
7
+ export { STORES } from './store/index.js';
8
+ export { openHub, openRoom, createHub, contractAt } from './hub/index.js';
9
+ //# sourceMappingURL=index.js.map