@elisym/sdk 0.5.0 → 0.7.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/dist/index.cjs +282 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +231 -9
- package/dist/index.d.ts +231 -9
- package/dist/index.js +274 -23
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +200 -0
- package/dist/runtime.cjs.map +1 -0
- package/dist/runtime.d.cts +116 -0
- package/dist/runtime.d.ts +116 -0
- package/dist/runtime.js +193 -0
- package/dist/runtime.js.map +1 -0
- package/dist/skills.cjs +673 -0
- package/dist/skills.cjs.map +1 -0
- package/dist/skills.d.cts +172 -0
- package/dist/skills.d.ts +172 -0
- package/dist/skills.js +660 -0
- package/dist/skills.js.map +1 -0
- package/package.json +21 -1
package/dist/runtime.cjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/runtime/jobLedger.ts
|
|
4
|
+
var JOB_LEDGER_VERSION = 1;
|
|
5
|
+
var TERMINAL_STATES = /* @__PURE__ */ new Set([
|
|
6
|
+
"delivered",
|
|
7
|
+
"result_received",
|
|
8
|
+
"failed",
|
|
9
|
+
"cancelled"
|
|
10
|
+
]);
|
|
11
|
+
async function pendingJobs(adapter, side) {
|
|
12
|
+
const latest = await adapter.loadLatest(side);
|
|
13
|
+
const pending = [];
|
|
14
|
+
for (const entry of latest.values()) {
|
|
15
|
+
if (!TERMINAL_STATES.has(entry.state)) {
|
|
16
|
+
pending.push(entry);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
pending.sort((left, right) => left.jobCreatedAt - right.jobCreatedAt);
|
|
20
|
+
return pending;
|
|
21
|
+
}
|
|
22
|
+
async function findByJobId(adapter, jobEventId) {
|
|
23
|
+
const latest = await adapter.loadLatest();
|
|
24
|
+
return latest.get(jobEventId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/runtime/memoryAdapter.ts
|
|
28
|
+
function createMemoryJobLedgerAdapter() {
|
|
29
|
+
const log = [];
|
|
30
|
+
let rowCounter = 0;
|
|
31
|
+
function latestByJobId(side) {
|
|
32
|
+
const latest = /* @__PURE__ */ new Map();
|
|
33
|
+
const latestRowAt = /* @__PURE__ */ new Map();
|
|
34
|
+
for (const row of log) {
|
|
35
|
+
if (side && row.entry.side !== side) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const jobId = row.entry.jobEventId;
|
|
39
|
+
const previous = latestRowAt.get(jobId) ?? -Infinity;
|
|
40
|
+
if (row.rowAt >= previous) {
|
|
41
|
+
latest.set(jobId, row.entry);
|
|
42
|
+
latestRowAt.set(jobId, row.rowAt);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return latest;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
async write(entry) {
|
|
49
|
+
const finalized = {
|
|
50
|
+
...entry,
|
|
51
|
+
transitionAt: Date.now(),
|
|
52
|
+
version: JOB_LEDGER_VERSION
|
|
53
|
+
};
|
|
54
|
+
rowCounter += 1;
|
|
55
|
+
log.push({ entry: finalized, rowAt: rowCounter });
|
|
56
|
+
},
|
|
57
|
+
async loadLatest(side) {
|
|
58
|
+
return latestByJobId(side);
|
|
59
|
+
},
|
|
60
|
+
async pruneOldEntries(retentionMs) {
|
|
61
|
+
const cutoff = Date.now() - retentionMs;
|
|
62
|
+
const latest = latestByJobId();
|
|
63
|
+
const dropIds = /* @__PURE__ */ new Set();
|
|
64
|
+
for (const [jobId, entry] of latest) {
|
|
65
|
+
if (TERMINAL_STATES.has(entry.state) && entry.transitionAt < cutoff) {
|
|
66
|
+
dropIds.add(jobId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (dropIds.size === 0) {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
let deleted = 0;
|
|
73
|
+
for (let index = log.length - 1; index >= 0; index--) {
|
|
74
|
+
const row = log[index];
|
|
75
|
+
if (row && dropIds.has(row.entry.jobEventId)) {
|
|
76
|
+
log.splice(index, 1);
|
|
77
|
+
deleted += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return deleted;
|
|
81
|
+
},
|
|
82
|
+
clear() {
|
|
83
|
+
log.length = 0;
|
|
84
|
+
rowCounter = 0;
|
|
85
|
+
},
|
|
86
|
+
rows() {
|
|
87
|
+
return log.map((row) => row.entry);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/runtime/recoveryLoop.ts
|
|
93
|
+
function createRecoveryLoop(options) {
|
|
94
|
+
const { adapter, onProviderPending, onCustomerPending, intervalMs, retentionMs, concurrency } = options;
|
|
95
|
+
const logger = options.logger ?? {};
|
|
96
|
+
if (intervalMs <= 0) {
|
|
97
|
+
throw new RangeError("intervalMs must be > 0");
|
|
98
|
+
}
|
|
99
|
+
if (concurrency <= 0) {
|
|
100
|
+
throw new RangeError("concurrency must be > 0");
|
|
101
|
+
}
|
|
102
|
+
if (retentionMs <= 0) {
|
|
103
|
+
throw new RangeError("retentionMs must be > 0");
|
|
104
|
+
}
|
|
105
|
+
let timer;
|
|
106
|
+
let running = false;
|
|
107
|
+
async function runBatch(entries, handler) {
|
|
108
|
+
let index = 0;
|
|
109
|
+
const workerCount = Math.min(concurrency, entries.length);
|
|
110
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
111
|
+
while (index < entries.length) {
|
|
112
|
+
const currentIndex = index++;
|
|
113
|
+
const entry = entries[currentIndex];
|
|
114
|
+
if (!entry) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await handler(entry);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.warn?.(
|
|
121
|
+
{
|
|
122
|
+
err: error instanceof Error ? error.message : String(error),
|
|
123
|
+
jobEventId: entry.jobEventId,
|
|
124
|
+
side: entry.side,
|
|
125
|
+
state: entry.state
|
|
126
|
+
},
|
|
127
|
+
"recovery handler threw"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
await Promise.all(workers);
|
|
133
|
+
}
|
|
134
|
+
async function sweepSide(side, handler) {
|
|
135
|
+
if (!handler) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const pending = await pendingJobs(adapter, side);
|
|
139
|
+
if (pending.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
logger.info?.({ [side]: pending.length }, "recovery sweep: resuming pending jobs");
|
|
143
|
+
await runBatch(pending, handler);
|
|
144
|
+
}
|
|
145
|
+
async function sweepOnce() {
|
|
146
|
+
if (running) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
running = true;
|
|
150
|
+
try {
|
|
151
|
+
try {
|
|
152
|
+
await adapter.pruneOldEntries(retentionMs);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.warn?.(
|
|
155
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
156
|
+
"recovery: pruneOldEntries failed"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
await sweepSide("provider", onProviderPending);
|
|
160
|
+
await sweepSide("customer", onCustomerPending);
|
|
161
|
+
} finally {
|
|
162
|
+
running = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function start() {
|
|
166
|
+
if (timer) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
sweepOnce().catch(
|
|
170
|
+
(error) => logger.warn?.(
|
|
171
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
172
|
+
"initial recovery sweep failed"
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
timer = setInterval(() => {
|
|
176
|
+
sweepOnce().catch(
|
|
177
|
+
(error) => logger.warn?.(
|
|
178
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
179
|
+
"recovery sweep failed"
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
}, intervalMs);
|
|
183
|
+
}
|
|
184
|
+
function stop() {
|
|
185
|
+
if (timer) {
|
|
186
|
+
clearInterval(timer);
|
|
187
|
+
timer = void 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { start, stop, sweepOnce };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
exports.JOB_LEDGER_VERSION = JOB_LEDGER_VERSION;
|
|
194
|
+
exports.TERMINAL_STATES = TERMINAL_STATES;
|
|
195
|
+
exports.createMemoryJobLedgerAdapter = createMemoryJobLedgerAdapter;
|
|
196
|
+
exports.createRecoveryLoop = createRecoveryLoop;
|
|
197
|
+
exports.findByJobId = findByJobId;
|
|
198
|
+
exports.pendingJobs = pendingJobs;
|
|
199
|
+
//# sourceMappingURL=runtime.cjs.map
|
|
200
|
+
//# sourceMappingURL=runtime.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/runtime/jobLedger.ts","../src/runtime/memoryAdapter.ts","../src/runtime/recoveryLoop.ts"],"names":[],"mappings":"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","file":"runtime.cjs","sourcesContent":["/**\n * Shared JobLedger types + adapter interface for elisym crash recovery.\n *\n * The ledger is an append-only log of state transitions for each job,\n * keyed by the Nostr job-request event id. Each adapter chooses its own\n * storage backend (Eliza memory, SQLite, tests-only in-memory) and\n * exposes the minimal read/write surface this module defines.\n */\n\nexport type JobSide = 'provider' | 'customer';\n\nexport type ProviderState =\n | 'waiting_payment'\n | 'paid'\n | 'executed'\n | 'delivered'\n | 'failed'\n | 'cancelled';\n\nexport type CustomerState =\n | 'submitted'\n | 'waiting_payment'\n | 'payment_sent'\n | 'result_received'\n | 'failed'\n | 'cancelled';\n\nexport type JobState = ProviderState | CustomerState;\n\nexport const JOB_LEDGER_VERSION = 1;\n\nexport const TERMINAL_STATES: ReadonlySet<JobState> = new Set<JobState>([\n 'delivered',\n 'result_received',\n 'failed',\n 'cancelled',\n]);\n\nexport interface JobLedgerEntry {\n jobEventId: string;\n side: JobSide;\n state: JobState;\n capability: string;\n priceLamports: string;\n rawEventJson?: string;\n customerPubkey?: string;\n providerPubkey?: string;\n input?: string;\n paymentRequestJson?: string;\n txSignature?: string;\n resultContent?: string;\n error?: string;\n retryCount?: number;\n transitionAt: number;\n jobCreatedAt: number;\n version: number;\n}\n\n/**\n * Input to `adapter.write`. The adapter is responsible for stamping\n * `transitionAt` (wall-clock) and `version` so callers can spread an\n * earlier entry safely without carrying those fields forward.\n */\nexport type JobLedgerWriteInput = Omit<JobLedgerEntry, 'transitionAt' | 'version'>;\n\nexport interface JobLedgerAdapter {\n /** Append a new state transition for a job. */\n write(entry: JobLedgerWriteInput): Promise<void>;\n /**\n * Return the latest entry per job id. If `side` is given, restrict to\n * one side; otherwise include both.\n */\n loadLatest(side?: JobSide): Promise<Map<string, JobLedgerEntry>>;\n /**\n * Delete terminal entries whose last transition happened before\n * `now - retentionMs`. Non-terminal entries are retained so recovery\n * can keep retrying. Returns the number of entries dropped.\n */\n pruneOldEntries(retentionMs: number): Promise<number>;\n}\n\n/**\n * Convenience: return non-terminal entries for `side`, oldest-first by\n * `jobCreatedAt`. Derived from `loadLatest` so any adapter satisfies it.\n */\nexport async function pendingJobs(\n adapter: JobLedgerAdapter,\n side: JobSide,\n): Promise<JobLedgerEntry[]> {\n const latest = await adapter.loadLatest(side);\n const pending: JobLedgerEntry[] = [];\n for (const entry of latest.values()) {\n if (!TERMINAL_STATES.has(entry.state)) {\n pending.push(entry);\n }\n }\n pending.sort((left, right) => left.jobCreatedAt - right.jobCreatedAt);\n return pending;\n}\n\nexport async function findByJobId(\n adapter: JobLedgerAdapter,\n jobEventId: string,\n): Promise<JobLedgerEntry | undefined> {\n const latest = await adapter.loadLatest();\n return latest.get(jobEventId);\n}\n","import {\n JOB_LEDGER_VERSION,\n TERMINAL_STATES,\n type JobLedgerAdapter,\n type JobLedgerEntry,\n type JobLedgerWriteInput,\n type JobSide,\n} from './jobLedger';\n\ninterface Row {\n entry: JobLedgerEntry;\n /** Row timestamp, separate from entry.transitionAt so we can resolve ties. */\n rowAt: number;\n}\n\n/**\n * In-memory reference adapter. Useful for tests and ephemeral deployments\n * where no durable backing store is required. Not suitable for real\n * crash-recovery (everything is lost on process exit).\n */\nexport function createMemoryJobLedgerAdapter(): JobLedgerAdapter & {\n /** Test-only: drop all rows. */\n clear(): void;\n /** Test-only: inspect the raw append log. */\n rows(): ReadonlyArray<JobLedgerEntry>;\n} {\n const log: Row[] = [];\n let rowCounter = 0;\n\n function latestByJobId(side?: JobSide): Map<string, JobLedgerEntry> {\n const latest = new Map<string, JobLedgerEntry>();\n const latestRowAt = new Map<string, number>();\n for (const row of log) {\n if (side && row.entry.side !== side) {\n continue;\n }\n const jobId = row.entry.jobEventId;\n const previous = latestRowAt.get(jobId) ?? -Infinity;\n if (row.rowAt >= previous) {\n latest.set(jobId, row.entry);\n latestRowAt.set(jobId, row.rowAt);\n }\n }\n return latest;\n }\n\n return {\n async write(entry: JobLedgerWriteInput): Promise<void> {\n const finalized: JobLedgerEntry = {\n ...entry,\n transitionAt: Date.now(),\n version: JOB_LEDGER_VERSION,\n };\n rowCounter += 1;\n log.push({ entry: finalized, rowAt: rowCounter });\n },\n async loadLatest(side?: JobSide): Promise<Map<string, JobLedgerEntry>> {\n return latestByJobId(side);\n },\n async pruneOldEntries(retentionMs: number): Promise<number> {\n const cutoff = Date.now() - retentionMs;\n const latest = latestByJobId();\n const dropIds = new Set<string>();\n for (const [jobId, entry] of latest) {\n if (TERMINAL_STATES.has(entry.state) && entry.transitionAt < cutoff) {\n dropIds.add(jobId);\n }\n }\n if (dropIds.size === 0) {\n return 0;\n }\n let deleted = 0;\n for (let index = log.length - 1; index >= 0; index--) {\n const row = log[index];\n if (row && dropIds.has(row.entry.jobEventId)) {\n log.splice(index, 1);\n deleted += 1;\n }\n }\n return deleted;\n },\n clear(): void {\n log.length = 0;\n rowCounter = 0;\n },\n rows(): ReadonlyArray<JobLedgerEntry> {\n return log.map((row) => row.entry);\n },\n };\n}\n","import { pendingJobs, type JobLedgerAdapter, type JobLedgerEntry, type JobSide } from './jobLedger';\n\nexport interface RecoveryLoopLogger {\n info?(obj: Record<string, unknown>, msg?: string): void;\n warn?(obj: Record<string, unknown>, msg?: string): void;\n debug?(obj: Record<string, unknown>, msg?: string): void;\n}\n\nexport interface RecoveryLoopOptions {\n adapter: JobLedgerAdapter;\n /** Called for each non-terminal provider-side entry. Should advance state. */\n onProviderPending?: (entry: JobLedgerEntry) => Promise<void>;\n /** Called for each non-terminal customer-side entry. Should advance state. */\n onCustomerPending?: (entry: JobLedgerEntry) => Promise<void>;\n /** Sweep cadence. */\n intervalMs: number;\n /** Retention for terminal entries. Passed to adapter.pruneOldEntries each sweep. */\n retentionMs: number;\n /** Concurrent per-job worker count during a sweep. */\n concurrency: number;\n /** Optional structured logger. Falls back to silence. */\n logger?: RecoveryLoopLogger;\n}\n\nexport interface RecoveryLoop {\n /** Kick off an initial sweep (non-blocking) and start the periodic timer. */\n start(): void;\n /** Stop the periodic timer. Does not cancel an in-flight sweep. */\n stop(): void;\n /** Run a single sweep synchronously. Useful for tests or manual triggers. */\n sweepOnce(): Promise<void>;\n}\n\n/**\n * Generic recovery scaffold: periodic pruning + concurrent replay of\n * non-terminal ledger entries. Per-side handlers own the business\n * semantics (retry budget, re-execute, payment verify, delivery retry).\n *\n * Notes:\n * - Overlap guard: if the previous sweep is still in flight, subsequent\n * tick fires are skipped - no queueing. The next on-schedule fire will\n * pick up where the previous left off (ledger is idempotent).\n * - Handler errors are caught and logged at `warn` so a single bad entry\n * doesn't poison the batch.\n */\nexport function createRecoveryLoop(options: RecoveryLoopOptions): RecoveryLoop {\n const { adapter, onProviderPending, onCustomerPending, intervalMs, retentionMs, concurrency } =\n options;\n const logger = options.logger ?? {};\n if (intervalMs <= 0) {\n throw new RangeError('intervalMs must be > 0');\n }\n if (concurrency <= 0) {\n throw new RangeError('concurrency must be > 0');\n }\n if (retentionMs <= 0) {\n throw new RangeError('retentionMs must be > 0');\n }\n\n let timer: ReturnType<typeof setInterval> | undefined;\n let running = false;\n\n async function runBatch(\n entries: JobLedgerEntry[],\n handler: (entry: JobLedgerEntry) => Promise<void>,\n ): Promise<void> {\n let index = 0;\n const workerCount = Math.min(concurrency, entries.length);\n const workers = Array.from({ length: workerCount }, async () => {\n while (index < entries.length) {\n const currentIndex = index++;\n const entry = entries[currentIndex];\n if (!entry) {\n continue;\n }\n try {\n await handler(entry);\n } catch (error) {\n logger.warn?.(\n {\n err: error instanceof Error ? error.message : String(error),\n jobEventId: entry.jobEventId,\n side: entry.side,\n state: entry.state,\n },\n 'recovery handler threw',\n );\n }\n }\n });\n await Promise.all(workers);\n }\n\n async function sweepSide(\n side: JobSide,\n handler: ((entry: JobLedgerEntry) => Promise<void>) | undefined,\n ): Promise<void> {\n if (!handler) {\n return;\n }\n const pending = await pendingJobs(adapter, side);\n if (pending.length === 0) {\n return;\n }\n logger.info?.({ [side]: pending.length }, 'recovery sweep: resuming pending jobs');\n await runBatch(pending, handler);\n }\n\n async function sweepOnce(): Promise<void> {\n if (running) {\n return;\n }\n running = true;\n try {\n try {\n await adapter.pruneOldEntries(retentionMs);\n } catch (error) {\n logger.warn?.(\n { err: error instanceof Error ? error.message : String(error) },\n 'recovery: pruneOldEntries failed',\n );\n }\n await sweepSide('provider', onProviderPending);\n await sweepSide('customer', onCustomerPending);\n } finally {\n running = false;\n }\n }\n\n function start(): void {\n if (timer) {\n return;\n }\n // Initial sweep kicked off in background so start() returns quickly.\n sweepOnce().catch((error) =>\n logger.warn?.(\n { err: error instanceof Error ? error.message : String(error) },\n 'initial recovery sweep failed',\n ),\n );\n timer = setInterval(() => {\n sweepOnce().catch((error) =>\n logger.warn?.(\n { err: error instanceof Error ? error.message : String(error) },\n 'recovery sweep failed',\n ),\n );\n }, intervalMs);\n }\n\n function stop(): void {\n if (timer) {\n clearInterval(timer);\n timer = undefined;\n }\n }\n\n return { start, stop, sweepOnce };\n}\n"]}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JobLedger types + adapter interface for elisym crash recovery.
|
|
3
|
+
*
|
|
4
|
+
* The ledger is an append-only log of state transitions for each job,
|
|
5
|
+
* keyed by the Nostr job-request event id. Each adapter chooses its own
|
|
6
|
+
* storage backend (Eliza memory, SQLite, tests-only in-memory) and
|
|
7
|
+
* exposes the minimal read/write surface this module defines.
|
|
8
|
+
*/
|
|
9
|
+
type JobSide = 'provider' | 'customer';
|
|
10
|
+
type ProviderState = 'waiting_payment' | 'paid' | 'executed' | 'delivered' | 'failed' | 'cancelled';
|
|
11
|
+
type CustomerState = 'submitted' | 'waiting_payment' | 'payment_sent' | 'result_received' | 'failed' | 'cancelled';
|
|
12
|
+
type JobState = ProviderState | CustomerState;
|
|
13
|
+
declare const JOB_LEDGER_VERSION = 1;
|
|
14
|
+
declare const TERMINAL_STATES: ReadonlySet<JobState>;
|
|
15
|
+
interface JobLedgerEntry {
|
|
16
|
+
jobEventId: string;
|
|
17
|
+
side: JobSide;
|
|
18
|
+
state: JobState;
|
|
19
|
+
capability: string;
|
|
20
|
+
priceLamports: string;
|
|
21
|
+
rawEventJson?: string;
|
|
22
|
+
customerPubkey?: string;
|
|
23
|
+
providerPubkey?: string;
|
|
24
|
+
input?: string;
|
|
25
|
+
paymentRequestJson?: string;
|
|
26
|
+
txSignature?: string;
|
|
27
|
+
resultContent?: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
retryCount?: number;
|
|
30
|
+
transitionAt: number;
|
|
31
|
+
jobCreatedAt: number;
|
|
32
|
+
version: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Input to `adapter.write`. The adapter is responsible for stamping
|
|
36
|
+
* `transitionAt` (wall-clock) and `version` so callers can spread an
|
|
37
|
+
* earlier entry safely without carrying those fields forward.
|
|
38
|
+
*/
|
|
39
|
+
type JobLedgerWriteInput = Omit<JobLedgerEntry, 'transitionAt' | 'version'>;
|
|
40
|
+
interface JobLedgerAdapter {
|
|
41
|
+
/** Append a new state transition for a job. */
|
|
42
|
+
write(entry: JobLedgerWriteInput): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Return the latest entry per job id. If `side` is given, restrict to
|
|
45
|
+
* one side; otherwise include both.
|
|
46
|
+
*/
|
|
47
|
+
loadLatest(side?: JobSide): Promise<Map<string, JobLedgerEntry>>;
|
|
48
|
+
/**
|
|
49
|
+
* Delete terminal entries whose last transition happened before
|
|
50
|
+
* `now - retentionMs`. Non-terminal entries are retained so recovery
|
|
51
|
+
* can keep retrying. Returns the number of entries dropped.
|
|
52
|
+
*/
|
|
53
|
+
pruneOldEntries(retentionMs: number): Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Convenience: return non-terminal entries for `side`, oldest-first by
|
|
57
|
+
* `jobCreatedAt`. Derived from `loadLatest` so any adapter satisfies it.
|
|
58
|
+
*/
|
|
59
|
+
declare function pendingJobs(adapter: JobLedgerAdapter, side: JobSide): Promise<JobLedgerEntry[]>;
|
|
60
|
+
declare function findByJobId(adapter: JobLedgerAdapter, jobEventId: string): Promise<JobLedgerEntry | undefined>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* In-memory reference adapter. Useful for tests and ephemeral deployments
|
|
64
|
+
* where no durable backing store is required. Not suitable for real
|
|
65
|
+
* crash-recovery (everything is lost on process exit).
|
|
66
|
+
*/
|
|
67
|
+
declare function createMemoryJobLedgerAdapter(): JobLedgerAdapter & {
|
|
68
|
+
/** Test-only: drop all rows. */
|
|
69
|
+
clear(): void;
|
|
70
|
+
/** Test-only: inspect the raw append log. */
|
|
71
|
+
rows(): ReadonlyArray<JobLedgerEntry>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
interface RecoveryLoopLogger {
|
|
75
|
+
info?(obj: Record<string, unknown>, msg?: string): void;
|
|
76
|
+
warn?(obj: Record<string, unknown>, msg?: string): void;
|
|
77
|
+
debug?(obj: Record<string, unknown>, msg?: string): void;
|
|
78
|
+
}
|
|
79
|
+
interface RecoveryLoopOptions {
|
|
80
|
+
adapter: JobLedgerAdapter;
|
|
81
|
+
/** Called for each non-terminal provider-side entry. Should advance state. */
|
|
82
|
+
onProviderPending?: (entry: JobLedgerEntry) => Promise<void>;
|
|
83
|
+
/** Called for each non-terminal customer-side entry. Should advance state. */
|
|
84
|
+
onCustomerPending?: (entry: JobLedgerEntry) => Promise<void>;
|
|
85
|
+
/** Sweep cadence. */
|
|
86
|
+
intervalMs: number;
|
|
87
|
+
/** Retention for terminal entries. Passed to adapter.pruneOldEntries each sweep. */
|
|
88
|
+
retentionMs: number;
|
|
89
|
+
/** Concurrent per-job worker count during a sweep. */
|
|
90
|
+
concurrency: number;
|
|
91
|
+
/** Optional structured logger. Falls back to silence. */
|
|
92
|
+
logger?: RecoveryLoopLogger;
|
|
93
|
+
}
|
|
94
|
+
interface RecoveryLoop {
|
|
95
|
+
/** Kick off an initial sweep (non-blocking) and start the periodic timer. */
|
|
96
|
+
start(): void;
|
|
97
|
+
/** Stop the periodic timer. Does not cancel an in-flight sweep. */
|
|
98
|
+
stop(): void;
|
|
99
|
+
/** Run a single sweep synchronously. Useful for tests or manual triggers. */
|
|
100
|
+
sweepOnce(): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Generic recovery scaffold: periodic pruning + concurrent replay of
|
|
104
|
+
* non-terminal ledger entries. Per-side handlers own the business
|
|
105
|
+
* semantics (retry budget, re-execute, payment verify, delivery retry).
|
|
106
|
+
*
|
|
107
|
+
* Notes:
|
|
108
|
+
* - Overlap guard: if the previous sweep is still in flight, subsequent
|
|
109
|
+
* tick fires are skipped - no queueing. The next on-schedule fire will
|
|
110
|
+
* pick up where the previous left off (ledger is idempotent).
|
|
111
|
+
* - Handler errors are caught and logged at `warn` so a single bad entry
|
|
112
|
+
* doesn't poison the batch.
|
|
113
|
+
*/
|
|
114
|
+
declare function createRecoveryLoop(options: RecoveryLoopOptions): RecoveryLoop;
|
|
115
|
+
|
|
116
|
+
export { type CustomerState, JOB_LEDGER_VERSION, type JobLedgerAdapter, type JobLedgerEntry, type JobLedgerWriteInput, type JobSide, type JobState, type ProviderState, type RecoveryLoop, type RecoveryLoopLogger, type RecoveryLoopOptions, TERMINAL_STATES, createMemoryJobLedgerAdapter, createRecoveryLoop, findByJobId, pendingJobs };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JobLedger types + adapter interface for elisym crash recovery.
|
|
3
|
+
*
|
|
4
|
+
* The ledger is an append-only log of state transitions for each job,
|
|
5
|
+
* keyed by the Nostr job-request event id. Each adapter chooses its own
|
|
6
|
+
* storage backend (Eliza memory, SQLite, tests-only in-memory) and
|
|
7
|
+
* exposes the minimal read/write surface this module defines.
|
|
8
|
+
*/
|
|
9
|
+
type JobSide = 'provider' | 'customer';
|
|
10
|
+
type ProviderState = 'waiting_payment' | 'paid' | 'executed' | 'delivered' | 'failed' | 'cancelled';
|
|
11
|
+
type CustomerState = 'submitted' | 'waiting_payment' | 'payment_sent' | 'result_received' | 'failed' | 'cancelled';
|
|
12
|
+
type JobState = ProviderState | CustomerState;
|
|
13
|
+
declare const JOB_LEDGER_VERSION = 1;
|
|
14
|
+
declare const TERMINAL_STATES: ReadonlySet<JobState>;
|
|
15
|
+
interface JobLedgerEntry {
|
|
16
|
+
jobEventId: string;
|
|
17
|
+
side: JobSide;
|
|
18
|
+
state: JobState;
|
|
19
|
+
capability: string;
|
|
20
|
+
priceLamports: string;
|
|
21
|
+
rawEventJson?: string;
|
|
22
|
+
customerPubkey?: string;
|
|
23
|
+
providerPubkey?: string;
|
|
24
|
+
input?: string;
|
|
25
|
+
paymentRequestJson?: string;
|
|
26
|
+
txSignature?: string;
|
|
27
|
+
resultContent?: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
retryCount?: number;
|
|
30
|
+
transitionAt: number;
|
|
31
|
+
jobCreatedAt: number;
|
|
32
|
+
version: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Input to `adapter.write`. The adapter is responsible for stamping
|
|
36
|
+
* `transitionAt` (wall-clock) and `version` so callers can spread an
|
|
37
|
+
* earlier entry safely without carrying those fields forward.
|
|
38
|
+
*/
|
|
39
|
+
type JobLedgerWriteInput = Omit<JobLedgerEntry, 'transitionAt' | 'version'>;
|
|
40
|
+
interface JobLedgerAdapter {
|
|
41
|
+
/** Append a new state transition for a job. */
|
|
42
|
+
write(entry: JobLedgerWriteInput): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Return the latest entry per job id. If `side` is given, restrict to
|
|
45
|
+
* one side; otherwise include both.
|
|
46
|
+
*/
|
|
47
|
+
loadLatest(side?: JobSide): Promise<Map<string, JobLedgerEntry>>;
|
|
48
|
+
/**
|
|
49
|
+
* Delete terminal entries whose last transition happened before
|
|
50
|
+
* `now - retentionMs`. Non-terminal entries are retained so recovery
|
|
51
|
+
* can keep retrying. Returns the number of entries dropped.
|
|
52
|
+
*/
|
|
53
|
+
pruneOldEntries(retentionMs: number): Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Convenience: return non-terminal entries for `side`, oldest-first by
|
|
57
|
+
* `jobCreatedAt`. Derived from `loadLatest` so any adapter satisfies it.
|
|
58
|
+
*/
|
|
59
|
+
declare function pendingJobs(adapter: JobLedgerAdapter, side: JobSide): Promise<JobLedgerEntry[]>;
|
|
60
|
+
declare function findByJobId(adapter: JobLedgerAdapter, jobEventId: string): Promise<JobLedgerEntry | undefined>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* In-memory reference adapter. Useful for tests and ephemeral deployments
|
|
64
|
+
* where no durable backing store is required. Not suitable for real
|
|
65
|
+
* crash-recovery (everything is lost on process exit).
|
|
66
|
+
*/
|
|
67
|
+
declare function createMemoryJobLedgerAdapter(): JobLedgerAdapter & {
|
|
68
|
+
/** Test-only: drop all rows. */
|
|
69
|
+
clear(): void;
|
|
70
|
+
/** Test-only: inspect the raw append log. */
|
|
71
|
+
rows(): ReadonlyArray<JobLedgerEntry>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
interface RecoveryLoopLogger {
|
|
75
|
+
info?(obj: Record<string, unknown>, msg?: string): void;
|
|
76
|
+
warn?(obj: Record<string, unknown>, msg?: string): void;
|
|
77
|
+
debug?(obj: Record<string, unknown>, msg?: string): void;
|
|
78
|
+
}
|
|
79
|
+
interface RecoveryLoopOptions {
|
|
80
|
+
adapter: JobLedgerAdapter;
|
|
81
|
+
/** Called for each non-terminal provider-side entry. Should advance state. */
|
|
82
|
+
onProviderPending?: (entry: JobLedgerEntry) => Promise<void>;
|
|
83
|
+
/** Called for each non-terminal customer-side entry. Should advance state. */
|
|
84
|
+
onCustomerPending?: (entry: JobLedgerEntry) => Promise<void>;
|
|
85
|
+
/** Sweep cadence. */
|
|
86
|
+
intervalMs: number;
|
|
87
|
+
/** Retention for terminal entries. Passed to adapter.pruneOldEntries each sweep. */
|
|
88
|
+
retentionMs: number;
|
|
89
|
+
/** Concurrent per-job worker count during a sweep. */
|
|
90
|
+
concurrency: number;
|
|
91
|
+
/** Optional structured logger. Falls back to silence. */
|
|
92
|
+
logger?: RecoveryLoopLogger;
|
|
93
|
+
}
|
|
94
|
+
interface RecoveryLoop {
|
|
95
|
+
/** Kick off an initial sweep (non-blocking) and start the periodic timer. */
|
|
96
|
+
start(): void;
|
|
97
|
+
/** Stop the periodic timer. Does not cancel an in-flight sweep. */
|
|
98
|
+
stop(): void;
|
|
99
|
+
/** Run a single sweep synchronously. Useful for tests or manual triggers. */
|
|
100
|
+
sweepOnce(): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Generic recovery scaffold: periodic pruning + concurrent replay of
|
|
104
|
+
* non-terminal ledger entries. Per-side handlers own the business
|
|
105
|
+
* semantics (retry budget, re-execute, payment verify, delivery retry).
|
|
106
|
+
*
|
|
107
|
+
* Notes:
|
|
108
|
+
* - Overlap guard: if the previous sweep is still in flight, subsequent
|
|
109
|
+
* tick fires are skipped - no queueing. The next on-schedule fire will
|
|
110
|
+
* pick up where the previous left off (ledger is idempotent).
|
|
111
|
+
* - Handler errors are caught and logged at `warn` so a single bad entry
|
|
112
|
+
* doesn't poison the batch.
|
|
113
|
+
*/
|
|
114
|
+
declare function createRecoveryLoop(options: RecoveryLoopOptions): RecoveryLoop;
|
|
115
|
+
|
|
116
|
+
export { type CustomerState, JOB_LEDGER_VERSION, type JobLedgerAdapter, type JobLedgerEntry, type JobLedgerWriteInput, type JobSide, type JobState, type ProviderState, type RecoveryLoop, type RecoveryLoopLogger, type RecoveryLoopOptions, TERMINAL_STATES, createMemoryJobLedgerAdapter, createRecoveryLoop, findByJobId, pendingJobs };
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// src/runtime/jobLedger.ts
|
|
2
|
+
var JOB_LEDGER_VERSION = 1;
|
|
3
|
+
var TERMINAL_STATES = /* @__PURE__ */ new Set([
|
|
4
|
+
"delivered",
|
|
5
|
+
"result_received",
|
|
6
|
+
"failed",
|
|
7
|
+
"cancelled"
|
|
8
|
+
]);
|
|
9
|
+
async function pendingJobs(adapter, side) {
|
|
10
|
+
const latest = await adapter.loadLatest(side);
|
|
11
|
+
const pending = [];
|
|
12
|
+
for (const entry of latest.values()) {
|
|
13
|
+
if (!TERMINAL_STATES.has(entry.state)) {
|
|
14
|
+
pending.push(entry);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
pending.sort((left, right) => left.jobCreatedAt - right.jobCreatedAt);
|
|
18
|
+
return pending;
|
|
19
|
+
}
|
|
20
|
+
async function findByJobId(adapter, jobEventId) {
|
|
21
|
+
const latest = await adapter.loadLatest();
|
|
22
|
+
return latest.get(jobEventId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/runtime/memoryAdapter.ts
|
|
26
|
+
function createMemoryJobLedgerAdapter() {
|
|
27
|
+
const log = [];
|
|
28
|
+
let rowCounter = 0;
|
|
29
|
+
function latestByJobId(side) {
|
|
30
|
+
const latest = /* @__PURE__ */ new Map();
|
|
31
|
+
const latestRowAt = /* @__PURE__ */ new Map();
|
|
32
|
+
for (const row of log) {
|
|
33
|
+
if (side && row.entry.side !== side) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const jobId = row.entry.jobEventId;
|
|
37
|
+
const previous = latestRowAt.get(jobId) ?? -Infinity;
|
|
38
|
+
if (row.rowAt >= previous) {
|
|
39
|
+
latest.set(jobId, row.entry);
|
|
40
|
+
latestRowAt.set(jobId, row.rowAt);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return latest;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
async write(entry) {
|
|
47
|
+
const finalized = {
|
|
48
|
+
...entry,
|
|
49
|
+
transitionAt: Date.now(),
|
|
50
|
+
version: JOB_LEDGER_VERSION
|
|
51
|
+
};
|
|
52
|
+
rowCounter += 1;
|
|
53
|
+
log.push({ entry: finalized, rowAt: rowCounter });
|
|
54
|
+
},
|
|
55
|
+
async loadLatest(side) {
|
|
56
|
+
return latestByJobId(side);
|
|
57
|
+
},
|
|
58
|
+
async pruneOldEntries(retentionMs) {
|
|
59
|
+
const cutoff = Date.now() - retentionMs;
|
|
60
|
+
const latest = latestByJobId();
|
|
61
|
+
const dropIds = /* @__PURE__ */ new Set();
|
|
62
|
+
for (const [jobId, entry] of latest) {
|
|
63
|
+
if (TERMINAL_STATES.has(entry.state) && entry.transitionAt < cutoff) {
|
|
64
|
+
dropIds.add(jobId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (dropIds.size === 0) {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
let deleted = 0;
|
|
71
|
+
for (let index = log.length - 1; index >= 0; index--) {
|
|
72
|
+
const row = log[index];
|
|
73
|
+
if (row && dropIds.has(row.entry.jobEventId)) {
|
|
74
|
+
log.splice(index, 1);
|
|
75
|
+
deleted += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return deleted;
|
|
79
|
+
},
|
|
80
|
+
clear() {
|
|
81
|
+
log.length = 0;
|
|
82
|
+
rowCounter = 0;
|
|
83
|
+
},
|
|
84
|
+
rows() {
|
|
85
|
+
return log.map((row) => row.entry);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/runtime/recoveryLoop.ts
|
|
91
|
+
function createRecoveryLoop(options) {
|
|
92
|
+
const { adapter, onProviderPending, onCustomerPending, intervalMs, retentionMs, concurrency } = options;
|
|
93
|
+
const logger = options.logger ?? {};
|
|
94
|
+
if (intervalMs <= 0) {
|
|
95
|
+
throw new RangeError("intervalMs must be > 0");
|
|
96
|
+
}
|
|
97
|
+
if (concurrency <= 0) {
|
|
98
|
+
throw new RangeError("concurrency must be > 0");
|
|
99
|
+
}
|
|
100
|
+
if (retentionMs <= 0) {
|
|
101
|
+
throw new RangeError("retentionMs must be > 0");
|
|
102
|
+
}
|
|
103
|
+
let timer;
|
|
104
|
+
let running = false;
|
|
105
|
+
async function runBatch(entries, handler) {
|
|
106
|
+
let index = 0;
|
|
107
|
+
const workerCount = Math.min(concurrency, entries.length);
|
|
108
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
109
|
+
while (index < entries.length) {
|
|
110
|
+
const currentIndex = index++;
|
|
111
|
+
const entry = entries[currentIndex];
|
|
112
|
+
if (!entry) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await handler(entry);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.warn?.(
|
|
119
|
+
{
|
|
120
|
+
err: error instanceof Error ? error.message : String(error),
|
|
121
|
+
jobEventId: entry.jobEventId,
|
|
122
|
+
side: entry.side,
|
|
123
|
+
state: entry.state
|
|
124
|
+
},
|
|
125
|
+
"recovery handler threw"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
await Promise.all(workers);
|
|
131
|
+
}
|
|
132
|
+
async function sweepSide(side, handler) {
|
|
133
|
+
if (!handler) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const pending = await pendingJobs(adapter, side);
|
|
137
|
+
if (pending.length === 0) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
logger.info?.({ [side]: pending.length }, "recovery sweep: resuming pending jobs");
|
|
141
|
+
await runBatch(pending, handler);
|
|
142
|
+
}
|
|
143
|
+
async function sweepOnce() {
|
|
144
|
+
if (running) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
running = true;
|
|
148
|
+
try {
|
|
149
|
+
try {
|
|
150
|
+
await adapter.pruneOldEntries(retentionMs);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.warn?.(
|
|
153
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
154
|
+
"recovery: pruneOldEntries failed"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
await sweepSide("provider", onProviderPending);
|
|
158
|
+
await sweepSide("customer", onCustomerPending);
|
|
159
|
+
} finally {
|
|
160
|
+
running = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function start() {
|
|
164
|
+
if (timer) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
sweepOnce().catch(
|
|
168
|
+
(error) => logger.warn?.(
|
|
169
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
170
|
+
"initial recovery sweep failed"
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
timer = setInterval(() => {
|
|
174
|
+
sweepOnce().catch(
|
|
175
|
+
(error) => logger.warn?.(
|
|
176
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
177
|
+
"recovery sweep failed"
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
}, intervalMs);
|
|
181
|
+
}
|
|
182
|
+
function stop() {
|
|
183
|
+
if (timer) {
|
|
184
|
+
clearInterval(timer);
|
|
185
|
+
timer = void 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { start, stop, sweepOnce };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { JOB_LEDGER_VERSION, TERMINAL_STATES, createMemoryJobLedgerAdapter, createRecoveryLoop, findByJobId, pendingJobs };
|
|
192
|
+
//# sourceMappingURL=runtime.js.map
|
|
193
|
+
//# sourceMappingURL=runtime.js.map
|