@gregiteen/ssss-cli 0.10.2 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-10-13
11
+ ### Added
12
+ - `trigger` gains `event_subject` and `event_match`, and `assistant` gains `owns`.
13
+ `@gregiteen/ssss-cli/runtime` exports `triggerMatchesEvent`, `subjectMatches`
14
+ and `resolveSubjectOwner`; the spec (§11.8) defines event matching and subject
15
+ ownership, which `source: event` previously left undefined.
16
+
17
+ ### Fixed
18
+ - Work caused by an event is now keyed by `event_id` (and carries it as
19
+ `causation_id`), not by the event's timestamp. Two events in the same
20
+ millisecond no longer collapse into one task path and key.
21
+
22
+ ## [0.10.3] - 2026-10-01
23
+ ### Fixed
24
+ - Read event logs in 64 KiB chunks when building duplicate-ID indexes or
25
+ replaying events. Large histories no longer require one full-log string.
26
+ UTF-8 decoding, replay cursors, file locks and duplicate detection retain
27
+ their contracts. A regression check guards bounded reads and corrupt-log
28
+ rejection.
29
+
10
30
  ## [0.10.2] - 2026-09-28
11
31
  ### Fixed
12
32
  - The 0.10.0 notes promised pointer fields for large-file assets, but the
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.10.2
1
+ 0.11.0
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.10.2"
109
+ "exporter": "@gregiteen/ssss-cli@0.10.3"
110
110
  }
111
111
  },
112
112
  "files": [
package/docs/ssss-spec.md CHANGED
@@ -1487,6 +1487,24 @@ Core trigger types are:
1487
1487
  | `file_change` | A matching vault path is created, patched, or deleted. |
1488
1488
  | `condition` | A host-defined predicate over vault/projection state becomes true. |
1489
1489
 
1490
+ An `event` trigger selects canonical events (§8) with two optional fields on the
1491
+ `trigger` primitive, evaluated as a pure function of the trigger and the event:
1492
+
1493
+ - `event_subject` is a glob over the event `subject`. `*` matches within one `/`
1494
+ segment and `**` matches across segments.
1495
+ - `event_match` maps dotted event paths (for example `principal.kind`) to a literal
1496
+ or an array of allowed literals. Every entry MUST hold.
1497
+
1498
+ A trigger with neither field matches nothing. An event nobody matches is only
1499
+ recorded. Work an event causes MUST carry the event's `event_id` as `causation_id`,
1500
+ and its idempotency basis uses `event_id` in place of `scheduled_for`, so a replayed
1501
+ event creates no second task and two events at the same instant create two.
1502
+
1503
+ An `assistant` MAY declare `owns`, a list of subject globs it is responsible for.
1504
+ The owner of a subject is the assistant whose claim has the longest literal prefix.
1505
+ Equal best claims are a conflict that a host MUST surface rather than resolve
1506
+ silently. Ownership says who is responsible; a lease (§7) says who is acting now.
1507
+
1490
1508
  Trigger entries SHOULD use the fields documented in §5.4 `workflow`. Hosts MAY add
1491
1509
  type-specific `x_` fields, but MUST preserve unknown fields when rewriting the
1492
1510
  workflow document (§4.2).
@@ -1497,6 +1515,7 @@ The idempotency basis for runtime-created work is:
1497
1515
 
1498
1516
  ```text
1499
1517
  workspace_id + workflow_id + trigger_id + scheduled_for
1518
+ workspace_id + workflow_id + trigger_id + event_id (event-driven work)
1500
1519
  ```
1501
1520
 
1502
1521
  Two daemon ticks evaluating the same trigger for the same scheduled instant MUST
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -56,7 +56,7 @@
56
56
  ],
57
57
  "scripts": {
58
58
  "conformance": "node ./scripts/conformance.mjs conformance",
59
- "test": "node ./scripts/conformance.mjs conformance --engine"
59
+ "test": "node ./scripts/conformance.mjs conformance --engine && node ./scripts/check-events-streaming.mjs"
60
60
  },
