@xandreed/core 0.13.0 → 0.14.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/README.md +1 -1
- package/dist/conformance.functions.d.ts +34 -0
- package/dist/conformance.functions.js +25 -0
- package/dist/domain/bash.entity.d.ts +82 -0
- package/dist/domain/bash.entity.functions.d.ts +17 -0
- package/dist/domain/bash.entity.functions.js +26 -0
- package/dist/domain/bash.entity.functions.test.d.ts +1 -0
- package/dist/domain/bash.entity.js +30 -0
- package/dist/domain/file-system.entity.d.ts +91 -0
- package/dist/domain/file-system.entity.functions.d.ts +18 -0
- package/dist/domain/file-system.entity.functions.js +30 -0
- package/dist/domain/file-system.entity.js +37 -0
- package/dist/domain/loop-event.entity.d.ts +9 -0
- package/dist/domain/media.entity.d.ts +79 -0
- package/dist/domain/media.entity.functions.d.ts +76 -0
- package/dist/domain/media.entity.functions.js +110 -0
- package/dist/domain/media.entity.js +38 -0
- package/dist/domain/media.entity.test.d.ts +1 -0
- package/dist/domain/message.entity.d.ts +157 -5
- package/dist/domain/message.entity.js +8 -4
- package/dist/domain/model-catalog.entity.d.ts +2 -0
- package/dist/domain/model-catalog.entity.js +2 -1
- package/dist/domain/token-usage.entity.d.ts +45 -1
- package/dist/domain/token-usage.entity.functions.d.ts +30 -1
- package/dist/domain/token-usage.entity.functions.js +24 -4
- package/dist/domain/token-usage.entity.js +19 -2
- package/dist/domain/token-usage.entity.test.d.ts +1 -0
- package/dist/harness/capability.entity.functions.js +2 -1
- package/dist/harness/plugin.adapter.d.ts +3 -0
- package/dist/harness/plugin.adapter.js +7 -1
- package/dist/harness/skill-files.entity.d.ts +101 -0
- package/dist/harness/skill-files.entity.functions.d.ts +58 -0
- package/dist/harness/skill-files.entity.functions.js +150 -0
- package/dist/harness/skill-files.entity.functions.test.d.ts +1 -0
- package/dist/harness/skill-files.entity.js +25 -0
- package/dist/index.d.ts +48 -8
- package/dist/index.js +67 -6
- package/dist/loop/mapping.d.ts +12 -6
- package/dist/loop/mapping.js +30 -6
- package/dist/loop/stepContext.d.ts +8 -0
- package/dist/loop/stepContext.js +1 -1
- package/dist/loop/traceContent.d.ts +29 -0
- package/dist/loop/traceContent.js +4 -0
- package/dist/loop/traceContent.test.d.ts +1 -0
- package/dist/memory/memory-log.entity.d.ts +152 -14
- package/dist/memory/memory-log.entity.functions.d.ts +35 -5
- package/dist/memory/memory-log.entity.functions.js +59 -20
- package/dist/memory/memory-log.entity.js +19 -6
- package/dist/memory/memory-session.js +10 -6
- package/dist/memory/memory.conformance.d.ts +8 -1
- package/dist/memory/memory.conformance.js +32 -0
- package/dist/models/auth-store.conformance.d.ts +13 -0
- package/dist/models/auth-store.conformance.js +73 -0
- package/dist/models/auth-store.conformance.test.d.ts +1 -0
- package/dist/models/model-catalog.conformance.d.ts +15 -0
- package/dist/models/model-catalog.conformance.js +27 -0
- package/dist/models/model-catalog.conformance.test.d.ts +1 -0
- package/dist/models/settings-store.conformance.d.ts +26 -0
- package/dist/models/settings-store.conformance.js +80 -0
- package/dist/models/settings-store.conformance.test.d.ts +1 -0
- package/dist/ports/activity.port.d.ts +35 -0
- package/dist/ports/activity.port.js +7 -0
- package/dist/ports/auth-store.port.d.ts +7 -5
- package/dist/ports/bash.port.d.ts +22 -0
- package/dist/ports/bash.port.js +4 -0
- package/dist/ports/capability.port.d.ts +11 -1
- package/dist/ports/file-system.port.d.ts +44 -15
- package/dist/ports/file-system.port.js +1 -7
- package/dist/ports/memory.port.d.ts +13 -1
- package/dist/ports/model-catalog.port.d.ts +4 -1
- package/dist/ports/run-context.port.d.ts +6 -1
- package/dist/ports/session-log.port.d.ts +4 -1
- package/dist/ports/sessions.port.d.ts +35 -6
- package/dist/ports/settings-store.port.d.ts +11 -7
- package/dist/ports/step-loop.port.d.ts +30 -2
- package/dist/ports/tasks.port.d.ts +5 -1
- package/dist/ports/turn-admission.port.d.ts +8 -2
- package/dist/ports/turn-events.port.d.ts +8 -1
- package/dist/ports/turn-scope.port.d.ts +19 -1
- package/dist/ports/turn.port.d.ts +70 -5
- package/dist/ports/utility-llm.port.d.ts +2 -0
- package/dist/session/activity.entity.d.ts +42 -0
- package/dist/session/activity.entity.functions.d.ts +21 -0
- package/dist/session/activity.entity.functions.js +23 -0
- package/dist/session/activity.entity.functions.test.d.ts +1 -0
- package/dist/session/activity.entity.js +15 -0
- package/dist/session/chassis.js +2 -1
- package/dist/session/session-event.entity.d.ts +68 -6
- package/dist/session/session-event.entity.functions.d.ts +34 -2
- package/dist/session/session-event.entity.functions.js +54 -16
- package/dist/session/session-event.entity.js +19 -2
- package/dist/session/session-log.conformance.d.ts +4 -3
- package/dist/session/session-log.conformance.js +16 -14
- package/dist/session/session-log.recovery.conformance.d.ts +40 -0
- package/dist/session/session-log.recovery.conformance.js +143 -0
- package/dist/session/session-log.recovery.conformance.test.d.ts +1 -0
- package/dist/session/sessions.conformance.d.ts +79 -0
- package/dist/session/sessions.conformance.js +355 -0
- package/dist/session/sessions.entity.d.ts +36 -3
- package/dist/session/sessions.entity.functions.d.ts +42 -0
- package/dist/session/sessions.entity.functions.js +26 -0
- package/dist/session/sessions.entity.js +17 -4
- package/dist/session/sessions.entity.test.d.ts +1 -0
- package/dist/session/tasks.conformance.d.ts +70 -0
- package/dist/session/tasks.conformance.js +213 -0
- package/dist/session/tasks.conformance.test.d.ts +1 -0
- package/dist/turn/model-request.entity.d.ts +4 -0
- package/dist/turn/model-request.entity.js +7 -2
- package/dist/turn/step-loop.conformance.d.ts +4 -1
- package/dist/turn/step-loop.conformance.js +73 -7
- package/dist/turn/tool-registry.conformance.d.ts +38 -0
- package/dist/turn/tool-registry.conformance.js +206 -0
- package/dist/turn/turn-bus.js +4 -3
- package/dist/turn/turn-event.entity.d.ts +134 -34
- package/dist/turn/turn-event.entity.functions.d.ts +11 -0
- package/dist/turn/turn-event.entity.functions.js +9 -1
- package/dist/turn/turn-event.entity.js +76 -3
- package/dist/turn/turn-event.entity.test.d.ts +1 -0
- package/dist/turn/turn-lifecycle.d.ts +46 -8
- package/dist/turn/turn-lifecycle.js +33 -3
- package/dist/turn/turn-lifecycle.test.d.ts +1 -0
- package/dist/turn/turn-run.d.ts +15 -3
- package/dist/turn/turn-run.js +37 -9
- package/dist/turn/turn.adapter.d.ts +6 -5
- package/dist/turn/turn.adapter.js +15 -3
- package/dist/turn/turn.conformance.d.ts +11 -2
- package/dist/turn/turn.conformance.js +63 -2
- package/dist/turn/user-message.entity.d.ts +14 -4
- package/dist/turn/user-message.entity.functions.d.ts +21 -0
- package/dist/turn/user-message.entity.functions.js +24 -0
- package/dist/turn/user-message.entity.js +6 -8
- package/dist/turn/user-message.entity.test.d.ts +1 -0
- package/dist/util/glob.d.ts +30 -0
- package/dist/util/glob.js +91 -0
- package/dist/util/glob.test.d.ts +1 -0
- package/dist/workspace/bash.conformance.d.ts +37 -0
- package/dist/workspace/bash.conformance.js +120 -0
- package/dist/workspace/bash.conformance.test.d.ts +1 -0
- package/dist/workspace/file-system.conformance.d.ts +38 -0
- package/dist/workspace/file-system.conformance.js +254 -0
- package/dist/workspace/file-system.conformance.test.d.ts +1 -0
- package/dist/workspace/file-system.memory.adapter.d.ts +23 -0
- package/dist/workspace/file-system.memory.adapter.js +132 -0
- package/package.json +1 -1
- package/dist/harness/skill-files.d.ts +0 -17
- package/dist/harness/skill-files.js +0 -25
- package/dist/ports/shell.port.d.ts +0 -32
- package/dist/ports/shell.port.js +0 -16
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Effect, Scope } from "effect";
|
|
2
|
+
import type { Context } from "effect";
|
|
3
|
+
import type { ConformanceCheck } from "../conformance.entity.js";
|
|
4
|
+
import type { SessionLog } from "../ports/session-log.port.js";
|
|
5
|
+
import type { SessionLogError } from "./session-log.entity.js";
|
|
6
|
+
type Log = Context.Service.Shape<typeof SessionLog>;
|
|
7
|
+
/**
|
|
8
|
+
* A SessionLog backend as the recovery kit opens it. Each run of `open` is a
|
|
9
|
+
* new handle on the same stored data (a new connection to the same file or
|
|
10
|
+
* server), released with its scope; the kit opens several at once and opens
|
|
11
|
+
* again after closing. An in-memory backend passes only when every `open`
|
|
12
|
+
* returns a log over the same store (build it once and `Effect.succeed` it):
|
|
13
|
+
* a fresh `SessionLogMemoryLive` per open starts empty.
|
|
14
|
+
*/
|
|
15
|
+
export interface SessionLogSubject {
|
|
16
|
+
readonly open: Effect.Effect<Log, SessionLogError, Scope.Scope>;
|
|
17
|
+
/**
|
|
18
|
+
* The backend stamps commits and heads with the caller's Effect `Clock`
|
|
19
|
+
* (the memory and SQLite logs do), so the kit can stop the clock and check
|
|
20
|
+
* the `notAfter` boundary to the millisecond.
|
|
21
|
+
*/
|
|
22
|
+
readonly callerClock?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What a SessionLog backend must keep across handles, restarts and
|
|
26
|
+
* interruptions, as checks: commits survive closing and opening again
|
|
27
|
+
* (twice); two open handles see each other's commits and race by
|
|
28
|
+
* compare-and-swap with exactly one winner per revision and no lost or
|
|
29
|
+
* doubled event; an interrupted commit is stored whole or not at all, and has
|
|
30
|
+
* settled by the time the interrupt returns (a backend whose write can land
|
|
31
|
+
* after its caller stops waiting, such as a request already sent to a
|
|
32
|
+
* server, makes that write uninterruptible); a handle whose scope never
|
|
33
|
+
* closed (leaked, or held by a process that hangs) blocks nobody; and, with
|
|
34
|
+
* `callerClock`, a commit at exactly its `notAfter` is stored at that time
|
|
35
|
+
* while one a millisecond later is refused. Every check uses fresh ids and
|
|
36
|
+
* owners, so the kit is safe on a shared database; each must finish within
|
|
37
|
+
* 30 seconds, so a handle that waits on another fails instead of hanging.
|
|
38
|
+
*/
|
|
39
|
+
export declare const sessionLogRecoveryConformance: (subject: SessionLogSubject) => ReadonlyArray<ConformanceCheck>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Effect, Exit, Fiber, Option, Scope } from "effect";
|
|
2
|
+
import { atTime, check, expect, fail, freshId, headerOf } from "../conformance.functions.js";
|
|
3
|
+
import { canonicalJson } from "../memory/memory-log.entity.functions.js";
|
|
4
|
+
const draft = (kind, data, turn = Option.none()) => ({ kind, turn, data }), noState = { notAfter: Option.none(), state: Option.none() }, everything = { after: 0, limit: Option.none(), kinds: [] }, range = (length) => Array.from({ length }, (_, n) => n), rich = { zeta: 1, alpha: { b: [!0, !1, { y: "\xFC", x: 0.5 }], a: "text \u2713" }, empty: {}, list: [] }, seen = (log, id) => Effect.all([log.head(id), log.read(id, everything)]).pipe(Effect.map(([head, events]) => ({
|
|
5
|
+
seq: head.seq,
|
|
6
|
+
revision: head.revision,
|
|
7
|
+
state: head.state,
|
|
8
|
+
events,
|
|
9
|
+
text: canonicalJson({
|
|
10
|
+
header: { ...head.header, parent: Option.getOrNull(head.header.parent) },
|
|
11
|
+
seq: head.seq,
|
|
12
|
+
revision: head.revision,
|
|
13
|
+
state: head.state,
|
|
14
|
+
updatedAt: head.updatedAt,
|
|
15
|
+
events: events.map((event) => ({ ...event, turn: Option.getOrNull(event.turn) }))
|
|
16
|
+
})
|
|
17
|
+
}))), listed = (log, owner) => log.list({ owner, limit: 10, before: Option.none(), parent: Option.none() }).pipe(Effect.map((heads) => canonicalJson(heads.map((head) => ({ id: head.header.id, seq: head.seq, revision: head.revision, updatedAt: head.updatedAt }))))), increment = (log, id, by, n, tries = 0) => Effect.gen(function* () {
|
|
18
|
+
const head = yield* log.head(id);
|
|
19
|
+
yield* Effect.yieldNow;
|
|
20
|
+
const count = typeof head.state.count === "number" ? head.state.count : 0, outcome = yield* Effect.result(log.commit(id, {
|
|
21
|
+
expect: head.revision,
|
|
22
|
+
notAfter: Option.none(),
|
|
23
|
+
events: [draft("increment", { by, n })],
|
|
24
|
+
state: Option.some({ count: count + 1 })
|
|
25
|
+
}));
|
|
26
|
+
if (outcome._tag === "Success")
|
|
27
|
+
return outcome.success.revision;
|
|
28
|
+
return outcome.failure._tag === "RevisionConflict" && tries < 100 ? yield* increment(log, id, by, n, tries + 1) : yield* Effect.fail(outcome.failure);
|
|
29
|
+
});
|
|
30
|
+
export const sessionLogRecoveryConformance = (subject) => {
|
|
31
|
+
const recovery = (name, id, body) => check(name, id, (owner) => Effect.timeoutOrElse(Effect.scoped(body(owner)), {
|
|
32
|
+
duration: "30 seconds",
|
|
33
|
+
orElse: () => fail(id, "the check did not finish in 30 seconds: a handle waits on another")
|
|
34
|
+
})), reading = (id) => Effect.scoped(subject.open.pipe(Effect.flatMap((log) => seen(log, id))));
|
|
35
|
+
return [
|
|
36
|
+
recovery("what a handle committed is all there after it closes and the store is opened again", "reopen", (owner) => Effect.gen(function* () {
|
|
37
|
+
const [id, other] = [yield* freshId, yield* freshId], before = yield* Effect.scoped(Effect.gen(function* () {
|
|
38
|
+
const log = yield* subject.open;
|
|
39
|
+
yield* log.create(headerOf(id, owner));
|
|
40
|
+
yield* log.create(headerOf(other, owner));
|
|
41
|
+
yield* log.commit(id, { expect: 0, notAfter: Option.none(), events: [draft("a", { n: 1 }, Option.some(1)), draft("b", rich, Option.some(1))], state: Option.some({ open: { turn: 1 } }) });
|
|
42
|
+
yield* log.commit(id, { expect: 1, ...noState, events: [draft("c", { n: 3 })] });
|
|
43
|
+
yield* log.commit(other, { expect: 0, notAfter: Option.none(), events: [], state: Option.some({ title: "other" }) });
|
|
44
|
+
return { session: yield* seen(log, id), listing: yield* listed(log, owner) };
|
|
45
|
+
}));
|
|
46
|
+
yield* expect("reopen", before.session.seq === 3 && before.session.revision === 2, `the writing handle read seq ${before.session.seq}, revision ${before.session.revision}`);
|
|
47
|
+
const after = yield* subject.open.pipe(Effect.flatMap((log) => Effect.all({ session: seen(log, id), listing: listed(log, owner) })));
|
|
48
|
+
yield* expect("reopen", after.session.text === before.session.text, `after reopening the session reads ${after.session.text}, not ${before.session.text}`);
|
|
49
|
+
yield* expect("reopen", after.listing === before.listing, `after reopening the listing is ${after.listing}, not ${before.listing}`);
|
|
50
|
+
})),
|
|
51
|
+
recovery("opening an existing store again is idempotent: twice more, nothing is lost and commits go on", "reopen-twice", (owner) => Effect.gen(function* () {
|
|
52
|
+
const id = yield* freshId, written = yield* Effect.scoped(Effect.gen(function* () {
|
|
53
|
+
const log = yield* subject.open;
|
|
54
|
+
yield* log.create(headerOf(id, owner));
|
|
55
|
+
yield* log.commit(id, { expect: 0, notAfter: Option.none(), events: [draft("a", { n: 1 })], state: Option.some({ n: 1 }) });
|
|
56
|
+
return yield* seen(log, id);
|
|
57
|
+
})), first = yield* reading(id), second = yield* reading(id);
|
|
58
|
+
yield* expect("reopen-twice", first.text === written.text && second.text === written.text, "opening the store again changed what it holds");
|
|
59
|
+
const next = yield* (yield* subject.open).commit(id, { expect: 1, ...noState, events: [draft("b", { n: 2 })] });
|
|
60
|
+
yield* expect("reopen-twice", next.revision === 2 && next.seq === 2 && next.events[0]?.seq === 2, `the next commit stored revision ${next.revision}, seq ${next.seq}`);
|
|
61
|
+
})),
|
|
62
|
+
recovery("two open handles see each other's commits at once, and a stale revision through either is refused", "cross-handle", (owner) => Effect.gen(function* () {
|
|
63
|
+
const id = yield* freshId, [a, b] = [yield* subject.open, yield* subject.open];
|
|
64
|
+
yield* a.create(headerOf(id, owner));
|
|
65
|
+
yield* expect("cross-handle", (yield* b.head(id)).revision === 0, "the other handle reads a revision for a new session");
|
|
66
|
+
yield* a.commit(id, { expect: 0, ...noState, events: [draft("a", { by: "a" })] });
|
|
67
|
+
const fromB = yield* seen(b, id);
|
|
68
|
+
yield* expect("cross-handle", fromB.revision === 1 && fromB.events.map((event) => event.kind).join() === "a", `the other handle reads revision ${fromB.revision}`);
|
|
69
|
+
yield* b.commit(id, { expect: 1, notAfter: Option.none(), events: [draft("b", { by: "b" })], state: Option.some({ by: "b" }) });
|
|
70
|
+
const fromA = yield* seen(a, id);
|
|
71
|
+
yield* expect("cross-handle", fromA.revision === 2 && fromA.events.map((event) => event.kind).join() === "a,b" && fromA.state.by === "b", `the first handle reads revision ${fromA.revision}`);
|
|
72
|
+
yield* expect("cross-handle", fromA.text === (yield* seen(b, id)).text, "the two handles read the session differently");
|
|
73
|
+
const stale = yield* Effect.flip(a.commit(id, { expect: 1, ...noState, events: [draft("stale", {})] }));
|
|
74
|
+
yield* expect("cross-handle", stale._tag === "RevisionConflict" && stale.actual === 2, `a stale commit gave ${stale._tag}`);
|
|
75
|
+
const listing = yield* b.list({ owner, limit: 10, before: Option.none(), parent: Option.none() });
|
|
76
|
+
yield* expect("cross-handle", listing.length === 1 && listing[0]?.revision === 2, "the other handle does not list the session as it is");
|
|
77
|
+
})),
|
|
78
|
+
recovery("of compare-and-swap commits racing through two handles, exactly one wins each revision, and none is lost or stored twice", "cross-handle-race", (owner) => Effect.gen(function* () {
|
|
79
|
+
const id = yield* freshId, handles = [yield* subject.open, yield* subject.open];
|
|
80
|
+
yield* handles[0].create(headerOf(id, owner));
|
|
81
|
+
const attempts = yield* Effect.forEach(range(8), (n) => Effect.result(handles[n % 2].commit(id, {
|
|
82
|
+
expect: 0,
|
|
83
|
+
notAfter: Option.none(),
|
|
84
|
+
events: [draft("attempt", { n })],
|
|
85
|
+
state: Option.some({ winner: n })
|
|
86
|
+
})), { concurrency: "unbounded" }), won = attempts.filter((attempt) => attempt._tag === "Success"), refused = attempts.filter((attempt) => attempt._tag === "Failure" && attempt.failure._tag === "RevisionConflict");
|
|
87
|
+
yield* expect("cross-handle-race", won.length === 1 && refused.length === 7, `of eight commits against one revision, ${won.length} won and ${refused.length} were refused`);
|
|
88
|
+
const rounds = 12, revisions = (yield* Effect.forEach(handles, (log, side) => Effect.forEach(range(rounds), (n) => increment(log, id, `handle-${side}`, n)), { concurrency: "unbounded" })).flat().toSorted((left, right) => left - right);
|
|
89
|
+
yield* expect("cross-handle-race", revisions.join() === range(rounds * 2).map((n) => n + 2).join(), `the increments stored revisions ${revisions.join()}`);
|
|
90
|
+
const final = yield* reading(id), increments = final.events.filter((event) => event.kind === "increment");
|
|
91
|
+
yield* expect("cross-handle-race", final.revision === rounds * 2 + 1 && final.seq === rounds * 2 + 1 && final.state.count === rounds * 2, `after ${rounds * 2} increments: revision ${final.revision}, seq ${final.seq}, count ${JSON.stringify(final.state.count)}`);
|
|
92
|
+
yield* expect("cross-handle-race", handles.every((_, side) => increments.filter((event) => event.data.by === `handle-${side}`).map((event) => event.data.n).join() === range(rounds).join()), "an increment was lost, stored twice or out of order");
|
|
93
|
+
})),
|
|
94
|
+
recovery("an interrupted commit leaves all of its events or none, and the session takes the next commit", "interrupted-commit", (owner) => Effect.gen(function* () {
|
|
95
|
+
const id = yield* freshId, parts = 40, before = yield* Effect.scoped(Effect.gen(function* () {
|
|
96
|
+
const log = yield* subject.open;
|
|
97
|
+
yield* log.create(headerOf(id, owner));
|
|
98
|
+
yield* Effect.forEach([0, 1, 4], (yields, batch) => Effect.gen(function* () {
|
|
99
|
+
const head = yield* log.head(id), fiber = yield* Effect.forkChild(log.commit(id, {
|
|
100
|
+
expect: head.revision,
|
|
101
|
+
notAfter: Option.none(),
|
|
102
|
+
events: range(parts).map((n) => draft("part", { batch, n })),
|
|
103
|
+
state: Option.some({ batch })
|
|
104
|
+
}));
|
|
105
|
+
yield* Effect.forEach(range(yields), () => Effect.yieldNow, { discard: !0 });
|
|
106
|
+
yield* Fiber.interrupt(fiber);
|
|
107
|
+
const after = yield* seen(log, id), written = after.events.filter((event) => event.data.batch === batch).length, whole = written === parts && after.revision === head.revision + 1 && canonicalJson(after.state) === canonicalJson({ batch }), nothing = written === 0 && after.revision === head.revision && after.seq === head.seq;
|
|
108
|
+
yield* expect("interrupted-commit", whole || nothing, `an interrupted commit left ${written} of its ${parts} events at revision ${after.revision}`);
|
|
109
|
+
yield* expect("interrupted-commit", after.events.every((event, index) => event.seq === index + 1) && after.seq === after.events.length, "the sequence has a gap");
|
|
110
|
+
}), { discard: !0 });
|
|
111
|
+
return yield* seen(log, id);
|
|
112
|
+
})), log = yield* subject.open, reopened = yield* seen(log, id);
|
|
113
|
+
yield* expect("interrupted-commit", reopened.text === before.text, "after reopening the session differs from what the interrupted handle read");
|
|
114
|
+
const next = yield* log.commit(id, { expect: reopened.revision, ...noState, events: [draft("next", {})] });
|
|
115
|
+
yield* expect("interrupted-commit", next.revision === reopened.revision + 1 && next.seq === reopened.seq + 1, "the commit after the interrupted ones was not stored next");
|
|
116
|
+
})),
|
|
117
|
+
recovery("a handle whose scope never closed blocks nothing: a new handle reads all it committed and commits after it", "unclosed-handle", (owner) => Effect.gen(function* () {
|
|
118
|
+
const id = yield* freshId, abandoned = yield* Scope.make();
|
|
119
|
+
yield* Effect.addFinalizer(() => Scope.close(abandoned, Exit.void));
|
|
120
|
+
const left = yield* subject.open.pipe(Scope.provide(abandoned));
|
|
121
|
+
yield* left.create(headerOf(id, owner));
|
|
122
|
+
yield* left.commit(id, { expect: 0, notAfter: Option.none(), events: [draft("a", { n: 1 }), draft("b", { n: 2 })], state: Option.some({ by: "abandoned" }) });
|
|
123
|
+
const wrote = yield* seen(left, id), continued = yield* Effect.scoped(Effect.gen(function* () {
|
|
124
|
+
const log = yield* subject.open;
|
|
125
|
+
yield* expect("unclosed-handle", (yield* seen(log, id)).text === wrote.text, "a new handle does not read what the unclosed one committed");
|
|
126
|
+
yield* log.commit(id, { expect: 1, ...noState, events: [draft("c", { n: 3 })] });
|
|
127
|
+
return yield* seen(log, id);
|
|
128
|
+
})), again = yield* reading(id);
|
|
129
|
+
yield* expect("unclosed-handle", continued.seq === 3 && continued.revision === 2 && again.text === continued.text, `after the unclosed handle: seq ${again.seq}, revision ${again.revision}`);
|
|
130
|
+
})),
|
|
131
|
+
...subject.callerClock === !0 ? [
|
|
132
|
+
recovery("with the caller's clock, a commit at exactly its notAfter is stored at that time, and one a millisecond later is refused", "expiry-boundary", (owner) => Effect.gen(function* () {
|
|
133
|
+
const id = yield* freshId, log = yield* subject.open, deadline = (yield* log.create(headerOf(id, owner))).now + 60000, onTime = yield* atTime(deadline)(log.commit(id, { expect: 0, notAfter: Option.some(deadline), events: [draft("on-time", {})], state: Option.none() }));
|
|
134
|
+
yield* expect("expiry-boundary", onTime.at === deadline && onTime.events.every((event) => event.at === deadline), `a commit at its notAfter was stored at ${onTime.at}, not ${deadline}`);
|
|
135
|
+
const late = yield* Effect.flip(atTime(deadline + 1)(log.commit(id, { expect: 1, notAfter: Option.some(deadline), events: [draft("late", {})], state: Option.some({ late: !0 }) })));
|
|
136
|
+
yield* expect("expiry-boundary", late._tag === "LeaseExpired" && late.notAfter === deadline && late.now === deadline + 1, `a commit a millisecond late gave ${JSON.stringify(late)}`);
|
|
137
|
+
const head = yield* atTime(deadline + 1)(log.head(id));
|
|
138
|
+
yield* expect("expiry-boundary", head.revision === 1 && head.seq === 1 && head.updatedAt === deadline && canonicalJson(head.state) === "{}", "the late commit wrote something");
|
|
139
|
+
yield* expect("expiry-boundary", head.now === deadline + 1, `the head was read at ${head.now}, not at the caller's ${deadline + 1}`);
|
|
140
|
+
}))
|
|
141
|
+
] : []
|
|
142
|
+
];
|
|
143
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Effect, Option, Scope } from "effect";
|
|
2
|
+
import type { Context } from "effect";
|
|
3
|
+
import type { ConformanceCheck } from "../conformance.entity.js";
|
|
4
|
+
import type { TurnActivity } from "../ports/activity.port.js";
|
|
5
|
+
import type { Sessions } from "../ports/sessions.port.js";
|
|
6
|
+
import { TurnAdmission } from "../ports/turn-admission.port.js";
|
|
7
|
+
type Service = Context.Service.Shape<typeof Sessions>;
|
|
8
|
+
type Admission = Context.Service.Shape<typeof TurnAdmission>;
|
|
9
|
+
/** One `Sessions` instance as the kit builds it. */
|
|
10
|
+
export interface SessionsInstance {
|
|
11
|
+
readonly sessions: Service;
|
|
12
|
+
/** Its `TurnActivity` (the turns it has in flight), when it provides one: then checked against what it opens. */
|
|
13
|
+
readonly activity: Option.Option<Context.Service.Shape<typeof TurnActivity>>;
|
|
14
|
+
}
|
|
15
|
+
/** A process as the kit names one, for process ownership: its host, its pid and when it started. */
|
|
16
|
+
export interface SessionsProcess {
|
|
17
|
+
readonly host: string;
|
|
18
|
+
readonly pid: number;
|
|
19
|
+
readonly startedAt: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A Sessions backend as the kit builds it. Every instance runs over the same
|
|
23
|
+
* store, as one per server would; the kit opens several at once, and uses
|
|
24
|
+
* fresh owners and session ids in every check, so the store may be shared.
|
|
25
|
+
*/
|
|
26
|
+
export interface SessionsSubject {
|
|
27
|
+
/**
|
|
28
|
+
* Another instance over the same store each time it runs, released with
|
|
29
|
+
* its scope, whose turns `admission` admits and which holds its turns by
|
|
30
|
+
* a lease of `ttlMs` of storage time from their begin, renewed by each of
|
|
31
|
+
* their writes and by nothing else (no keep-alive).
|
|
32
|
+
*/
|
|
33
|
+
readonly leased: (options: {
|
|
34
|
+
readonly ttlMs: number;
|
|
35
|
+
readonly admission: Admission;
|
|
36
|
+
}) => Effect.Effect<SessionsInstance, unknown, Scope.Scope>;
|
|
37
|
+
/**
|
|
38
|
+
* The same, holding its turns for as long as the process that began them
|
|
39
|
+
* runs: the instance runs in `self`, and of the pids of its host only
|
|
40
|
+
* `running` run (the liveness check it asks). Leave it out for a backend
|
|
41
|
+
* without process ownership: the dead-holder check is then not run.
|
|
42
|
+
*/
|
|
43
|
+
readonly processOwned?: (options: {
|
|
44
|
+
readonly self: SessionsProcess;
|
|
45
|
+
readonly running: ReadonlyArray<number>;
|
|
46
|
+
readonly admission: Admission;
|
|
47
|
+
}) => Effect.Effect<SessionsInstance, unknown, Scope.Scope>;
|
|
48
|
+
/** Move the clock the store and the instances read forward by `millis`: `TestClock.adjust`, or a sleep on a live clock. */
|
|
49
|
+
readonly advance: (millis: number) => Effect.Effect<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The Sessions contract, as checks any backend must pass: sessions created,
|
|
53
|
+
* read only by their owner, listed in pages and forked; one open turn per
|
|
54
|
+
* session (`SessionBusy`, on any instance, and exactly one winner of a
|
|
55
|
+
* race); a key's retry is the same turn (`TurnDuplicate`, whatever the
|
|
56
|
+
* `meta`) and anything else under it a `KeyConflict`; `meta` stored,
|
|
57
|
+
* admitted and shown to the host's admission; the resume rules and every
|
|
58
|
+
* `NotResumable` reason; an image its bytes do not support refused with
|
|
59
|
+
* `AttachmentRejected` and an inbox turn with nothing to take with
|
|
60
|
+
* `NothingPending`, opening nothing; the writer storing in order and its
|
|
61
|
+
* end storing how the turn ended (completed, failed with a code, a scope
|
|
62
|
+
* closed unended or interrupted); writing outside a turn and the reserved
|
|
63
|
+
* kinds; cancel and removal closing the writer; the inbox (delivered once
|
|
64
|
+
* per id, taken by one inbox turn, waiting for the open turn, given back
|
|
65
|
+
* when its turn is interrupted from outside, done when its turn is
|
|
66
|
+
* cancelled); a turn whose lease ran out shown closed, left alone by a
|
|
67
|
+
* drain with nothing to take and reaped by the next begin; a write renewing
|
|
68
|
+
* the lease from that write; with `processOwned`, a turn whose process
|
|
69
|
+
* stopped reaped, and one whose process runs held; and, for an instance
|
|
70
|
+
* with a `TurnActivity`, its activity agreeing with what is open, per
|
|
71
|
+
* session and turn, a begin counting from the moment it is asked for (an
|
|
72
|
+
* instance without one passes that check unchecked).
|
|
73
|
+
*
|
|
74
|
+
* Checks that move the clock (`advance`) run leases of a second: give the
|
|
75
|
+
* kit a clock it can move at once (`TestClock`) or expect them to take
|
|
76
|
+
* seconds. Run each check under the clock `advance` moves.
|
|
77
|
+
*/
|
|
78
|
+
export declare const sessionsConformance: (subject: SessionsSubject) => ReadonlyArray<ConformanceCheck>;
|
|
79
|
+
export {};
|