@gregiteen/ssss-cli 0.10.3 → 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 +12 -0
- package/VERSION +1 -1
- package/docs/ssss-spec.md +19 -0
- package/package.json +1 -1
- package/registry/core.json +2 -1
- package/scripts/conformance.mjs +27 -1
- package/src/runtime.mjs +61 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ 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
|
+
|
|
10
22
|
## [0.10.3] - 2026-10-01
|
|
11
23
|
### Fixed
|
|
12
24
|
- Read event logs in 64 KiB chunks when building duplicate-ID indexes or
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.11.0
|
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
package/registry/core.json
CHANGED
|
@@ -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"],
|
package/scripts/conformance.mjs
CHANGED
|
@@ -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/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
|
-
|
|
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 = [
|