@keemakr/schedules 0.1.1-dev-20261012160211

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 ADDED
@@ -0,0 +1,115 @@
1
+ # @keemakr/schedules
2
+
3
+ An agent that cannot finish now asks to be brought back later, instead of sleeping inside a run, hammering a closed door, or dropping the work.
4
+
5
+ Every Pack gets this. `@keemakr/agent-sdk` exposes it as `kee.schedules`, and the `schedules:defer` scope is on every grant — deferring adds no authority, it only moves work the caller may already do to a later minute, as the same principal.
6
+
7
+ ## The rate-limit case
8
+
9
+ ```ts
10
+ import { isRateLimited } from '@keemakr/agent-sdk';
11
+
12
+ const res = await kee.connections.smartlead.call('leads.add', batch);
13
+ if (isRateLimited(res)) {
14
+ return kee.schedules.resumeAfter(res, {
15
+ key: `place:${campaignId}`,
16
+ reason: 'Smartlead is rate limiting; the rest of this campaign is waiting.',
17
+ provider: 'smartlead',
18
+ state: { campaignId, remaining },
19
+ });
20
+ }
21
+ ```
22
+
23
+ `resumeAfter` reads the provider's own `Retry-After` off the connector result (core returns it as `retryAfter` on a 429, and remembers a per-credential backoff of its own). Nothing guesses a delay.
24
+
25
+ When the window reopens, core starts a new turn for the same agent, acting as the same person, with your `state` handed back and the attempt number stated.
26
+
27
+ ## A plain deferral
28
+
29
+ ```ts
30
+ await kee.schedules.after('20m', {
31
+ key: `enrich:${practiceId}`,
32
+ reason: 'Nothing to enrich until tonight’s sync lands.',
33
+ state: { practiceId },
34
+ });
35
+ ```
36
+
37
+ Durations are `'90s'`, `'20m'`, `'2h'`, `'1d'`, or a number of seconds. Anything else throws — a delay that silently resolves to zero is a hot loop.
38
+
39
+ ## The rules
40
+
41
+ | Rule | Why |
42
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `key` collapses repeats | The same key while one is pending returns the standing deferral. A retry loop that defers on every attempt cannot become a swarm of timers. |
44
+ | `reason` is required | It is shown to the tenant beside the pending resume. An agent that cannot say why it is coming back does not come back. |
45
+ | `state` is a cursor, not a payload | 8 KB. Keep the data in a Table and the place here. |
46
+ | Attempts are capped | A key that has come back ten times stops and raises attention. Retrying is not a plan. |
47
+ | The horizon is a day | Past that, the authority that asked for the work is no longer reliably the authority that gets it. Work further out needs a schedule a person owns. |
48
+ | One install, fifty pending | Over the budget, `after()` refuses and names the count. Something is looping. |
49
+
50
+ ## Batching: when the pile is bigger than the door
51
+
52
+ A connector that takes a hundred a minute and an agent holding five hundred. Retrying the whole set is the same wall five times over — so put the work down and take it back in chunks.
53
+
54
+ ```ts
55
+ const batch = kee.schedules.batch('smartlead.leads.add');
56
+
57
+ await batch.add(
58
+ leads.map((l) => ({ key: l.email, payload: l })),
59
+ {
60
+ reason: 'Smartlead will not take them all at once.',
61
+ provider: 'smartlead',
62
+ maxSize: 100, // drain as soon as this many are waiting
63
+ maxWait: '1h', // and no later than this, however few there are
64
+ },
65
+ );
66
+ ```
67
+
68
+ When the drain wakes the agent, it takes chunks and settles them:
69
+
70
+ ```ts
71
+ const chunk = await batch.take(100); // leased to this run
72
+
73
+ for (const item of chunk) {
74
+ /* place the lead */
75
+ }
76
+
77
+ await batch.done(placed.map((i) => i.id)); // landed — rows deleted
78
+ await batch.release(refused.map((i) => i.id)); // door shut again — back in the pile
79
+ await batch.fail([{ id, error: 'address is invalid' }]); // never going to land — kept, with why
80
+ ```
81
+
82
+ Rules worth knowing:
83
+
84
+ | Rule | Why |
85
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `add` is idempotent per item key | An agent that rebuilds its list after a resume and adds it again does not double the pile. |
87
+ | `take` leases | Two resumes take different work, never the same. A resume that dies mid-chunk returns its items when the lease expires, rather than stranding them. |
88
+ | `done` deletes | The record of what happened is where the work landed — a Table, the provider, the trace. A scratch buffer is not an audit trail. |
89
+ | An empty pile closes itself | And its drain is cancelled, so nothing wakes an agent to look at nothing. |
90
+ | The drain is one deferral | A full pile pulls that one forward; it never grows a second. Two drains would take overlapping chunks and each conclude the other had done nothing. |
91
+ | Limits | 10,000 items per batch, 1,000 per add, 500 per take, 4 KB per item, 20 open batches per install. |
92
+
93
+ If the door shuts again mid-pile, release the chunk you are holding and defer with the same key. What you have not settled stays in the batch.
94
+
95
+ ## What this cannot do
96
+
97
+ Create a cadence. Standing authority to act on a clock — every Monday, every hour — belongs to a schedule with a named human owner, declared in the Pack's manifest and approved at install. No agent mints one for itself, and no scope an agent carries would let it.
98
+
99
+ ## Wiring it yourself
100
+
101
+ The package is transport-free. `createSchedules(post)` takes anything that can reach `POST /api/capability/schedules/defer` with the run's grant, so a runtime that is not the agent SDK can expose the same surface:
102
+
103
+ ```ts
104
+ import { createSchedules } from '@keemakr/schedules';
105
+
106
+ const schedules = createSchedules(async (body) => {
107
+ const res = await fetch(`${coreUrl}/api/capability/schedules/defer`, {
108
+ method: 'POST',
109
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${grant}` },
110
+ body: JSON.stringify(body),
111
+ });
112
+ if (!res.ok) throw new Error(await res.text());
113
+ return res.json();
114
+ });
115
+ ```
@@ -0,0 +1,184 @@
1
+ /** How long to wait: seconds as a number, or a short duration like '90s', '20m', '2h'. */
2
+ export type Duration = number | `${number}s` | `${number}m` | `${number}h` | `${number}d`;
3
+ /**
4
+ * Seconds from a duration. Strings are the ergonomic form; a bare number is
5
+ * already seconds. Anything else throws rather than silently becoming zero — a
6
+ * deferral that resolves to "now" is an infinite loop wearing a delay.
7
+ */
8
+ export declare function parseDuration(input: Duration): number;
9
+ /**
10
+ * The shape core's connection proxy returns when a provider is pacing us — the
11
+ * same object `kee.connections.<p>.call()` rejects or returns on a 429. Both the
12
+ * provider's own Retry-After and core's tenant-budget backoff arrive here as
13
+ * `retryAfter` in whole seconds.
14
+ */
15
+ export interface RateLimitedResult {
16
+ code?: string;
17
+ status?: number;
18
+ retryAfter?: number;
19
+ error?: string;
20
+ }
21
+ /** Is this a rate limit, as opposed to any other refusal? */
22
+ export declare function isRateLimited(result: unknown): result is RateLimitedResult;
23
+ /**
24
+ * How long to wait after a rate-limited result. The provider's own number when
25
+ * it gave one; otherwise a conservative minute — long enough that the retry is
26
+ * not part of the problem, short enough that a person waiting notices nothing.
27
+ */
28
+ export declare const DEFAULT_BACKOFF_SECONDS = 60;
29
+ export declare function backoffFor(result: RateLimitedResult): number;
30
+ export interface DeferOptions {
31
+ /**
32
+ * The idempotency key. Asking twice with the same key while one is still
33
+ * pending returns the standing deferral instead of making a second one — so a
34
+ * retry loop that defers on every attempt cannot fan out into a swarm of
35
+ * timers. Key it on the work, not the attempt: `place:${campaignId}`.
36
+ */
37
+ key: string;
38
+ /** One sentence, shown to the tenant beside the pending resume. Required: an agent that cannot say why it is coming back does not come back. */
39
+ reason: string;
40
+ /** The cursor handed back on resume. Keep it small — a place, not a payload. */
41
+ state?: Record<string, unknown>;
42
+ /** The connector that paced this. `resumeAfter` fills it when you name the provider. */
43
+ provider?: string | null;
44
+ /** Which agent of the install resumes. Omit for the install's single agent. */
45
+ agentKey?: string | null;
46
+ }
47
+ export interface Deferred {
48
+ deferralId: string;
49
+ key: string;
50
+ /** When core will bring the agent back. */
51
+ resumeAt: Date;
52
+ /** Which attempt this is for the key. Attempts are capped; the resume says the ceiling. */
53
+ attempt: number;
54
+ /** The human the resume will act as, or null for an unattended run. */
55
+ actsAs: string | null;
56
+ /** True when an identical key was already pending and this call changed nothing. */
57
+ deduplicated: boolean;
58
+ }
59
+ export interface Schedules {
60
+ /** Come back after a delay, carrying `state`. */
61
+ after(delay: Duration, options: DeferOptions): Promise<Deferred>;
62
+ /** Come back when a rate-limited provider's window reopens, using its own Retry-After. */
63
+ resumeAfter(result: RateLimitedResult, options: DeferOptions & {
64
+ provider?: string | null;
65
+ }): Promise<Deferred>;
66
+ /** A named pile of work to put down now and take back in chunks later. */
67
+ batch(key: string): Batch;
68
+ }
69
+ /** The one call this package makes. Supplied by the host SDK, which owns the grant. */
70
+ export type DeferPost = (body: {
71
+ key: string;
72
+ delay_seconds: number;
73
+ reason: string;
74
+ state: Record<string, unknown>;
75
+ provider: string | null;
76
+ agent_key: string | null;
77
+ }) => Promise<{
78
+ deferral_id: string;
79
+ key: string;
80
+ resume_at: string;
81
+ attempt: number;
82
+ acts_as: string | null;
83
+ deduplicated: boolean;
84
+ }>;
85
+ /**
86
+ * Build the schedules surface over a caller that can reach
87
+ * `POST /api/capability/schedules/defer` with the run's grant.
88
+ *
89
+ * Transport-free on purpose: `@keemakr/agent-sdk` wires this to its own
90
+ * authenticated fetch and exposes it as `kee.schedules`, so a Pack gets it
91
+ * without adding a dependency, and anything else holding a grant can wire the
92
+ * same surface without inheriting an SDK.
93
+ */
94
+ export declare function createSchedules(post: DeferPost, batchDeps?: {
95
+ post: BatchPost;
96
+ get: BatchGet;
97
+ }): Schedules;
98
+ export interface BatchItemInput {
99
+ key: string;
100
+ payload?: Record<string, unknown>;
101
+ }
102
+ export interface BatchDrainOptions {
103
+ /** Drain when this many are waiting. Default 100. */
104
+ maxSize?: number;
105
+ /** Drain no later than this after the batch opened. Default '1h'. */
106
+ maxWait?: Duration;
107
+ /** The connector this pile is waiting on — recorded, and shown to the tenant. */
108
+ provider?: string | null;
109
+ /** Which agent of the install resumes. Omit for the install's single agent. */
110
+ agentKey?: string | null;
111
+ }
112
+ export interface BatchAddResult {
113
+ batchId: string;
114
+ /** Items this call created. A repeat of the same item key adds nothing. */
115
+ added: number;
116
+ pending: number;
117
+ drainAt: Date | null;
118
+ /** True when the pile filled and its drain was pulled forward. */
119
+ drainPulledForward: boolean;
120
+ }
121
+ export interface LeasedItem {
122
+ /** Pass this back to `done`, `release` or `fail`. */
123
+ id: string;
124
+ key: string;
125
+ payload: Record<string, unknown>;
126
+ /** How many times this item has been taken. A high count is an item to look at. */
127
+ attempts: number;
128
+ }
129
+ export interface BatchSettleResult {
130
+ done: number;
131
+ released: number;
132
+ failed: number;
133
+ remaining: number;
134
+ /** True when the pile emptied and the batch closed itself. */
135
+ closed: boolean;
136
+ }
137
+ export interface BatchStatus {
138
+ batchId: string;
139
+ key: string;
140
+ status: 'open' | 'draining' | 'closed';
141
+ provider: string | null;
142
+ pending: number;
143
+ leased: number;
144
+ failed: number;
145
+ drainAt: Date | null;
146
+ }
147
+ export interface Batch {
148
+ /**
149
+ * Put work down. Idempotent per item key, so an agent that rebuilds its list
150
+ * after a resume and adds it again does not double the pile.
151
+ *
152
+ * `reason` is required — it is what the tenant sees beside the batch.
153
+ */
154
+ add(items: BatchItemInput[], options: {
155
+ reason: string;
156
+ } & BatchDrainOptions): Promise<BatchAddResult>;
157
+ /** Take a chunk, leased so a duplicate resume cannot do the same work twice. */
158
+ take(limit?: number, options?: {
159
+ leaseSeconds?: number;
160
+ }): Promise<LeasedItem[]>;
161
+ /** These landed. The rows are deleted — the record is where the work went. */
162
+ done(ids: string[]): Promise<BatchSettleResult>;
163
+ /** The door shut again. Put these back for the next drain. */
164
+ release(ids: string[]): Promise<BatchSettleResult>;
165
+ /** These will never land. Kept with the reason, so somebody can see them. */
166
+ fail(failures: Array<{
167
+ id: string;
168
+ error: string;
169
+ }>): Promise<BatchSettleResult>;
170
+ /** How much is piled up, and when it drains. Null when there is no live batch. */
171
+ status(): Promise<BatchStatus | null>;
172
+ /** Give up on the pile. Items go with it. */
173
+ close(): Promise<{
174
+ closed: boolean;
175
+ discarded: number;
176
+ }>;
177
+ }
178
+ /** The batch calls, supplied by the host SDK, which owns the grant. */
179
+ export type BatchPost = (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
180
+ export type BatchGet = (key: string) => Promise<Record<string, unknown> | null>;
181
+ export declare function createBatch(deps: {
182
+ post: BatchPost;
183
+ get: BatchGet;
184
+ }, key: string): Batch;
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ // @keemakr/schedules — an agent defers its own work.
2
+ //
3
+ // The case this exists for, in full:
4
+ //
5
+ // const res = await kee.connections.smartlead.call('leads.add', batch);
6
+ // if (isRateLimited(res)) {
7
+ // return kee.schedules.resumeAfter(res, {
8
+ // key: `place:${campaignId}`,
9
+ // reason: 'Smartlead is rate limiting; the rest of this campaign is waiting.',
10
+ // state: { campaignId, remaining },
11
+ // });
12
+ // }
13
+ //
14
+ // The agent stops, and core brings it back when the window reopens with exactly
15
+ // that state in hand. No sleeping inside a run, no retry loop against a closed
16
+ // door, no work quietly dropped.
17
+ //
18
+ // WHAT THIS IS NOT. It cannot create a cadence. Standing authority to act on a
19
+ // clock — every Monday, every hour — belongs to a schedule a person owns, and no
20
+ // agent mints one for itself. A deferral only moves work the caller may ALREADY
21
+ // do to a later minute, as the same principal; that is why it needs no approval
22
+ // and why the horizon is hours rather than months.
23
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3_600, d: 86_400 };
24
+ /**
25
+ * Seconds from a duration. Strings are the ergonomic form; a bare number is
26
+ * already seconds. Anything else throws rather than silently becoming zero — a
27
+ * deferral that resolves to "now" is an infinite loop wearing a delay.
28
+ */
29
+ export function parseDuration(input) {
30
+ if (typeof input === 'number') {
31
+ if (!Number.isFinite(input) || input < 0) {
32
+ throw new TypeError(`invalid duration: ${input}`);
33
+ }
34
+ return input;
35
+ }
36
+ const match = /^(\d+(?:\.\d+)?)([smhd])$/.exec(String(input).trim());
37
+ if (!match) {
38
+ throw new TypeError(`invalid duration '${input}' — use seconds, or '90s' / '20m' / '2h' / '1d'`);
39
+ }
40
+ return Number(match[1]) * UNIT_SECONDS[match[2]];
41
+ }
42
+ /** Is this a rate limit, as opposed to any other refusal? */
43
+ export function isRateLimited(result) {
44
+ if (typeof result !== 'object' || result === null)
45
+ return false;
46
+ const r = result;
47
+ if (r.ok === true)
48
+ return false;
49
+ return r.code === 'rate_limited' || r.status === 429;
50
+ }
51
+ /**
52
+ * How long to wait after a rate-limited result. The provider's own number when
53
+ * it gave one; otherwise a conservative minute — long enough that the retry is
54
+ * not part of the problem, short enough that a person waiting notices nothing.
55
+ */
56
+ export const DEFAULT_BACKOFF_SECONDS = 60;
57
+ export function backoffFor(result) {
58
+ const seconds = result.retryAfter;
59
+ return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
60
+ ? Math.ceil(seconds)
61
+ : DEFAULT_BACKOFF_SECONDS;
62
+ }
63
+ /**
64
+ * Build the schedules surface over a caller that can reach
65
+ * `POST /api/capability/schedules/defer` with the run's grant.
66
+ *
67
+ * Transport-free on purpose: `@keemakr/agent-sdk` wires this to its own
68
+ * authenticated fetch and exposes it as `kee.schedules`, so a Pack gets it
69
+ * without adding a dependency, and anything else holding a grant can wire the
70
+ * same surface without inheriting an SDK.
71
+ */
72
+ export function createSchedules(post, batchDeps) {
73
+ async function defer(delaySeconds, options) {
74
+ const json = await post({
75
+ key: options.key,
76
+ delay_seconds: delaySeconds,
77
+ reason: options.reason,
78
+ state: options.state ?? {},
79
+ provider: options.provider ?? null,
80
+ agent_key: options.agentKey ?? null,
81
+ });
82
+ return {
83
+ deferralId: json.deferral_id,
84
+ key: json.key,
85
+ resumeAt: new Date(json.resume_at),
86
+ attempt: json.attempt,
87
+ actsAs: json.acts_as,
88
+ deduplicated: json.deduplicated,
89
+ };
90
+ }
91
+ return {
92
+ after(delay, options) {
93
+ return defer(parseDuration(delay), options);
94
+ },
95
+ resumeAfter(result, options) {
96
+ return defer(backoffFor(result), options);
97
+ },
98
+ batch(key) {
99
+ if (!batchDeps) {
100
+ throw new Error('batching is not wired in this runtime — pass batch transport to createSchedules()');
101
+ }
102
+ return createBatch(batchDeps, key);
103
+ },
104
+ };
105
+ }
106
+ function settleResult(json) {
107
+ return {
108
+ done: Number(json.done ?? 0),
109
+ released: Number(json.released ?? 0),
110
+ failed: Number(json.failed ?? 0),
111
+ remaining: Number(json.remaining ?? 0),
112
+ closed: json.closed === true,
113
+ };
114
+ }
115
+ export function createBatch(deps, key) {
116
+ const settle = async (payload) => settleResult(await deps.post({ op: 'settle', key, ...payload }));
117
+ return {
118
+ async add(items, options) {
119
+ const json = await deps.post({
120
+ op: 'add',
121
+ key,
122
+ items: items.map((i) => ({ key: i.key, payload: i.payload ?? {} })),
123
+ reason: options.reason,
124
+ provider: options.provider ?? null,
125
+ agent_key: options.agentKey ?? null,
126
+ max_size: options.maxSize ?? 100,
127
+ max_wait_seconds: parseDuration(options.maxWait ?? '1h'),
128
+ });
129
+ return {
130
+ batchId: String(json.batch_id),
131
+ added: Number(json.added ?? 0),
132
+ pending: Number(json.pending ?? 0),
133
+ drainAt: json.drain_at ? new Date(String(json.drain_at)) : null,
134
+ drainPulledForward: json.drain_pulled_forward === true,
135
+ };
136
+ },
137
+ async take(limit = 100, options) {
138
+ const json = await deps.post({
139
+ op: 'take',
140
+ key,
141
+ limit,
142
+ ...(options?.leaseSeconds ? { lease_seconds: options.leaseSeconds } : {}),
143
+ });
144
+ const items = Array.isArray(json.items) ? json.items : [];
145
+ return items.map((i) => ({
146
+ id: String(i.id),
147
+ key: String(i.key),
148
+ payload: (i.payload ?? {}),
149
+ attempts: Number(i.attempts ?? 0),
150
+ }));
151
+ },
152
+ done(ids) {
153
+ return settle({ done: ids });
154
+ },
155
+ release(ids) {
156
+ return settle({ release: ids });
157
+ },
158
+ fail(failures) {
159
+ return settle({ failed: failures });
160
+ },
161
+ async status() {
162
+ const json = await deps.get(key);
163
+ if (!json)
164
+ return null;
165
+ return {
166
+ batchId: String(json.batch_id),
167
+ key: String(json.key),
168
+ status: json.status,
169
+ provider: (json.provider ?? null),
170
+ pending: Number(json.pending ?? 0),
171
+ leased: Number(json.leased ?? 0),
172
+ failed: Number(json.failed ?? 0),
173
+ drainAt: json.drain_at ? new Date(String(json.drain_at)) : null,
174
+ };
175
+ },
176
+ async close() {
177
+ const json = await deps.post({ op: 'close', key });
178
+ return { closed: json.closed === true, discarded: Number(json.discarded ?? 0) };
179
+ },
180
+ };
181
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@keemakr/schedules",
3
+ "version": "0.1.1-dev-20261012160211",
4
+ "description": "Let a keemakr agent defer its own work: hit a rate limit, hand back a cursor, and be resumed when the window reopens.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/fsztpartners/keemakr.git",
26
+ "directory": "packages/schedules"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "typecheck": "tsc --noEmit",
31
+ "prepack": "npm run build"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^20.19.43",
35
+ "typescript": "^5"
36
+ },
37
+ "engines": {
38
+ "node": ">=20.9"
39
+ }
40
+ }