@playola-radio/slipstream 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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- package/schemas/slipstream.task.started.v1.json +72 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attribution producer's scheduling + evaluation core. It decides *when* to
|
|
3
|
+
* evaluate a change and folds evidence into a published result; the honesty of
|
|
4
|
+
* the result itself is the pure reducer's ({@link ./attribution.ts}).
|
|
5
|
+
*
|
|
6
|
+
* Design (locked, Codex-vetted):
|
|
7
|
+
* - **Grace.** Every interval-bearing change arms a deadline at `end_ms +
|
|
8
|
+
* grace_ms`; the first evaluation waits for it so a burst of evidence lands
|
|
9
|
+
* before we commit to a status. An unavailable/legacy interval has no window to
|
|
10
|
+
* wait for and resolves immediately to `observation-interval-unavailable`.
|
|
11
|
+
* - **Revision without new grace.** A resolved change stays revisable: later
|
|
12
|
+
* evidence triggers a fresh evaluation right away (no second grace), and a new
|
|
13
|
+
* result is appended only when it *differs semantically* — the original event
|
|
14
|
+
* is never mutated (append-only revision).
|
|
15
|
+
* - **Original-policy retention.** A change keeps the policy it bound to at
|
|
16
|
+
* commit time; every revision scores under that policy, never a newer one.
|
|
17
|
+
* - **Serialized evaluate+publish.** All evaluations run one at a time so a stale
|
|
18
|
+
* result can never win a race against a newer one, and the semantic-dedup read
|
|
19
|
+
* stays consistent with the append that follows it.
|
|
20
|
+
*
|
|
21
|
+
* Clock and timers are injected so tests drive them deterministically. A single
|
|
22
|
+
* timer is armed for the nearest pending deadline.
|
|
23
|
+
*/
|
|
24
|
+
import { foldEvidence } from "./attribution.js";
|
|
25
|
+
import { evaluateChange, attributionResultsEqual } from "./attribution-scoring.js";
|
|
26
|
+
export function createAttributionEngine(opts) {
|
|
27
|
+
const { now, setTimer, clearTimer, readEvents, appendAttribution } = opts;
|
|
28
|
+
const changes = new Map();
|
|
29
|
+
const published = new Map();
|
|
30
|
+
let timer;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
// Serialized evaluate+publish queue. Per-change dedup collapses redundant
|
|
33
|
+
// requests; the pump processes one evaluation at a time.
|
|
34
|
+
const queue = [];
|
|
35
|
+
const queued = new Set();
|
|
36
|
+
let pumpPromise = Promise.resolve();
|
|
37
|
+
let pumping = false;
|
|
38
|
+
const requestEval = (changeSeq) => {
|
|
39
|
+
if (stopped || queued.has(changeSeq))
|
|
40
|
+
return;
|
|
41
|
+
queued.add(changeSeq);
|
|
42
|
+
queue.push(changeSeq);
|
|
43
|
+
if (!pumping)
|
|
44
|
+
pumpPromise = pump();
|
|
45
|
+
};
|
|
46
|
+
const pump = async () => {
|
|
47
|
+
pumping = true;
|
|
48
|
+
try {
|
|
49
|
+
for (;;) {
|
|
50
|
+
const changeSeq = queue.shift();
|
|
51
|
+
if (changeSeq === undefined)
|
|
52
|
+
break;
|
|
53
|
+
queued.delete(changeSeq);
|
|
54
|
+
await evaluate(changeSeq);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
pumping = false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const evaluate = async (changeSeq) => {
|
|
62
|
+
if (stopped)
|
|
63
|
+
return;
|
|
64
|
+
const c = changes.get(changeSeq);
|
|
65
|
+
if (!c)
|
|
66
|
+
return;
|
|
67
|
+
const invocations = foldEvidence(readEvents()).values();
|
|
68
|
+
const result = evaluateChange({
|
|
69
|
+
path: c.path,
|
|
70
|
+
interval: c.interval,
|
|
71
|
+
policy: c.policy,
|
|
72
|
+
invocations,
|
|
73
|
+
});
|
|
74
|
+
const data = {
|
|
75
|
+
change_seq: changeSeq.toString(),
|
|
76
|
+
policy_seq: c.policySeq.toString(),
|
|
77
|
+
status: result.status,
|
|
78
|
+
reason: result.reason,
|
|
79
|
+
evidence_seqs: result.evidenceSeqs.map((s) => s.toString()),
|
|
80
|
+
...(result.excludedConflicts.length > 0
|
|
81
|
+
? { excluded_conflicts: result.excludedConflicts }
|
|
82
|
+
: {}),
|
|
83
|
+
};
|
|
84
|
+
const prev = published.get(changeSeq);
|
|
85
|
+
if (prev && attributionResultsEqual(prev, data))
|
|
86
|
+
return;
|
|
87
|
+
try {
|
|
88
|
+
await appendAttribution(data);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return; // transient storage fault: leave unpublished so a later eval retries
|
|
92
|
+
}
|
|
93
|
+
if (stopped)
|
|
94
|
+
return; // fenced after the append: a fresh generation owns state now
|
|
95
|
+
published.set(changeSeq, data);
|
|
96
|
+
};
|
|
97
|
+
const rescheduleTimer = () => {
|
|
98
|
+
if (timer !== undefined) {
|
|
99
|
+
clearTimer(timer);
|
|
100
|
+
timer = undefined;
|
|
101
|
+
}
|
|
102
|
+
let nearest = Infinity;
|
|
103
|
+
for (const c of changes.values()) {
|
|
104
|
+
if (!c.graced && c.deadlineMs !== undefined)
|
|
105
|
+
nearest = Math.min(nearest, c.deadlineMs);
|
|
106
|
+
}
|
|
107
|
+
if (nearest === Infinity)
|
|
108
|
+
return;
|
|
109
|
+
timer = setTimer(Math.max(0, nearest - now()), onTimer);
|
|
110
|
+
};
|
|
111
|
+
const onTimer = () => {
|
|
112
|
+
timer = undefined;
|
|
113
|
+
if (stopped)
|
|
114
|
+
return;
|
|
115
|
+
const t = now();
|
|
116
|
+
for (const c of changes.values()) {
|
|
117
|
+
if (!c.graced && c.deadlineMs !== undefined && c.deadlineMs <= t) {
|
|
118
|
+
c.graced = true;
|
|
119
|
+
requestEval(c.changeSeq);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
rescheduleTimer();
|
|
123
|
+
};
|
|
124
|
+
const seedPublished = (changeSeq, data) => {
|
|
125
|
+
published.set(changeSeq, data);
|
|
126
|
+
};
|
|
127
|
+
const onChangeCommitted = (change) => {
|
|
128
|
+
if (stopped || changes.has(change.changeSeq))
|
|
129
|
+
return; // idempotent
|
|
130
|
+
const { interval } = change;
|
|
131
|
+
const deadlineMs = interval !== undefined && !('unavailable' in interval)
|
|
132
|
+
? interval.end_ms + change.policy.grace_ms
|
|
133
|
+
: undefined;
|
|
134
|
+
const tracked = { ...change, deadlineMs, graced: deadlineMs === undefined };
|
|
135
|
+
changes.set(change.changeSeq, tracked);
|
|
136
|
+
if (deadlineMs === undefined) {
|
|
137
|
+
// No observation window to wait for: resolve immediately (unknown).
|
|
138
|
+
requestEval(change.changeSeq);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
rescheduleTimer();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const onEvidenceChanged = () => {
|
|
145
|
+
if (stopped)
|
|
146
|
+
return;
|
|
147
|
+
for (const c of changes.values()) {
|
|
148
|
+
if (c.graced)
|
|
149
|
+
requestEval(c.changeSeq);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const drain = async () => {
|
|
153
|
+
await pumpPromise;
|
|
154
|
+
};
|
|
155
|
+
const stop = async () => {
|
|
156
|
+
stopped = true;
|
|
157
|
+
if (timer !== undefined) {
|
|
158
|
+
clearTimer(timer);
|
|
159
|
+
timer = undefined;
|
|
160
|
+
}
|
|
161
|
+
await pumpPromise;
|
|
162
|
+
};
|
|
163
|
+
return { onChangeCommitted, onEvidenceChanged, seedPublished, drain, stop };
|
|
164
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attribution producer: the session-side composition that turns a durable
|
|
3
|
+
* event log into revisable attribution. It owns three cooperating pieces —
|
|
4
|
+
*
|
|
5
|
+
* - the {@link createEvidenceIngestor} write side (durable, deduped evidence),
|
|
6
|
+
* - the {@link createAttributionEngine} scheduler (grace + revision), and
|
|
7
|
+
* - an in-memory projection of the log the engine folds (evidence + policy).
|
|
8
|
+
*
|
|
9
|
+
* and wires them to the session's single append path. It is honest about
|
|
10
|
+
* restarts: `start(events)` rebuilds every projection from the durable log and
|
|
11
|
+
* only then arms a fresh generation of evaluations (the replay barrier), and
|
|
12
|
+
* `stop()` discards outstanding work and fences the previous generation so a
|
|
13
|
+
* stale evaluation can never append after a recovery reopens the log.
|
|
14
|
+
*
|
|
15
|
+
* Enrichment never blocks capture (CLAUDE.md): all scheduling is asynchronous,
|
|
16
|
+
* append-only, and a failed attribution append is retried later rather than
|
|
17
|
+
* propagated. The producer never emits a `file.changed` — it only refines.
|
|
18
|
+
*/
|
|
19
|
+
import { foldAttributions } from "./attribution.js";
|
|
20
|
+
import { createAttributionEngine, } from "./attribution-engine.js";
|
|
21
|
+
import { createEvidenceIngestor, } from "./evidence-ingest.js";
|
|
22
|
+
/** A1 ships no real transcript adapters, so both real sources are `unconfigured`;
|
|
23
|
+
* an `unknown` under this coverage honestly means "nothing was watched". */
|
|
24
|
+
export const DEFAULT_ENRICHMENT_POLICY = {
|
|
25
|
+
window_ms: 2000,
|
|
26
|
+
grace_ms: 5000,
|
|
27
|
+
sources: { 'claude-code': 'unconfigured', codex: 'unconfigured' },
|
|
28
|
+
};
|
|
29
|
+
const EVIDENCE = 'slipstream.harness.evidence.v1';
|
|
30
|
+
const FILE_CHANGED = 'slipstream.file.changed.v1';
|
|
31
|
+
const POLICY = 'slipstream.enrichment.configured.v1';
|
|
32
|
+
/** Canonical form for comparing two policies by value (source key order is incidental). */
|
|
33
|
+
function policyKey(p) {
|
|
34
|
+
const sources = Object.keys(p.sources)
|
|
35
|
+
.sort()
|
|
36
|
+
.map((k) => `${k}=${p.sources[k]}`)
|
|
37
|
+
.join(',');
|
|
38
|
+
return `${p.window_ms}|${p.grace_ms}|${sources}`;
|
|
39
|
+
}
|
|
40
|
+
export function createAttributionProducer(opts) {
|
|
41
|
+
const { appendEvent, now, setTimer, clearTimer } = opts;
|
|
42
|
+
let running = false;
|
|
43
|
+
let engine;
|
|
44
|
+
let ingestor;
|
|
45
|
+
let evidenceList = [];
|
|
46
|
+
let currentPolicy;
|
|
47
|
+
const inflightIngests = new Set();
|
|
48
|
+
const makeEngine = () => createAttributionEngine({
|
|
49
|
+
now,
|
|
50
|
+
setTimer,
|
|
51
|
+
clearTimer,
|
|
52
|
+
readEvents: () => evidenceList,
|
|
53
|
+
appendAttribution: (data) => appendEvent({ type: 'slipstream.change.attribution.v1', occurred_at_ms: now(), data }),
|
|
54
|
+
});
|
|
55
|
+
const makeIngestor = () => createEvidenceIngestor({ append: appendEvent, now });
|
|
56
|
+
const registerChange = (event, policy) => {
|
|
57
|
+
// A change committed before any policy is a legacy record with no bound
|
|
58
|
+
// policy; it carries the documented legacy disposition and is not scheduled.
|
|
59
|
+
if (!policy || !engine)
|
|
60
|
+
return;
|
|
61
|
+
engine.onChangeCommitted({
|
|
62
|
+
changeSeq: BigInt(event.seq),
|
|
63
|
+
path: event.data.path,
|
|
64
|
+
interval: event.data.observed_interval_ms,
|
|
65
|
+
policy: policy.policy,
|
|
66
|
+
policySeq: policy.seq,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const noteCommitted = (event) => {
|
|
70
|
+
if (!running || !engine)
|
|
71
|
+
return;
|
|
72
|
+
switch (event.type) {
|
|
73
|
+
case POLICY:
|
|
74
|
+
currentPolicy = { seq: BigInt(event.seq), policy: event.data.policy };
|
|
75
|
+
break;
|
|
76
|
+
case EVIDENCE:
|
|
77
|
+
evidenceList.push(event);
|
|
78
|
+
engine.onEvidenceChanged();
|
|
79
|
+
break;
|
|
80
|
+
case FILE_CHANGED:
|
|
81
|
+
registerChange(event, currentPolicy);
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const ingestEvidence = (evidence) => {
|
|
88
|
+
if (!running || !ingestor) {
|
|
89
|
+
return Promise.resolve({ status: 'rejected', reason: 'queue-full', retryable: true });
|
|
90
|
+
}
|
|
91
|
+
const p = ingestor.ingest(evidence);
|
|
92
|
+
// Track a settled (never-rejecting) view so drain/stop can await it without a
|
|
93
|
+
// second rejecting chain producing an unhandled rejection; return the original
|
|
94
|
+
// promise so the caller still observes ingestion failures.
|
|
95
|
+
const settled = p.then(() => { }, () => { });
|
|
96
|
+
inflightIngests.add(settled);
|
|
97
|
+
void settled.finally(() => inflightIngests.delete(settled));
|
|
98
|
+
return p;
|
|
99
|
+
};
|
|
100
|
+
const ensurePolicy = async (policy) => {
|
|
101
|
+
if (currentPolicy && policyKey(currentPolicy.policy) === policyKey(policy))
|
|
102
|
+
return;
|
|
103
|
+
await appendEvent({
|
|
104
|
+
type: 'slipstream.enrichment.configured.v1',
|
|
105
|
+
occurred_at_ms: now(),
|
|
106
|
+
data: { policy },
|
|
107
|
+
});
|
|
108
|
+
// `noteCommitted` (via the sequencer) advances `currentPolicy` from the commit.
|
|
109
|
+
};
|
|
110
|
+
const start = (events) => {
|
|
111
|
+
running = true;
|
|
112
|
+
evidenceList = [];
|
|
113
|
+
currentPolicy = undefined;
|
|
114
|
+
ingestor = makeIngestor();
|
|
115
|
+
engine = makeEngine();
|
|
116
|
+
// Replay in commit order: seed evidence + policy, and collect each change with
|
|
117
|
+
// the policy in force at its position (highest policy seq below the change).
|
|
118
|
+
const evidence = [];
|
|
119
|
+
const pendingChanges = [];
|
|
120
|
+
for (const event of events) {
|
|
121
|
+
if (event.type === POLICY) {
|
|
122
|
+
currentPolicy = { seq: BigInt(event.seq), policy: event.data.policy };
|
|
123
|
+
}
|
|
124
|
+
else if (event.type === EVIDENCE) {
|
|
125
|
+
evidence.push(event);
|
|
126
|
+
}
|
|
127
|
+
else if (event.type === FILE_CHANGED) {
|
|
128
|
+
pendingChanges.push({ event, policy: currentPolicy });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
evidenceList = evidence;
|
|
132
|
+
ingestor.seed(evidence);
|
|
133
|
+
// Seed prior results BEFORE scheduling, so a replayed evaluation that
|
|
134
|
+
// reproduces the same result appends nothing (no double-attribution).
|
|
135
|
+
for (const { data } of foldAttributions(events).values()) {
|
|
136
|
+
engine.seedPublished(BigInt(data.change_seq), data);
|
|
137
|
+
}
|
|
138
|
+
for (const { event, policy } of pendingChanges)
|
|
139
|
+
registerChange(event, policy);
|
|
140
|
+
};
|
|
141
|
+
const drain = async () => {
|
|
142
|
+
await Promise.allSettled([...inflightIngests]);
|
|
143
|
+
await engine?.drain();
|
|
144
|
+
};
|
|
145
|
+
const stop = async () => {
|
|
146
|
+
running = false;
|
|
147
|
+
await Promise.allSettled([...inflightIngests]);
|
|
148
|
+
await engine?.stop();
|
|
149
|
+
engine = undefined;
|
|
150
|
+
ingestor = undefined;
|
|
151
|
+
};
|
|
152
|
+
return { noteCommitted, ingestEvidence, ensurePolicy, start, drain, stop };
|
|
153
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { evidenceKeyString } from "./attribution.js";
|
|
2
|
+
/** Whether an invocation's ±window overlaps the observation interval, inclusive. */
|
|
3
|
+
function windowOverlaps(inv, start, end, windowMs) {
|
|
4
|
+
const winStart = inv.minAtMs - windowMs;
|
|
5
|
+
const winEnd = inv.maxAtMs + windowMs;
|
|
6
|
+
return winStart <= end && winEnd >= start;
|
|
7
|
+
}
|
|
8
|
+
function touchesPath(inv, path) {
|
|
9
|
+
return inv.knownPaths.includes(path);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Match one change against the known invocations under its bound policy. An
|
|
13
|
+
* unavailable interval resolves to `unknown` with its own distinct reason — never
|
|
14
|
+
* conflated with "we looked and found nothing". Each eligible invocation
|
|
15
|
+
* contributes one candidate; conflicts are excluded and disclosed, never counted.
|
|
16
|
+
*/
|
|
17
|
+
export function evaluateChange(input) {
|
|
18
|
+
const { interval } = input;
|
|
19
|
+
// An absent, explicitly-unavailable, or inverted (end before start, e.g. the
|
|
20
|
+
// wall clock regressed mid-acquisition) interval bounds no real observation
|
|
21
|
+
// window. Treat all three as the honest unavailable disposition rather than
|
|
22
|
+
// matching evidence against a degenerate window and publishing a false result.
|
|
23
|
+
if (interval === undefined || 'unavailable' in interval || interval.end_ms < interval.start_ms) {
|
|
24
|
+
return {
|
|
25
|
+
status: 'unknown',
|
|
26
|
+
reason: 'observation-interval-unavailable',
|
|
27
|
+
evidenceSeqs: [],
|
|
28
|
+
excludedConflicts: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const eligible = [];
|
|
32
|
+
const conflicts = [];
|
|
33
|
+
for (const inv of input.invocations) {
|
|
34
|
+
const relevant = touchesPath(inv, input.path) &&
|
|
35
|
+
windowOverlaps(inv, interval.start_ms, interval.end_ms, input.policy.window_ms);
|
|
36
|
+
if (!relevant)
|
|
37
|
+
continue;
|
|
38
|
+
if (inv.conflicted)
|
|
39
|
+
conflicts.push(inv);
|
|
40
|
+
else
|
|
41
|
+
eligible.push(inv);
|
|
42
|
+
}
|
|
43
|
+
const evidenceSeqs = eligible
|
|
44
|
+
.flatMap((inv) => inv.evidenceSeqs)
|
|
45
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
46
|
+
const excludedConflicts = conflicts
|
|
47
|
+
.slice()
|
|
48
|
+
.sort((a, b) => (a.keyStr < b.keyStr ? -1 : a.keyStr > b.keyStr ? 1 : 0))
|
|
49
|
+
.map((inv) => inv.key);
|
|
50
|
+
let status;
|
|
51
|
+
let reason;
|
|
52
|
+
if (eligible.length >= 2) {
|
|
53
|
+
status = 'ambiguous';
|
|
54
|
+
reason = 'multiple-candidates';
|
|
55
|
+
}
|
|
56
|
+
else if (eligible.length === 1) {
|
|
57
|
+
status = 'heuristic';
|
|
58
|
+
reason = 'single-candidate';
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
status = 'unknown';
|
|
62
|
+
reason = 'no-matching-evidence';
|
|
63
|
+
}
|
|
64
|
+
return { status, reason, evidenceSeqs, excludedConflicts };
|
|
65
|
+
}
|
|
66
|
+
function sameStringSet(a, b) {
|
|
67
|
+
if (a.length !== b.length)
|
|
68
|
+
return false;
|
|
69
|
+
const sa = [...a].sort();
|
|
70
|
+
const sb = [...b].sort();
|
|
71
|
+
return sa.every((v, i) => v === sb[i]);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether two attribution results are semantically identical, so a re-evaluation
|
|
75
|
+
* that reproduces the last result appends nothing. Compares the meaning, not the
|
|
76
|
+
* envelope: status, reason, bound policy, evidence set, and conflict disclosure.
|
|
77
|
+
*/
|
|
78
|
+
export function attributionResultsEqual(a, b) {
|
|
79
|
+
if (a.status !== b.status || a.reason !== b.reason || a.policy_seq !== b.policy_seq)
|
|
80
|
+
return false;
|
|
81
|
+
if (!sameStringSet(a.evidence_seqs, b.evidence_seqs))
|
|
82
|
+
return false;
|
|
83
|
+
const ca = (a.excluded_conflicts ?? []).map(evidenceKeyString);
|
|
84
|
+
const cb = (b.excluded_conflicts ?? []).map(evidenceKeyString);
|
|
85
|
+
return sameStringSet(ca, cb);
|
|
86
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const EVIDENCE = 'slipstream.harness.evidence.v1';
|
|
2
|
+
const FILE_CHANGED = 'slipstream.file.changed.v1';
|
|
3
|
+
const ATTRIBUTION = 'slipstream.change.attribution.v1';
|
|
4
|
+
/** A stable string identity for an evidence key, safe as a Map key. JSON-encoded
|
|
5
|
+
* so no component's bytes can bleed into another's: plain concatenation (even with
|
|
6
|
+
* a unit separator) collides, since the schema forbids no character in these ids. */
|
|
7
|
+
export function evidenceKeyString(key) {
|
|
8
|
+
return JSON.stringify([key.harness, key.harness_session_id, key.record_id]);
|
|
9
|
+
}
|
|
10
|
+
/** Canonical signature of the semantic fact a record asserts, used for both dedup
|
|
11
|
+
* (identical signature = one variant) and conflict detection. Adapter version is
|
|
12
|
+
* deliberately excluded: a re-emit under a newer adapter is the same fact. Scope is
|
|
13
|
+
* a structured value, so `['a,b']` (one path) and `['a','b']` (two) never collide. */
|
|
14
|
+
export function variantSignature(data) {
|
|
15
|
+
const scope = data.file_scope.kind === 'paths'
|
|
16
|
+
? { kind: 'paths', paths: [...data.file_scope.paths].sort() }
|
|
17
|
+
: { kind: 'unknown', reason: data.file_scope.reason };
|
|
18
|
+
return JSON.stringify([data.tool_name, data.timestamp.basis, data.timestamp.at_ms, scope]);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fold evidence records into one {@link Invocation} per key. Records sharing a
|
|
22
|
+
* key are joined; byte-identical variants dedup to a single seq; contradictory
|
|
23
|
+
* variants mark the invocation conflicted (never silently merged or overwritten).
|
|
24
|
+
*/
|
|
25
|
+
export function foldEvidence(events) {
|
|
26
|
+
const groups = new Map();
|
|
27
|
+
for (const e of events) {
|
|
28
|
+
if (e.type !== EVIDENCE)
|
|
29
|
+
continue;
|
|
30
|
+
const data = e.data;
|
|
31
|
+
const keyStr = evidenceKeyString(data.evidence_key);
|
|
32
|
+
const sig = variantSignature(data);
|
|
33
|
+
const seq = BigInt(e.seq);
|
|
34
|
+
let group = groups.get(keyStr);
|
|
35
|
+
if (!group) {
|
|
36
|
+
group = { key: data.evidence_key, variants: new Map() };
|
|
37
|
+
groups.set(keyStr, group);
|
|
38
|
+
}
|
|
39
|
+
const existing = group.variants.get(sig);
|
|
40
|
+
// Identical reread: keep the earliest seq that recorded the fact.
|
|
41
|
+
if (!existing || seq < existing.seq)
|
|
42
|
+
group.variants.set(sig, { seq, data });
|
|
43
|
+
}
|
|
44
|
+
const out = new Map();
|
|
45
|
+
for (const [keyStr, group] of groups) {
|
|
46
|
+
const variants = [...group.variants.values()];
|
|
47
|
+
const evidenceSeqs = variants.map((v) => v.seq).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
48
|
+
const bases = new Set();
|
|
49
|
+
let sameBasisConflict = false;
|
|
50
|
+
for (const v of variants) {
|
|
51
|
+
const b = v.data.timestamp.basis;
|
|
52
|
+
if (bases.has(b))
|
|
53
|
+
sameBasisConflict = true;
|
|
54
|
+
bases.add(b);
|
|
55
|
+
}
|
|
56
|
+
const tools = new Set(variants.map((v) => v.data.tool_name));
|
|
57
|
+
const conflicted = sameBasisConflict || tools.size > 1;
|
|
58
|
+
let minAtMs = Infinity;
|
|
59
|
+
let maxAtMs = -Infinity;
|
|
60
|
+
// Union of known paths across every variant. An `unknown`-scope variant adds
|
|
61
|
+
// no path (we cannot say what it touched), but a sibling variant that names a
|
|
62
|
+
// path keeps the invocation relevant — so a conflict is still disclosed.
|
|
63
|
+
const paths = new Set();
|
|
64
|
+
for (const v of variants) {
|
|
65
|
+
minAtMs = Math.min(minAtMs, v.data.timestamp.at_ms);
|
|
66
|
+
maxAtMs = Math.max(maxAtMs, v.data.timestamp.at_ms);
|
|
67
|
+
if (v.data.file_scope.kind === 'paths')
|
|
68
|
+
for (const p of v.data.file_scope.paths)
|
|
69
|
+
paths.add(p);
|
|
70
|
+
}
|
|
71
|
+
out.set(keyStr, {
|
|
72
|
+
key: group.key,
|
|
73
|
+
keyStr,
|
|
74
|
+
minAtMs,
|
|
75
|
+
maxAtMs,
|
|
76
|
+
knownPaths: [...paths].sort(),
|
|
77
|
+
conflicted,
|
|
78
|
+
evidenceSeqs,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** The Fork-1 identity of a change/attribution target: `(source, change_seq)`.
|
|
84
|
+
* A change seq alone is NOT unique — two sessions each hold a change seq `2` — so
|
|
85
|
+
* a fold over a multi-session stream must key by both or silently drop one. */
|
|
86
|
+
export function attributionTargetKey(source, changeSeq) {
|
|
87
|
+
return JSON.stringify([source, String(changeSeq)]);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The Fork-3 public fold: each change's current attribution, keyed by the
|
|
91
|
+
* `(source, change_seq)` identity. An attribution is counted only if its target
|
|
92
|
+
* reference is valid — an existing `file.changed.v1` in the same source with a
|
|
93
|
+
* strictly smaller seq (Fork 1). The highest-seq valid attribution wins, so a
|
|
94
|
+
* revision supersedes without mutating any prior record.
|
|
95
|
+
*/
|
|
96
|
+
export function foldAttributions(events) {
|
|
97
|
+
const changeTargets = new Set();
|
|
98
|
+
for (const e of events) {
|
|
99
|
+
if (e.type === FILE_CHANGED)
|
|
100
|
+
changeTargets.add(attributionTargetKey(e.source, e.seq));
|
|
101
|
+
}
|
|
102
|
+
const latest = new Map();
|
|
103
|
+
for (const e of events) {
|
|
104
|
+
if (e.type !== ATTRIBUTION)
|
|
105
|
+
continue;
|
|
106
|
+
if (!/^[1-9][0-9]*$/.test(e.data.change_seq))
|
|
107
|
+
continue;
|
|
108
|
+
const changeSeq = BigInt(e.data.change_seq);
|
|
109
|
+
const attrSeq = BigInt(e.seq);
|
|
110
|
+
if (changeSeq >= attrSeq)
|
|
111
|
+
continue; // must strictly precede its target
|
|
112
|
+
const targetKey = attributionTargetKey(e.source, e.data.change_seq);
|
|
113
|
+
if (!changeTargets.has(targetKey))
|
|
114
|
+
continue;
|
|
115
|
+
const cur = latest.get(targetKey);
|
|
116
|
+
if (!cur || attrSeq > cur.seq)
|
|
117
|
+
latest.set(targetKey, { seq: attrSeq, source: e.source, data: e.data });
|
|
118
|
+
}
|
|
119
|
+
return latest;
|
|
120
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// BlobSource implementations: resolve a content blob (by sha256) to text, binary,
|
|
2
|
+
// oversize, or an honest "missing" with a reason. The reader over HTTP and the
|
|
3
|
+
// direct disk reader are the two ways to reach the content-addressed store; both
|
|
4
|
+
// feed the same change renderer. Each bounds the bytes it will actually read to
|
|
5
|
+
// `maxBytes`, so a snapshot that under-reports its size can't force a huge read.
|
|
6
|
+
import { constants } from 'node:fs';
|
|
7
|
+
import { open } from 'node:fs/promises';
|
|
8
|
+
import { blobPath, isValidHex } from "./store-reader.js";
|
|
9
|
+
function decode(buf) {
|
|
10
|
+
if (buf.includes(0))
|
|
11
|
+
return { kind: 'binary' };
|
|
12
|
+
// ignoreBOM keeps a leading U+FEFF as content, so a BOM-only file decodes to a
|
|
13
|
+
// one-char line, not a false "(empty file)".
|
|
14
|
+
try {
|
|
15
|
+
return { kind: 'text', text: new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buf) };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { kind: 'binary' };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function diskBlobSource(storeDir) {
|
|
22
|
+
return async (sha256, maxBytes) => {
|
|
23
|
+
if (!isValidHex(sha256))
|
|
24
|
+
return { kind: 'missing', reason: 'invalid-hex' };
|
|
25
|
+
// O_NOFOLLOW: a symlink planted at a valid CAS path must serve nothing but
|
|
26
|
+
// the blob it names — never the link target's bytes.
|
|
27
|
+
let handle;
|
|
28
|
+
try {
|
|
29
|
+
handle = await open(blobPath(storeDir, sha256), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return { kind: 'missing', reason: err.code ?? 'open-failed' };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const { size } = await handle.stat();
|
|
36
|
+
if (size > maxBytes)
|
|
37
|
+
return { kind: 'oversize', size };
|
|
38
|
+
return decode(await handle.readFile());
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return { kind: 'missing', reason: err.code ?? 'read-failed' };
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function httpBlobSource(desc, timeoutMs = 5000) {
|
|
49
|
+
return async (sha256, maxBytes) => {
|
|
50
|
+
if (!isValidHex(sha256))
|
|
51
|
+
return { kind: 'missing', reason: 'invalid-hex' };
|
|
52
|
+
const url = new URL(`v1/blobs/sha256/${sha256}`, desc.url);
|
|
53
|
+
const ac = new AbortController();
|
|
54
|
+
// The timer stays armed through the body read, not just the header fetch: a
|
|
55
|
+
// server that stalls mid-body must still trip the timeout rather than hang.
|
|
56
|
+
// Aborting on every early return tears down the connection so a stalled body
|
|
57
|
+
// can't hold it open after we've stopped reading.
|
|
58
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(url, {
|
|
61
|
+
headers: { authorization: `Bearer ${desc.token}` }, redirect: 'error', signal: ac.signal,
|
|
62
|
+
});
|
|
63
|
+
// Only a plain 200 carries the full blob; 204/206 and the rest are not it.
|
|
64
|
+
if (res.status !== 200) {
|
|
65
|
+
ac.abort();
|
|
66
|
+
return { kind: 'missing', reason: `http-${res.status}` };
|
|
67
|
+
}
|
|
68
|
+
const declared = Number(res.headers.get('content-length'));
|
|
69
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
70
|
+
ac.abort();
|
|
71
|
+
return { kind: 'oversize', size: declared };
|
|
72
|
+
}
|
|
73
|
+
if (!res.body)
|
|
74
|
+
return { kind: 'missing', reason: 'no-body' };
|
|
75
|
+
// Read incrementally so a chunked body with no Content-Length can't be fully
|
|
76
|
+
// buffered before the cap check: stop and abort the moment it exceeds it.
|
|
77
|
+
const reader = res.body.getReader();
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (;;) {
|
|
81
|
+
const { value, done } = await reader.read();
|
|
82
|
+
if (done)
|
|
83
|
+
break;
|
|
84
|
+
total += value.byteLength;
|
|
85
|
+
if (total > maxBytes) {
|
|
86
|
+
ac.abort();
|
|
87
|
+
return { kind: 'oversize', size: total };
|
|
88
|
+
}
|
|
89
|
+
chunks.push(value);
|
|
90
|
+
}
|
|
91
|
+
return decode(Buffer.concat(chunks));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { kind: 'missing', reason: 'fetch-failed' };
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { staticBoundary } from "./reader-runtime.js";
|
|
2
|
+
export function createBoundaryRegistry() {
|
|
3
|
+
const entries = new Map();
|
|
4
|
+
const ensure = (id, boundary) => {
|
|
5
|
+
let entry = entries.get(id);
|
|
6
|
+
if (!entry) {
|
|
7
|
+
entry = { boundary, followers: new Set() };
|
|
8
|
+
entries.set(id, entry);
|
|
9
|
+
}
|
|
10
|
+
return entry;
|
|
11
|
+
};
|
|
12
|
+
const transition = (id, boundary) => {
|
|
13
|
+
const entry = ensure(id, boundary);
|
|
14
|
+
entry.boundary = boundary;
|
|
15
|
+
// Disconnect followers pinned to the previous boundary; they reconnect and
|
|
16
|
+
// re-resolve. Clear the set so a later transition never re-aborts a stale ref.
|
|
17
|
+
const stale = [...entry.followers];
|
|
18
|
+
entry.followers.clear();
|
|
19
|
+
for (const ac of stale)
|
|
20
|
+
ac.abort();
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
reserve(id) {
|
|
24
|
+
ensure(id, staticBoundary(0n));
|
|
25
|
+
},
|
|
26
|
+
activate(id, boundary) {
|
|
27
|
+
transition(id, boundary);
|
|
28
|
+
},
|
|
29
|
+
freeze(id, seq) {
|
|
30
|
+
transition(id, staticBoundary(seq));
|
|
31
|
+
},
|
|
32
|
+
get(id) {
|
|
33
|
+
return entries.get(id);
|
|
34
|
+
},
|
|
35
|
+
installIfAbsent(id, boundary) {
|
|
36
|
+
return ensure(id, boundary);
|
|
37
|
+
},
|
|
38
|
+
addFollower(id, ac) {
|
|
39
|
+
const entry = entries.get(id);
|
|
40
|
+
if (entry)
|
|
41
|
+
entry.followers.add(ac);
|
|
42
|
+
},
|
|
43
|
+
removeFollower(id, ac) {
|
|
44
|
+
entries.get(id)?.followers.delete(ac);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|