@frockbot/kernel-do 0.0.0 → 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.
@@ -0,0 +1,189 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ // The Durable Object's half of the durable-root generation ledger.
3
+ //
4
+ // "The Workspace and its object-storage twin are the only durable state
5
+ // outside a Durable Object. They hold files, never authority: a Durable Object
6
+ // records every intent, effect, and generation that concerns them." Object
7
+ // storage holds the bytes; this module holds the authority — which generation
8
+ // those bytes are, who wrote them, which entity tag a conditional write must
9
+ // match, which losing writes were preserved, and which files were deleted.
10
+ //
11
+ // It implements `WorkspaceGenerationsV1`, declared in `@frockbot/kernel-
12
+ // contracts`, and knows nothing about object storage: the store that consumes
13
+ // it supplies etags and conflict keys as opaque strings. That is deliberate.
14
+ // The Bot Durable Object owns its own roots; "The User's Durable Object is the
15
+ // authority for ... the generation records of User and Project Memory roots",
16
+ // and the same class serves either — the owner is whichever object constructs
17
+ // it.
18
+ //
19
+ // A minted generation id is sortable and strictly increasing across eviction:
20
+ // `<milliseconds>-<sequence>`, both zero-padded, with the milliseconds never
21
+ // allowed to move backwards. Ordering generations is the one thing the id is
22
+ // for, so a clock that jumps backwards must not be able to reorder them.
23
+ import {
24
+ decodeWorkspaceGenerationRecordV1,
25
+ workspaceRootKeyV1,
26
+ type WorkspaceGenerationRecordV1,
27
+ type WorkspaceGenerationsV1,
28
+ type WorkspaceRootV1,
29
+ } from "@frockbot/kernel-contracts";
30
+ import {
31
+ WORKSPACE_GENERATION_CURSOR_KEY,
32
+ workspaceConflictKey,
33
+ workspaceConflictPrefix,
34
+ workspaceGenerationKey,
35
+ } from "./storage-keys.js";
36
+
37
+ /** Most preserved losing writes returned for one file in a single read. */
38
+ export const MAX_WORKSPACE_CONFLICT_PAGE = 100;
39
+
40
+ interface GenerationCursor {
41
+ millis: number;
42
+ sequence: number;
43
+ }
44
+
45
+ function decodeCursor(input: unknown): GenerationCursor {
46
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
47
+ return { millis: 0, sequence: 0 };
48
+ }
49
+ const value = input as Record<string, unknown>;
50
+ const millis = value.millis;
51
+ const sequence = value.sequence;
52
+ if (
53
+ !Number.isSafeInteger(millis) ||
54
+ (millis as number) < 0 ||
55
+ !Number.isSafeInteger(sequence) ||
56
+ (sequence as number) < 0
57
+ ) {
58
+ throw new Error("workspace generation cursor is invalid");
59
+ }
60
+ return { millis: millis as number, sequence: sequence as number };
61
+ }
62
+
63
+ export interface DurableWorkspaceGenerationsOptions {
64
+ state: DurableObjectState;
65
+ }
66
+
67
+ /**
68
+ * `WorkspaceGenerationsV1` over one Durable Object's storage, under the
69
+ * `workspace:` key prefixes.
70
+ */
71
+ export class DurableWorkspaceGenerations implements WorkspaceGenerationsV1 {
72
+ private readonly ctx: DurableObjectState;
73
+ /** The cursor, cached while resident; storage remains the authority. */
74
+ private cursor: GenerationCursor | undefined;
75
+ /** Serializes minting, so no two mints can read one cursor. */
76
+ private minting: Promise<void> = Promise.resolve();
77
+
78
+ constructor(options: DurableWorkspaceGenerationsOptions) {
79
+ this.ctx = options.state;
80
+ }
81
+
82
+ /**
83
+ * The `root` is accepted and unused: this ledger *is* one authority, so
84
+ * every id it mints already orders against every other. Routing a root to
85
+ * the object that owns it happens above, in the Worker.
86
+ *
87
+ * Minting is serialized through `minting`. The cursor is only assigned after
88
+ * an `await` on storage, so two mints that begin before either has read —
89
+ * the ordinary case on a cold object, where nothing is cached — would both
90
+ * read the same cursor and return the same id. Two files would then claim
91
+ * one generation, which is the one thing a generation id exists to prevent.
92
+ */
93
+ async mint(at: Date, _root?: WorkspaceRootV1): Promise<string> {
94
+ const minted = this.minting.then(
95
+ () => this.mintOne(at),
96
+ () => this.mintOne(at),
97
+ );
98
+ this.minting = minted.then(
99
+ () => undefined,
100
+ () => undefined,
101
+ );
102
+ return minted;
103
+ }
104
+
105
+ private async mintOne(at: Date): Promise<string> {
106
+ const stored =
107
+ this.cursor ??
108
+ decodeCursor(
109
+ await this.ctx.storage.get<unknown>(WORKSPACE_GENERATION_CURSOR_KEY),
110
+ );
111
+ const millis = Math.max(at.getTime(), stored.millis);
112
+ const next: GenerationCursor = {
113
+ millis,
114
+ sequence: millis === stored.millis ? stored.sequence + 1 : 1,
115
+ };
116
+ this.cursor = next;
117
+ await this.ctx.storage.put(WORKSPACE_GENERATION_CURSOR_KEY, next);
118
+ return `${next.millis.toString().padStart(15, "0")}-${next.sequence
119
+ .toString()
120
+ .padStart(9, "0")}`;
121
+ }
122
+
123
+ async current(
124
+ root: WorkspaceRootV1,
125
+ path: string,
126
+ ): Promise<WorkspaceGenerationRecordV1 | undefined> {
127
+ const stored = await this.ctx.storage.get<unknown>(
128
+ workspaceGenerationKey(workspaceRootKeyV1(root), path),
129
+ );
130
+ if (stored === undefined) return undefined;
131
+ return decodeWorkspaceGenerationRecordV1(stored);
132
+ }
133
+
134
+ async record(entry: WorkspaceGenerationRecordV1): Promise<void> {
135
+ const decoded = decodeWorkspaceGenerationRecordV1(entry);
136
+ await this.ctx.storage.put(
137
+ workspaceGenerationKey(workspaceRootKeyV1(decoded.root), decoded.path),
138
+ decoded,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * A deletion is a recorded generation like any other. Object storage forgets
144
+ * a deleted key, so without this record nothing durable would say the file
145
+ * was removed, by whom, or when — the recovery question a Durable Object
146
+ * exists to answer.
147
+ */
148
+ async tombstone(entry: WorkspaceGenerationRecordV1): Promise<void> {
149
+ const decoded = decodeWorkspaceGenerationRecordV1({
150
+ ...entry,
151
+ deleted: true,
152
+ });
153
+ await this.ctx.storage.put(
154
+ workspaceGenerationKey(workspaceRootKeyV1(decoded.root), decoded.path),
155
+ decoded,
156
+ );
157
+ }
158
+
159
+ /**
160
+ * A losing write, preserved beside the winner. It is stored under its own
161
+ * key rather than replacing the current record, so "preserved as a
162
+ * conflicting generation and surfaced, never merged or dropped" is what the
163
+ * storage layout itself says.
164
+ */
165
+ async conflict(entry: WorkspaceGenerationRecordV1): Promise<void> {
166
+ const decoded = decodeWorkspaceGenerationRecordV1(entry);
167
+ await this.ctx.storage.put(
168
+ workspaceConflictKey(
169
+ workspaceRootKeyV1(decoded.root),
170
+ decoded.path,
171
+ decoded.generation.generationId,
172
+ ),
173
+ decoded,
174
+ );
175
+ }
176
+
177
+ async conflicts(
178
+ root: WorkspaceRootV1,
179
+ path: string,
180
+ ): Promise<WorkspaceGenerationRecordV1[]> {
181
+ const stored = await this.ctx.storage.list<unknown>({
182
+ prefix: workspaceConflictPrefix(workspaceRootKeyV1(root), path),
183
+ limit: MAX_WORKSPACE_CONFLICT_PAGE,
184
+ });
185
+ return [...stored.values()].map((value) =>
186
+ decodeWorkspaceGenerationRecordV1(value),
187
+ );
188
+ }
189
+ }
@@ -0,0 +1,103 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ // The Durable Object's half of the durable-root sync's effect records.
3
+ //
4
+ // Constitution, Computer and Workspace: "Computer effects are reconcilable. A
5
+ // mutation or process launch records intent and an effect identifier in the
6
+ // Bot's Durable Object and in the Workspace before it runs, so recovery can
7
+ // read its outcome or classify it as unknown without repeating it." The sync
8
+ // agent lives in a Computer provider Package and holds no authority of its
9
+ // own, so the record it depends on belongs here, in the object that owns the
10
+ // root — the same division `DurableWorkspaceGenerations` makes for generations.
11
+ //
12
+ // It is the same two-key shape the Bot's other effects use (`authorship:
13
+ // intent:` / `authorship:artifact:`), reduced to one key: an intent is written
14
+ // before the push and removed when the push settles, so "the key is present"
15
+ // *is* "this effect was recorded and never reported back". Recovery never
16
+ // repeats the write — the sync reads what the store actually holds for that
17
+ // path and adopts the generation it finds.
18
+ //
19
+ // Growth is bounded by settlement in the ordinary case. A connection that
20
+ // drops mid-push (which happens on every Computer pause) leaves an intent
21
+ // behind, so the store also caps how many unsettled intents it keeps: past the
22
+ // cap the oldest are dropped, because an intent so old that the sync has run
23
+ // many times since carries no information the store cannot re-derive.
24
+ import {
25
+ decodeWorkspaceSyncEffectV1,
26
+ type WorkspaceSyncEffectV1,
27
+ type WorkspaceSyncEffectsV1,
28
+ } from "@frockbot/kernel-contracts";
29
+ import {
30
+ WORKSPACE_SYNC_EFFECT_PREFIX,
31
+ workspaceSyncEffectKey,
32
+ } from "./storage-keys.js";
33
+
34
+ /** Most unsettled push intents one object keeps before dropping the oldest. */
35
+ export const MAX_WORKSPACE_SYNC_EFFECTS = 512;
36
+
37
+ export interface DurableWorkspaceSyncEffectsOptions {
38
+ state: DurableObjectState;
39
+ /** Overrides the retention cap; the default is the constant above. */
40
+ maximum?: number;
41
+ }
42
+
43
+ /** `WorkspaceSyncEffectsV1` over one Durable Object's storage. */
44
+ export class DurableWorkspaceSyncEffects implements WorkspaceSyncEffectsV1 {
45
+ private readonly ctx: DurableObjectState;
46
+ private readonly maximum: number;
47
+
48
+ constructor(options: DurableWorkspaceSyncEffectsOptions) {
49
+ this.ctx = options.state;
50
+ this.maximum = options.maximum ?? MAX_WORKSPACE_SYNC_EFFECTS;
51
+ }
52
+
53
+ async intent(effect: WorkspaceSyncEffectV1): Promise<void> {
54
+ const decoded = decodeWorkspaceSyncEffectV1(effect);
55
+ await this.ctx.storage.put(
56
+ workspaceSyncEffectKey(decoded.effectId),
57
+ decoded,
58
+ );
59
+ await this.trim();
60
+ }
61
+
62
+ async settle(effect: WorkspaceSyncEffectV1): Promise<void> {
63
+ await this.ctx.storage.delete(workspaceSyncEffectKey(effect.effectId));
64
+ }
65
+
66
+ async pending(effectId: string): Promise<WorkspaceSyncEffectV1 | undefined> {
67
+ const stored = await this.ctx.storage.get<unknown>(
68
+ workspaceSyncEffectKey(effectId),
69
+ );
70
+ if (stored === undefined) return undefined;
71
+ return decodeWorkspaceSyncEffectV1(stored);
72
+ }
73
+
74
+ /** Every intent this object still holds unsettled, oldest first. */
75
+ async unsettled(): Promise<WorkspaceSyncEffectV1[]> {
76
+ const stored = await this.ctx.storage.list<unknown>({
77
+ prefix: WORKSPACE_SYNC_EFFECT_PREFIX,
78
+ });
79
+ return [...stored.values()]
80
+ .map((value) => decodeWorkspaceSyncEffectV1(value))
81
+ .sort((left, right) => left.at.localeCompare(right.at));
82
+ }
83
+
84
+ private async trim(): Promise<void> {
85
+ const stored = await this.ctx.storage.list<unknown>({
86
+ prefix: WORKSPACE_SYNC_EFFECT_PREFIX,
87
+ });
88
+ if (stored.size <= this.maximum) return;
89
+ const ordered = [...stored.entries()]
90
+ .map(([key, value]) => {
91
+ try {
92
+ return { key, at: decodeWorkspaceSyncEffectV1(value).at };
93
+ } catch {
94
+ // An undecodable record is the oldest thing there is: drop it first.
95
+ return { key, at: "" };
96
+ }
97
+ })
98
+ .sort((left, right) => left.at.localeCompare(right.at));
99
+ for (const entry of ordered.slice(0, stored.size - this.maximum)) {
100
+ await this.ctx.storage.delete(entry.key);
101
+ }
102
+ }
103
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM"],
12
+ "types": ["bun", "node", "@cloudflare/workers-types"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/kernel-do
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.