@frockbot/kernel-do 0.0.0 → 0.1.1
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/package.json +25 -6
- package/src/authority.ts +989 -0
- package/src/composition-failures.test.ts +405 -0
- package/src/composition-failures.ts +108 -0
- package/src/composition-store.test.ts +497 -0
- package/src/composition-store.ts +473 -0
- package/src/index.ts +10 -0
- package/src/memory-storage.fixture.ts +61 -0
- package/src/run-records.ts +624 -0
- package/src/run-recovery.ts +163 -0
- package/src/run-terminal.ts +220 -0
- package/src/storage-keys.ts +129 -0
- package/src/turn-admission.test.ts +450 -0
- package/src/turn-errors.ts +33 -0
- package/src/workspace-generations.test.ts +228 -0
- package/src/workspace-generations.ts +189 -0
- package/src/workspace-sync-effects.ts +103 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -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