61
61
  "keywords": [
62
62
  "ssss",
@@ -115,6 +115,7 @@
115
115
  "append_only": false,
116
116
  "portability": "structural",
117
117
  "required_fields": ["type", "name"],
118
+ "optional_fields": ["owns"],
118
119
  "spec_ref": "§5.4 assistant"
119
120
  },
120
121
  "workflow": {
@@ -132,7 +133,7 @@
132
133
  "append_only": false,
133
134
  "portability": "structural",
134
135
  "required_fields": ["type", "name", "source", "status"],
135
- "optional_fields": ["slug", "description", "cron", "interval", "timezone", "target_workflow", "target_params", "concurrency", "misfire_policy"],
136
+ "optional_fields": ["slug", "description", "cron", "interval", "timezone", "target_workflow", "target_params", "concurrency", "misfire_policy", "event_subject", "event_match"],
136
137
  "enums": {
137
138
  "source": ["cron", "interval", "webhook", "event", "file_change", "condition", "manual"],
138
139
  "status": ["active", "paused", "disabled"],
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { JsonlEventStore, createCanonicalEvent } from '../src/events.mjs';
6
+
7
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-event-stream-'));
8
+ const file = path.join(root, 'acme.jsonl');
9
+ const make = index => createCanonicalEvent({
10
+ workspace_id: 'acme', action: 'event', subject: 'stream-test',
11
+ principal: { id: 'test', kind: 'service' }, operation_id: `operation-${index}`,
12
+ idempotency_key: `key-${index}`, payload: { text: 'a'.repeat(65500) + '🧠é'.repeat(25000) }
13
+ });
14
+ const events = [make(1), make(2)];
15
+ const originals = { alloc: Buffer.alloc, readFileSync: fs.readFileSync, readSync: fs.readSync };
16
+ try {
17
+ fs.writeFileSync(file, events.map(event => JSON.stringify(event)).join('\n') + '\n');
18
+ Buffer.alloc = (size, ...rest) => { assert.ok(size <= 65536, 'Event reader allocated the whole log'); return originals.alloc(size, ...rest); };
19
+ fs.readFileSync = (target, ...rest) => { assert.notEqual(target, file, 'Event reader loaded the whole log'); return originals.readFileSync(target, ...rest); };
20
+ let largestRead = 0;
21
+ fs.readSync = (fd, buffer, offset, length, position) => {
22
+ largestRead = Math.max(largestRead, length);
23
+ assert.ok(length <= 65536, 'Event reader requested the whole log');
24
+ return originals.readSync(fd, buffer, offset, Math.min(length, 4093), position);
25
+ };
26
+ const store = new JsonlEventStore(root);
27
+ await assert.rejects(store.append(events[0]), /Duplicate event_id/);
28
+ const third = make(3);
29
+ await store.append(third);
30
+ const replayed = [];
31
+ for await (const entry of store.replay({ workspaceId: 'acme', cursor: 1 })) replayed.push(entry);
32
+ assert.deepEqual(replayed.map(entry => entry.cursor), [2, 3]);
33
+ assert.deepEqual(replayed.map(entry => entry.event), [events[1], third]);
34
+ await assert.rejects(new JsonlEventStore(root).append(third), /Duplicate event_id/);
35
+ fs.appendFileSync(file, '{invalid JSON}\n');
36
+ await assert.rejects(new JsonlEventStore(root).append(make(4)), SyntaxError);
37
+ console.log(JSON.stringify({ ok: true, boundedReads: true, utf8: true, cursor: true, duplicate: true, corruptFailClosed: true, largestRead }));
38
+ } finally {
39
+ Buffer.alloc = originals.alloc; fs.readFileSync = originals.readFileSync; fs.readSync = originals.readSync;
40
+ fs.rmSync(root, { recursive: true });
41
+ }
@@ -29,7 +29,7 @@ import {
29
29
  } from '../src/bundle.mjs';
30
30
  import { parseDocument } from '../src/frontmatter.mjs';
31
31
  import { loadRegistries } from '../src/registry.mjs';
32
- import { createRunEnvelope, planWorkflowTrigger } from '../src/runtime.mjs';
32
+ import { createRunEnvelope, planWorkflowTrigger, resolveSubjectOwner, subjectMatches, triggerMatchesEvent } from '../src/runtime.mjs';
33
33
  import {
34
34
  buildSemanticIndex,
35
35
  enrichSemanticIndex,
@@ -970,6 +970,32 @@ async function runRuntimeConformance() {
970
970
  `${firstPlan.envelopes[1].path} !== ${secondPlan.envelopes[1].path}`);
971
971
  check('same workflow trigger plans the same task idempotency key',
972
972
  firstPlan.envelopes[1].idempotency_key === secondPlan.envelopes[1].idempotency_key);
973
+ const eventTrigger = { source: 'event', status: 'active', event_subject: 'tool/*/exec', event_match: { 'principal.kind': ['agent', 'user'] } };
974
+ const toolEvent = { event_id: 'e1', timestamp: scheduledFor, subject: 'tool/ssh/exec', principal: { kind: 'agent', id: 'a' } };
975
+ check('event trigger matches by subject glob and payload predicate',
976
+ triggerMatchesEvent(eventTrigger, toolEvent) === true
977
+ && triggerMatchesEvent(eventTrigger, { ...toolEvent, subject: 'tool/ssh/a/exec' }) === false
978
+ && triggerMatchesEvent(eventTrigger, { ...toolEvent, principal: { kind: 'service', id: 's' } }) === false
979
+ && triggerMatchesEvent({ ...eventTrigger, status: 'paused' }, toolEvent) === false
980
+ && triggerMatchesEvent({ source: 'event', status: 'active' }, toolEvent) === false
981
+ && triggerMatchesEvent({ ...eventTrigger, source: 'cron' }, toolEvent) === false);
982
+ check('subject globs: * stays in a segment, ** crosses segments',
983
+ subjectMatches('secrets/*', 'secrets/KEY') && !subjectMatches('secrets/*', 'secrets/a/b') && subjectMatches('secrets/**', 'secrets/a/b'));
984
+ const planEvent = (event) => planWorkflowTrigger({ workflowPath, workflowContent, workspaceId: 'TEST_WORKSPACE_ID', trigger: { type: 'event', event_type: 'tool' }, event });
985
+ const planA = planEvent(toolEvent);
986
+ const planB = planEvent({ ...toolEvent, event_id: 'e2' });
987
+ check('event-driven work is keyed by the event: same event same task, same instant different event different task',
988
+ planEvent(toolEvent).envelopes[1].idempotency_key === planA.envelopes[1].idempotency_key
989
+ && planA.envelopes[1].idempotency_key !== planB.envelopes[1].idempotency_key
990
+ && planA.envelopes[1].path !== planB.envelopes[1].path
991
+ && JSON.parse(planA.envelopes[0].content).causation_id === 'e1');
992
+ const ownerA = { name: 'gate', owns: ['tool/**'] };
993
+ const ownerB = { name: 'mesh-warden', owns: ['tool/ssh/*'] };
994
+ check('subject ownership is the longest claim; equal claims are a conflict, not a pick',
995
+ resolveSubjectOwner('tool/ssh/exec', [ownerA, ownerB]).owner === ownerB
996
+ && resolveSubjectOwner('tool/other/x', [ownerA, ownerB]).owner === ownerA
997
+ && resolveSubjectOwner('x/y', [ownerA, ownerB]).owner === null
998
+ && resolveSubjectOwner('tool/ssh/exec', [ownerB, { name: 'twin', owns: ['tool/ssh/*'] }]).conflict.length === 2);
973
999
  check('runtime emits event then task envelopes',
974
1000
  firstPlan.envelopes[0].type === 'event' && firstPlan.envelopes[1].type === 'operation');
975
1001
 
package/src/events.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { StringDecoder } from 'node:string_decoder';
5
6
  import { withFileLock } from './file-lock.mjs';
6
7
 
7
8
  // UUIDv5 namespace for event ids derived from idempotency keys.
@@ -83,22 +84,33 @@ function sameStat(a, b) {
83
84
  return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
84
85
  }
85
86
 
86
- /** Read every event_id in a log, bounded to the bytes covered by the returned stat. */
87
+ /** Decode one line at a time, never materializing the complete log as a string. */
88
+ function* logLines(fd, size) {
89
+ const buffer = Buffer.alloc(64 * 1024);
90
+ const decoder = new StringDecoder('utf8');
91
+ let offset = 0, pending = '';
92
+ while (offset < size) {
93
+ const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset);
94
+ if (read === 0) break;
95
+ offset += read;
96
+ const text = pending + decoder.write(buffer.subarray(0, read));
97
+ let start = 0, newline;
98
+ while ((newline = text.indexOf('\n', start)) !== -1) {
99
+ yield text.slice(start, newline);
100
+ start = newline + 1;
101
+ }
102
+ pending = text.slice(start);
103
+ }
104
+ pending += decoder.end();
105
+ if (pending) yield pending;
106
+ }
107
+
108
+ /** Read every event_id, bounded to the bytes covered by the returned stat. */
87
109
  function loadIndex(file) {
88
110
  const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
89
111
  try {
90
- const stat = fs.fstatSync(fd);
91
- const bytes = Buffer.alloc(stat.size);
92
- let offset = 0;
93
- while (offset < bytes.length) {
94
- const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
95
- if (read === 0) break;
96
- offset += read;
97
- }
98
- const ids = new Set();
99
- for (const line of bytes.subarray(0, offset).toString('utf8').split('\n')) {
100
- if (line.trim()) ids.add(JSON.parse(line).event_id);
101
- }
112
+ const stat = fs.fstatSync(fd), ids = new Set();
113
+ for (const line of logLines(fd, stat.size)) if (line.trim()) ids.add(JSON.parse(line).event_id);
102
114
  return { ids, stat };
103
115
  } finally { fs.closeSync(fd); }
104
116
  }
@@ -168,12 +180,15 @@ export class JsonlEventStore {
168
180
  for (const file of files) {
169
181
  if (!fs.existsSync(file)) continue;
170
182
  if (fs.lstatSync(file).isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
171
- for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
172
- if (!line.trim()) continue;
173
- cursor++;
174
- if (cursor <= (options.cursor || 0)) continue;
175
- yield { cursor, event: JSON.parse(line) };
176
- }
183
+ const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
184
+ try {
185
+ for (const line of logLines(fd, fs.fstatSync(fd).size)) {
186
+ if (!line.trim()) continue;
187
+ cursor++;
188
+ if (cursor <= (options.cursor || 0)) continue;
189
+ yield { cursor, event: JSON.parse(line) };
190
+ }
191
+ } finally { fs.closeSync(fd); }
177
192
  }
178
193
  }
179
194
  }
package/src/runtime.mjs CHANGED
@@ -69,6 +69,59 @@ export function normalizeRuntimeTrigger(trigger = {}) {
69
69
  };
70
70
  }
