@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `user_kv` table access in the USER'S OWN SQLite database — the Workers
|
|
3
|
+
* implementation of `ctx.kv` (`UserKvSurface`). Rows live in the owner file,
|
|
4
|
+
* so plugin state written here survives the object being evicted and
|
|
5
|
+
* restored from the owner copy, and leaves with the file when it is exported
|
|
6
|
+
* or deleted.
|
|
7
|
+
*
|
|
8
|
+
* user_kv(namespace, key, value, bytes, idle_ttl_ms, expires_at, touched_seq)
|
|
9
|
+
*
|
|
10
|
+
* `value` is JSON text; `bytes` the row's UTF-8 size — namespace, key and
|
|
11
|
+
* value together (`userKvRowBytes`) — the unit of the store-wide byte cap
|
|
12
|
+
* (`USER_KV_LIMITS` in `core/user-kv.ts`). Every write checks the caps
|
|
13
|
+
* inside its own transaction. `expires_at` (ms epoch) is null for entries written
|
|
14
|
+
* without an idle TTL. `touched_seq` is a store-wide counter bumped on every
|
|
15
|
+
* read and write of a row: the least-recently-used entry of a namespace is
|
|
16
|
+
* its lowest `touched_seq`. A counter rather than a timestamp keeps the order
|
|
17
|
+
* total when several touches land in the same millisecond.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
assertUserKvName,
|
|
21
|
+
assertUserKvOptions,
|
|
22
|
+
assertUserKvValueSize,
|
|
23
|
+
decodeUserKvValue,
|
|
24
|
+
encodeUserKvValue,
|
|
25
|
+
resolveUserKvLimits,
|
|
26
|
+
userKvOverflow,
|
|
27
|
+
userKvRowBytes,
|
|
28
|
+
userKvValueBytes,
|
|
29
|
+
type UserKvLimits,
|
|
30
|
+
type UserKvUsage,
|
|
31
|
+
} from '../core/user-kv';
|
|
32
|
+
import type { UserKvSurface, UserKvWriteOptions } from '../plugin-api/types';
|
|
33
|
+
import type { DoSqliteDatabase } from './database';
|
|
34
|
+
|
|
35
|
+
type KvRow = {
|
|
36
|
+
value: string;
|
|
37
|
+
idle_ttl_ms: number | bigint | null;
|
|
38
|
+
expires_at: number | bigint | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type CountRow = { n: number | bigint };
|
|
42
|
+
type TotalsRow = { n: number | bigint; b: number | bigint };
|
|
43
|
+
|
|
44
|
+
const NEXT_SEQ = `(SELECT COALESCE(MAX(touched_seq), 0) + 1 FROM user_kv)`;
|
|
45
|
+
|
|
46
|
+
export class SqliteUserKv implements UserKvSurface {
|
|
47
|
+
private setupPromise: Promise<void> | undefined;
|
|
48
|
+
private readonly now: () => number;
|
|
49
|
+
private readonly limits: Readonly<UserKvLimits>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `limits` overrides the hard caps for tests; the host constructs the
|
|
53
|
+
* store without it, so the caps in force are `USER_KV_LIMITS`.
|
|
54
|
+
*/
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly db: DoSqliteDatabase,
|
|
57
|
+
options: { now?: () => number; limits?: Partial<UserKvLimits> } = {},
|
|
58
|
+
) {
|
|
59
|
+
this.now = options.now ?? Date.now;
|
|
60
|
+
this.limits = resolveUserKvLimits(options.limits);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create the table + indexes, or bring a table from an older build up to
|
|
65
|
+
* the current shape. Idempotent, cached like `TasksStore`.
|
|
66
|
+
*/
|
|
67
|
+
setup(): Promise<void> {
|
|
68
|
+
this.setupPromise ??= this.doSetup().catch((error: unknown) => {
|
|
69
|
+
this.setupPromise = undefined;
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
return this.setupPromise;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async doSetup(): Promise<void> {
|
|
76
|
+
await this.db.run(`
|
|
77
|
+
CREATE TABLE IF NOT EXISTS user_kv (
|
|
78
|
+
namespace TEXT NOT NULL,
|
|
79
|
+
key TEXT NOT NULL,
|
|
80
|
+
value TEXT NOT NULL,
|
|
81
|
+
bytes INTEGER NOT NULL,
|
|
82
|
+
idle_ttl_ms INTEGER,
|
|
83
|
+
expires_at INTEGER,
|
|
84
|
+
touched_seq INTEGER NOT NULL,
|
|
85
|
+
PRIMARY KEY (namespace, key)
|
|
86
|
+
)`);
|
|
87
|
+
await this.addBytesColumn();
|
|
88
|
+
await this.db.run(
|
|
89
|
+
`CREATE INDEX IF NOT EXISTS idx_user_kv_recency ON user_kv(namespace, touched_seq)`,
|
|
90
|
+
);
|
|
91
|
+
// Every touch computes MAX(touched_seq) over the whole table.
|
|
92
|
+
await this.db.run(
|
|
93
|
+
`CREATE INDEX IF NOT EXISTS idx_user_kv_seq ON user_kv(touched_seq)`,
|
|
94
|
+
);
|
|
95
|
+
// Covers the store-wide COUNT(*) + SUM(bytes) of the cap check, so it
|
|
96
|
+
// reads the index rather than every value.
|
|
97
|
+
await this.db.run(
|
|
98
|
+
`CREATE INDEX IF NOT EXISTS idx_user_kv_bytes ON user_kv(bytes)`,
|
|
99
|
+
);
|
|
100
|
+
// The store-wide sweep of expired rows before a write is refused.
|
|
101
|
+
await this.db.run(
|
|
102
|
+
`CREATE INDEX IF NOT EXISTS idx_user_kv_expiry ON user_kv(expires_at)`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A table created before the caps has no `bytes`: add it and fill it from
|
|
108
|
+
* the stored names and JSON in one transaction, so a column that exists is always
|
|
109
|
+
* filled. SQLite only adds a NOT NULL column with a default; every write
|
|
110
|
+
* sets it explicitly.
|
|
111
|
+
*/
|
|
112
|
+
private async addBytesColumn(): Promise<void> {
|
|
113
|
+
const columns = await this.db.exec<{ name: string }>(
|
|
114
|
+
`PRAGMA table_info(user_kv)`,
|
|
115
|
+
);
|
|
116
|
+
if (columns.some((column) => column.name === 'bytes')) return;
|
|
117
|
+
await this.db.transaction(async () => {
|
|
118
|
+
await this.db.run(
|
|
119
|
+
`ALTER TABLE user_kv ADD COLUMN bytes INTEGER NOT NULL DEFAULT 0`,
|
|
120
|
+
);
|
|
121
|
+
await this.db.run(
|
|
122
|
+
`UPDATE user_kv SET bytes = length(CAST(namespace AS BLOB))
|
|
123
|
+
+ length(CAST(key AS BLOB)) + length(CAST(value AS BLOB))`,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async get(namespace: string, key: string): Promise<unknown> {
|
|
129
|
+
assertUserKvName('namespace', namespace);
|
|
130
|
+
assertUserKvName('key', key);
|
|
131
|
+
await this.setup();
|
|
132
|
+
return this.db.transaction(async () => {
|
|
133
|
+
const json = await this.touch(namespace, key);
|
|
134
|
+
return json === undefined ? undefined : decodeUserKvValue(json);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async set(
|
|
139
|
+
namespace: string,
|
|
140
|
+
key: string,
|
|
141
|
+
value: unknown,
|
|
142
|
+
options: UserKvWriteOptions = {},
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const json = encodeUserKvValue(value);
|
|
145
|
+
await this.update(namespace, key, () => decodeUserKvValue(json), options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async update(
|
|
149
|
+
namespace: string,
|
|
150
|
+
key: string,
|
|
151
|
+
fn: (current: unknown) => unknown,
|
|
152
|
+
options: UserKvWriteOptions = {},
|
|
153
|
+
): Promise<unknown> {
|
|
154
|
+
assertUserKvName('namespace', namespace);
|
|
155
|
+
assertUserKvName('key', key);
|
|
156
|
+
assertUserKvOptions(options);
|
|
157
|
+
await this.setup();
|
|
158
|
+
return this.db.transaction(async () => {
|
|
159
|
+
const current = await this.touch(namespace, key);
|
|
160
|
+
const next = fn(
|
|
161
|
+
current === undefined ? undefined : decodeUserKvValue(current),
|
|
162
|
+
);
|
|
163
|
+
if (next === undefined) {
|
|
164
|
+
await this.db.run(
|
|
165
|
+
`DELETE FROM user_kv WHERE namespace = ? AND key = ?`,
|
|
166
|
+
[namespace, key],
|
|
167
|
+
);
|
|
168
|
+
if (options.maxEntries !== undefined) {
|
|
169
|
+
await this.trim(namespace, options.maxEntries);
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const json = encodeUserKvValue(next);
|
|
174
|
+
const valueBytes = assertUserKvValueSize(namespace, json, this.limits);
|
|
175
|
+
const ttl = options.idleTtlMs ?? null;
|
|
176
|
+
await this.db.run(
|
|
177
|
+
`INSERT INTO user_kv (namespace, key, value, bytes, idle_ttl_ms, expires_at, touched_seq)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?, ${NEXT_SEQ})
|
|
179
|
+
ON CONFLICT (namespace, key) DO UPDATE SET
|
|
180
|
+
value = excluded.value,
|
|
181
|
+
bytes = excluded.bytes,
|
|
182
|
+
idle_ttl_ms = excluded.idle_ttl_ms,
|
|
183
|
+
expires_at = excluded.expires_at,
|
|
184
|
+
touched_seq = excluded.touched_seq`,
|
|
185
|
+
[
|
|
186
|
+
namespace,
|
|
187
|
+
key,
|
|
188
|
+
json,
|
|
189
|
+
userKvRowBytes(namespace, key, valueBytes),
|
|
190
|
+
ttl,
|
|
191
|
+
ttl === null ? null : this.now() + ttl,
|
|
192
|
+
],
|
|
193
|
+
);
|
|
194
|
+
if (options.maxEntries !== undefined) {
|
|
195
|
+
await this.trim(namespace, options.maxEntries);
|
|
196
|
+
}
|
|
197
|
+
// `touch` deleted an expired row, so `current` is the live row this
|
|
198
|
+
// write replaced, if any. Namespace and key are the same, so the row
|
|
199
|
+
// grew exactly when its value did.
|
|
200
|
+
await this.enforceCaps(namespace, {
|
|
201
|
+
entries: current === undefined,
|
|
202
|
+
bytes:
|
|
203
|
+
valueBytes > (current === undefined ? 0 : userKvValueBytes(current)),
|
|
204
|
+
});
|
|
205
|
+
return decodeUserKvValue(json);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async delete(namespace: string, key: string): Promise<void> {
|
|
210
|
+
assertUserKvName('namespace', namespace);
|
|
211
|
+
assertUserKvName('key', key);
|
|
212
|
+
await this.setup();
|
|
213
|
+
await this.db.run(`DELETE FROM user_kv WHERE namespace = ? AND key = ?`, [
|
|
214
|
+
namespace,
|
|
215
|
+
key,
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The live row's JSON, marked most recently used with its idle deadline
|
|
221
|
+
* slid; an expired row is deleted and reads as absent. Runs inside the
|
|
222
|
+
* caller's transaction.
|
|
223
|
+
*/
|
|
224
|
+
private async touch(
|
|
225
|
+
namespace: string,
|
|
226
|
+
key: string,
|
|
227
|
+
): Promise<string | undefined> {
|
|
228
|
+
const row = await this.db.get<KvRow>(
|
|
229
|
+
`SELECT value, idle_ttl_ms, expires_at FROM user_kv WHERE namespace = ? AND key = ?`,
|
|
230
|
+
[namespace, key],
|
|
231
|
+
);
|
|
232
|
+
if (!row) return undefined;
|
|
233
|
+
const now = this.now();
|
|
234
|
+
if (row.expires_at !== null && Number(row.expires_at) <= now) {
|
|
235
|
+
await this.db.run(`DELETE FROM user_kv WHERE namespace = ? AND key = ?`, [
|
|
236
|
+
namespace,
|
|
237
|
+
key,
|
|
238
|
+
]);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const ttl = row.idle_ttl_ms === null ? null : Number(row.idle_ttl_ms);
|
|
242
|
+
await this.db.run(
|
|
243
|
+
`UPDATE user_kv SET expires_at = ?, touched_seq = ${NEXT_SEQ}
|
|
244
|
+
WHERE namespace = ? AND key = ?`,
|
|
245
|
+
[ttl === null ? null : now + ttl, namespace, key],
|
|
246
|
+
);
|
|
247
|
+
return row.value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Throw `UserKvLimitError` — rolling the caller's transaction back,
|
|
252
|
+
* so the write and anything it evicted are undone — when the write broke a
|
|
253
|
+
* hard cap. Before refusing, expired rows of every namespace are swept and
|
|
254
|
+
* the store measured again, so rows nobody can read any more never block
|
|
255
|
+
* a write. Only the measures the write `grew` are checked
|
|
256
|
+
* (`userKvOverflow`). Runs inside the caller's transaction, after the write.
|
|
257
|
+
*/
|
|
258
|
+
private async enforceCaps(
|
|
259
|
+
namespace: string,
|
|
260
|
+
grew: { entries: boolean; bytes: boolean },
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
if (!grew.entries && !grew.bytes) return;
|
|
263
|
+
let overflow = userKvOverflow(
|
|
264
|
+
namespace,
|
|
265
|
+
await this.usage(namespace),
|
|
266
|
+
grew,
|
|
267
|
+
this.limits,
|
|
268
|
+
);
|
|
269
|
+
if (!overflow) return;
|
|
270
|
+
const swept = await this.db.run(
|
|
271
|
+
`DELETE FROM user_kv WHERE expires_at IS NOT NULL AND expires_at <= ?`,
|
|
272
|
+
[this.now()],
|
|
273
|
+
);
|
|
274
|
+
if (swept.changes > 0) {
|
|
275
|
+
overflow = userKvOverflow(
|
|
276
|
+
namespace,
|
|
277
|
+
await this.usage(namespace),
|
|
278
|
+
grew,
|
|
279
|
+
this.limits,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (overflow) throw overflow;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Rows of the namespace (`idx_user_kv_recency`) and rows + row bytes of
|
|
287
|
+
* the store (covering `idx_user_kv_bytes`), expired rows included.
|
|
288
|
+
*/
|
|
289
|
+
private async usage(namespace: string): Promise<UserKvUsage> {
|
|
290
|
+
const ns = await this.db.get<CountRow>(
|
|
291
|
+
`SELECT COUNT(*) AS n FROM user_kv WHERE namespace = ?`,
|
|
292
|
+
[namespace],
|
|
293
|
+
);
|
|
294
|
+
const totals = await this.db.get<TotalsRow>(
|
|
295
|
+
`SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM user_kv`,
|
|
296
|
+
);
|
|
297
|
+
return {
|
|
298
|
+
namespaceEntries: Number(ns?.n ?? 0),
|
|
299
|
+
totalEntries: Number(totals?.n ?? 0),
|
|
300
|
+
totalBytes: Number(totals?.b ?? 0),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Drop the namespace's expired rows, then the least recently used beyond `maxEntries`. */
|
|
305
|
+
private async trim(namespace: string, maxEntries: number): Promise<void> {
|
|
306
|
+
await this.db.run(
|
|
307
|
+
`DELETE FROM user_kv WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?`,
|
|
308
|
+
[namespace, this.now()],
|
|
309
|
+
);
|
|
310
|
+
await this.db.run(
|
|
311
|
+
`DELETE FROM user_kv WHERE namespace = ? AND key IN (
|
|
312
|
+
SELECT key FROM user_kv WHERE namespace = ?
|
|
313
|
+
ORDER BY touched_seq DESC LIMIT -1 OFFSET ?)`,
|
|
314
|
+
[namespace, namespace, maxEntries],
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
package/src/tasks/schedule.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* without a Durable Object; the scheduler (`scheduler.ts`) supplies the
|
|
8
8
|
* clock.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
CronExpression,
|
|
12
|
+
CronExpressionParser,
|
|
13
|
+
CronFieldCollection,
|
|
14
|
+
} from 'cron-parser';
|
|
11
15
|
import type { OracleTaskSchedule } from '../plugin-api/types';
|
|
12
16
|
|
|
13
17
|
/** Consecutive failed runs before a recurring task is stopped as `failed`. */
|
|
@@ -81,21 +85,201 @@ export function previewRuns(
|
|
|
81
85
|
return runs;
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
const DAY_MS = 86_400_000;
|
|
89
|
+
/** Calendar span searched for the two closest firing days (covers a leap year). */
|
|
90
|
+
const FIRING_DAY_SCAN_DAYS = 4 * 366;
|
|
91
|
+
/** How far ahead UTC-offset changes of the task's timezone are looked for. */
|
|
92
|
+
const OFFSET_CHANGE_LOOKAHEAD_DAYS = 366;
|
|
84
93
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
94
|
+
* Fires examined around one UTC-offset change. A pattern that fires more
|
|
95
|
+
* often than this in three days is already below any practical floor on its
|
|
96
|
+
* wall-clock gaps; the cap only bounds the work for tiny operator floors.
|
|
87
97
|
*/
|
|
88
|
-
|
|
98
|
+
const MAX_FIRES_PER_OFFSET_WINDOW = 3_000;
|
|
99
|
+
/**
|
|
100
|
+
* How far around an offset change fires are examined when the pattern's
|
|
101
|
+
* shortest gap is longer (weekly and rarer patterns): a gap of more than a
|
|
102
|
+
* week minus the shift is not a frequency any floor is about.
|
|
103
|
+
*/
|
|
104
|
+
const MAX_OFFSET_WINDOW_REACH_MS = 7 * DAY_MS;
|
|
105
|
+
|
|
106
|
+
/** A timezone's UTC offset (ms) at `atMs`. Throws a RangeError for an unknown zone. */
|
|
107
|
+
function utcOffsetMs(format: Intl.DateTimeFormat, atMs: number): number {
|
|
108
|
+
const parts: Record<string, number> = {};
|
|
109
|
+
for (const part of format.formatToParts(new Date(atMs))) {
|
|
110
|
+
if (part.type !== 'literal') parts[part.type] = Number(part.value);
|
|
111
|
+
}
|
|
112
|
+
const wallClock = Date.UTC(
|
|
113
|
+
parts.year ?? 0,
|
|
114
|
+
(parts.month ?? 1) - 1,
|
|
115
|
+
parts.day ?? 1,
|
|
116
|
+
(parts.hour ?? 0) % 24,
|
|
117
|
+
parts.minute ?? 0,
|
|
118
|
+
parts.second ?? 0,
|
|
119
|
+
);
|
|
120
|
+
return wallClock - Math.floor(atMs / 1000) * 1000;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The fewest calendar days between two consecutive days the pattern's
|
|
125
|
+
* date fields (day of month, month, day of week) select, over
|
|
126
|
+
* `FIRING_DAY_SCAN_DAYS` from `fromMs`; Infinity when fewer than two match.
|
|
127
|
+
*/
|
|
128
|
+
function closestFiringDays(
|
|
129
|
+
fields: CronFieldCollection,
|
|
130
|
+
fromMs: number,
|
|
131
|
+
): number {
|
|
132
|
+
if (
|
|
133
|
+
fields.dayOfMonth.isWildcard &&
|
|
134
|
+
fields.month.isWildcard &&
|
|
135
|
+
fields.dayOfWeek.isWildcard
|
|
136
|
+
) {
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
const days = CronExpression.fieldsToExpression(
|
|
140
|
+
CronFieldCollection.from(fields, { second: [0], minute: [0], hour: [0] }),
|
|
141
|
+
{ currentDate: new Date(fromMs), tz: 'UTC' },
|
|
142
|
+
);
|
|
143
|
+
const end = fromMs + FIRING_DAY_SCAN_DAYS * DAY_MS;
|
|
144
|
+
let closest = Number.POSITIVE_INFINITY;
|
|
145
|
+
let previous: number | undefined;
|
|
146
|
+
while (days.hasNext()) {
|
|
147
|
+
const day = days.next().getTime();
|
|
148
|
+
if (day > end) break;
|
|
149
|
+
if (previous !== undefined)
|
|
150
|
+
closest = Math.min(closest, Math.round((day - previous) / DAY_MS));
|
|
151
|
+
if (closest === 1) break;
|
|
152
|
+
previous = day;
|
|
153
|
+
}
|
|
154
|
+
return closest;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The shortest gap (ms) between consecutive fires of a cron pattern in
|
|
159
|
+
* `timezone` (the runtime's zone when absent), or null when the pattern or
|
|
160
|
+
* the timezone does not parse. Drives the minimum-frequency check, so it
|
|
161
|
+
* covers every pair of consecutive fires, not just the next two:
|
|
162
|
+
*
|
|
163
|
+
* - within a day: the closest two of the pattern's times of day;
|
|
164
|
+
* - across midnight: last time of one firing day → first time of the next
|
|
165
|
+
* firing day, for the two closest firing days (weekday lists, month ends
|
|
166
|
+
* and leap days included);
|
|
167
|
+
* - across a UTC-offset change of `timezone` in the coming year: the real
|
|
168
|
+
* elapsed time between the fires around it (a 01:00 / 04:00 pattern is
|
|
169
|
+
* 2 hours apart on the day the clocks go forward).
|
|
170
|
+
*
|
|
171
|
+
* Infinity when the pattern fires at most once in the scanned span. A gap
|
|
172
|
+
* below `floorMs` ends the search early (that is all the caller needs).
|
|
173
|
+
*/
|
|
174
|
+
export function cronIntervalMs(
|
|
175
|
+
cron: string,
|
|
176
|
+
timezone?: string,
|
|
177
|
+
fromMs = Date.now(),
|
|
178
|
+
floorMs = 0,
|
|
179
|
+
examined?: { fires: number },
|
|
180
|
+
): number | null {
|
|
181
|
+
let fields: CronFieldCollection;
|
|
182
|
+
let offsetFormat: Intl.DateTimeFormat | undefined;
|
|
89
183
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
184
|
+
fields = CronExpressionParser.parse(cron, {
|
|
185
|
+
currentDate: new Date(fromMs),
|
|
186
|
+
tz: 'UTC',
|
|
187
|
+
}).fields;
|
|
188
|
+
if (timezone) {
|
|
189
|
+
offsetFormat = new Intl.DateTimeFormat('en-US', {
|
|
190
|
+
timeZone: timezone,
|
|
191
|
+
hourCycle: 'h23',
|
|
192
|
+
year: 'numeric',
|
|
193
|
+
month: 'numeric',
|
|
194
|
+
day: 'numeric',
|
|
195
|
+
hour: 'numeric',
|
|
196
|
+
minute: 'numeric',
|
|
197
|
+
second: 'numeric',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
96
200
|
} catch {
|
|
97
201
|
return null;
|
|
98
202
|
}
|
|
203
|
+
|
|
204
|
+
// Wall-clock gaps: the pattern's times of day, then the step between days.
|
|
205
|
+
const times: number[] = [];
|
|
206
|
+
for (const hour of fields.hour.values)
|
|
207
|
+
for (const minute of fields.minute.values)
|
|
208
|
+
for (const second of fields.second.values)
|
|
209
|
+
times.push((hour * 3600 + minute * 60 + second) * 1000);
|
|
210
|
+
times.sort((a, b) => a - b);
|
|
211
|
+
let shortest = Number.POSITIVE_INFINITY;
|
|
212
|
+
for (let i = 1; i < times.length; i += 1)
|
|
213
|
+
shortest = Math.min(shortest, times[i]! - times[i - 1]!);
|
|
214
|
+
let firingDays: number;
|
|
215
|
+
try {
|
|
216
|
+
firingDays = closestFiringDays(fields, fromMs);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (Number.isFinite(firingDays) && times.length > 0) {
|
|
221
|
+
shortest = Math.min(
|
|
222
|
+
shortest,
|
|
223
|
+
firingDays * DAY_MS - (times[times.length - 1]! - times[0]!),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (!offsetFormat || shortest < floorMs) return shortest;
|
|
227
|
+
|
|
228
|
+
// Real elapsed time around each UTC-offset change in the coming year.
|
|
229
|
+
const format = offsetFormat;
|
|
230
|
+
let previousOffset = utcOffsetMs(format, fromMs);
|
|
231
|
+
for (let day = 1; day <= OFFSET_CHANGE_LOOKAHEAD_DAYS; day += 1) {
|
|
232
|
+
const dayMs = fromMs + day * DAY_MS;
|
|
233
|
+
const offset = utcOffsetMs(format, dayMs);
|
|
234
|
+
if (offset === previousOffset) continue;
|
|
235
|
+
const shift = Math.abs(offset - previousOffset);
|
|
236
|
+
previousOffset = offset;
|
|
237
|
+
// The change instant, to the minute, between the two daily samples.
|
|
238
|
+
let before = dayMs - DAY_MS;
|
|
239
|
+
let after = dayMs;
|
|
240
|
+
while (after - before > 60_000) {
|
|
241
|
+
const mid = before + Math.floor((after - before) / 2);
|
|
242
|
+
if (utcOffsetMs(format, mid) === offset) after = mid;
|
|
243
|
+
else before = mid;
|
|
244
|
+
}
|
|
245
|
+
// Only a pair of fires that straddles the change can come closer than
|
|
246
|
+
// its clock gap, and by at most `shift`: a pair that could beat the
|
|
247
|
+
// gap found so far (or break the floor) is less than `reach` apart,
|
|
248
|
+
// so both of its fires lie within `reach` of the change. Fires the
|
|
249
|
+
// change moves (a time that does not exist that day) land within
|
|
250
|
+
// `shift` after it.
|
|
251
|
+
const reach =
|
|
252
|
+
Math.min(
|
|
253
|
+
shortest,
|
|
254
|
+
floorMs > 0 ? floorMs : Number.POSITIVE_INFINITY,
|
|
255
|
+
MAX_OFFSET_WINDOW_REACH_MS,
|
|
256
|
+
) + shift;
|
|
257
|
+
const windowStart = after - reach;
|
|
258
|
+
const windowEnd = after + shift + reach;
|
|
259
|
+
try {
|
|
260
|
+
const fires = CronExpressionParser.parse(cron, {
|
|
261
|
+
currentDate: new Date(windowStart),
|
|
262
|
+
tz: timezone,
|
|
263
|
+
});
|
|
264
|
+
let previous: number | undefined;
|
|
265
|
+
for (let n = 0; n < MAX_FIRES_PER_OFFSET_WINDOW; n += 1) {
|
|
266
|
+
const fire = fires.next().getTime();
|
|
267
|
+
if (examined) examined.fires += 1;
|
|
268
|
+
// cron-parser can stop advancing next to a change that is not a
|
|
269
|
+
// whole hour (it returns the same fire again): the window ends there.
|
|
270
|
+
if (fire > windowEnd || (previous !== undefined && fire <= previous))
|
|
271
|
+
break;
|
|
272
|
+
if (previous !== undefined)
|
|
273
|
+
shortest = Math.min(shortest, fire - previous);
|
|
274
|
+
if (shortest < floorMs) return shortest;
|
|
275
|
+
previous = fire;
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// The pattern has no fire left after this window (cron-parser throws
|
|
279
|
+
// past its search horizon): nothing more to measure here.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return shortest;
|
|
99
283
|
}
|
|
100
284
|
|
|
101
285
|
/**
|
|
@@ -122,12 +306,17 @@ export function validateSchedule(
|
|
|
122
306
|
break;
|
|
123
307
|
}
|
|
124
308
|
case 'cron': {
|
|
125
|
-
const interval = cronIntervalMs(
|
|
309
|
+
const interval = cronIntervalMs(
|
|
310
|
+
schedule.cron,
|
|
311
|
+
schedule.timezone,
|
|
312
|
+
fromMs,
|
|
313
|
+
minIntervalSec * 1000,
|
|
314
|
+
);
|
|
126
315
|
if (interval === null) {
|
|
127
316
|
problems.push(`Cron pattern "${schedule.cron}" is not valid.`);
|
|
128
317
|
} else if (interval < minIntervalSec * 1000) {
|
|
129
318
|
problems.push(
|
|
130
|
-
`That cron schedule fires
|
|
319
|
+
`That cron schedule fires as little as ${Math.round(interval / 1000)}s apart — the minimum interval between runs is ${minIntervalSec}s. Pick a less frequent schedule.`,
|
|
131
320
|
);
|
|
132
321
|
}
|
|
133
322
|
break;
|