71
71
 
72
+ /** Glob over event subjects: `*` stays within one `/` segment, `**` crosses segments. */
73
+ export function subjectMatches(pattern, subject) {
74
+ if (typeof pattern !== 'string' || typeof subject !== 'string' || !pattern) return false;
75
+ const re = pattern.split('**').map((part) => part.split('*').map((lit) => lit.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
76
+ return new RegExp(`^${re}$`).test(subject);
77
+ }
78
+
79
+ function valueAt(object, dotted) {
80
+ return String(dotted).split('.').reduce((value, key) => (value && typeof value === 'object' ? value[key] : undefined), object);
81
+ }
82
+
83
+ /**
84
+ * Whether an `event` trigger selects a canonical event (§5.4 trigger, §11.8).
85
+ * `event_subject` is a subject glob. `event_match` maps dotted event paths to a
86
+ * literal or an array of allowed literals; every entry must hold. Pure and
87
+ * deterministic: no clock, no I/O. A trigger that is not `source: event`, or
88
+ * is not active, never matches.
89
+ */
90
+ export function triggerMatchesEvent(trigger = {}, event = {}) {
91
+ const source = trigger.source || trigger.type;
92
+ if (source !== 'event') return false;
93
+ if (trigger.status && trigger.status !== 'active') return false;
94
+ if (!trigger.event_subject && !trigger.event_match) return false;
95
+ if (trigger.event_subject && !subjectMatches(trigger.event_subject, event.subject)) return false;
96
+ for (const [key, expected] of Object.entries(trigger.event_match || {})) {
97
+ const actual = valueAt(event, key);
98
+ const allowed = Array.isArray(expected) ? expected : [expected];
99
+ if (!allowed.some((candidate) => candidate === actual)) return false;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * The assistant that owns a subject: the one whose `owns` globs claim it with
106
+ * the longest literal prefix. Equal best claims are a conflict, never a silent
107
+ * pick. Returns { owner, conflict } where `conflict` lists the tied assistants.
108
+ */
109
+ export function resolveSubjectOwner(subject, assistants = []) {
110
+ let best = -1;
111
+ let winners = [];
112
+ for (const assistant of assistants) {
113
+ for (const pattern of [].concat(assistant?.owns || [])) {
114
+ if (!subjectMatches(pattern, subject)) continue;
115
+ const specificity = pattern.split('*')[0].length;
116
+ if (specificity > best) { best = specificity; winners = [assistant]; }
117
+ else if (specificity === best && !winners.includes(assistant)) winners.push(assistant);
118
+ }
119
+ }
120
+ if (winners.length === 0) return { owner: null, conflict: [] };
121
+ if (winners.length === 1) return { owner: winners[0], conflict: [] };
122
+ return { owner: null, conflict: winners };
123
+ }
124
+
72
125
  export function planWorkflowTrigger({
73
126
  workflowPath,
74
127
  workflowContent,
@@ -78,6 +131,7 @@ export function planWorkflowTrigger({
78
131
  firedAt = scheduledFor || new Date().toISOString(),
79
132
  payload = {},
80
133
  actor = { role: 'system' },
134
+ event = null,
81
135
  } = {}) {
82
136
  if (!workflowPath) throw new Error('workflowPath is required.');
83
137
  if (!workflowContent) throw new Error('workflowContent is required.');
@@ -89,11 +143,14 @@ export function planWorkflowTrigger({
89
143
 
90
144
  const normalizedTrigger = normalizeRuntimeTrigger(trigger);
91
145
  const workflow_id = workflowIdFromPath(workflowPath, data);
92
- const scheduled_for = iso(scheduledFor || firedAt, 'scheduledFor');
146
+ // Event-driven work is identified by the event that caused it, so two events in the
147
+ // same millisecond make two tasks and a replayed event makes none.
148
+ const eventId = event?.event_id || null;
149
+ const scheduled_for = iso(scheduledFor || event?.timestamp || firedAt, 'scheduledFor');
93
150
  const fired_at = iso(firedAt || scheduled_for, 'firedAt');
94
- const basis = `${workspaceId}:${workflow_id}:${normalizedTrigger.trigger_id}:${scheduled_for}`;
151
+ const basis = `${workspaceId}:${workflow_id}:${normalizedTrigger.trigger_id}:${eventId || scheduled_for}`;
95
152
  const dedupe_key = `runtime:${hash(basis, 40)}`;
96
- const stamp = compactTimestamp(scheduled_for);
153
+ const stamp = compactTimestamp(scheduled_for) + (eventId ? `-${hash(eventId, 8)}` : '');
97
154
  const taskPath = `tasks/${workflow_id}/${normalizedTrigger.trigger_id}-${stamp}.md`;
98
155
  const eventPath = `events/workflows/${workflow_id}/${normalizedTrigger.trigger_id}-${stamp}`;
99
156
 
@@ -107,6 +164,7 @@ export function planWorkflowTrigger({
107
164
  fired_at,
108
165
  dedupe_key,
109
166
  payload,
167
+ ...(eventId ? { source_event_id: eventId, causation_id: eventId, correlation_id: event.correlation_id || eventId } : {}),
110
168
  };
111
169
 
112
170
  const taskContent = [