@getopencrawl/sdk 0.0.0-stage → 0.2.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 +598 -2
- package/dist/agentPlatform.d.ts +142 -0
- package/dist/agentPlatform.d.ts.map +1 -0
- package/dist/agentPlatform.js +46 -0
- package/dist/agentPlatform.js.map +1 -0
- package/dist/agents.d.ts +237 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +279 -0
- package/dist/agents.js.map +1 -0
- package/dist/answerContract.d.ts +85 -0
- package/dist/answerContract.d.ts.map +1 -0
- package/dist/answerContract.js +112 -0
- package/dist/answerContract.js.map +1 -0
- package/dist/answerView.d.ts +40 -0
- package/dist/answerView.d.ts.map +1 -0
- package/dist/answerView.js +118 -0
- package/dist/answerView.js.map +1 -0
- package/dist/configuration.d.ts +5 -0
- package/dist/configuration.d.ts.map +1 -0
- package/dist/configuration.js +29 -0
- package/dist/configuration.js.map +1 -0
- package/dist/customers.d.ts +176 -0
- package/dist/customers.d.ts.map +1 -0
- package/dist/customers.js +286 -0
- package/dist/customers.js.map +1 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +58 -0
- package/dist/errors.js.map +1 -0
- package/dist/eventEvidence.d.ts +10 -0
- package/dist/eventEvidence.d.ts.map +1 -0
- package/dist/eventEvidence.js +52 -0
- package/dist/eventEvidence.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +139 -0
- package/dist/index.js.map +1 -0
- package/dist/productMonitors.d.ts +258 -0
- package/dist/productMonitors.d.ts.map +1 -0
- package/dist/productMonitors.js +68 -0
- package/dist/productMonitors.js.map +1 -0
- package/dist/researchAnswerContract.d.ts +8 -0
- package/dist/researchAnswerContract.d.ts.map +1 -0
- package/dist/researchAnswerContract.js +23 -0
- package/dist/researchAnswerContract.js.map +1 -0
- package/dist/researchAnswerView.d.ts +8 -0
- package/dist/researchAnswerView.d.ts.map +1 -0
- package/dist/researchAnswerView.js +27 -0
- package/dist/researchAnswerView.js.map +1 -0
- package/dist/researchCompletion.d.ts +114 -0
- package/dist/researchCompletion.d.ts.map +1 -0
- package/dist/researchCompletion.js +94 -0
- package/dist/researchCompletion.js.map +1 -0
- package/dist/serviceTenants.d.ts +15 -0
- package/dist/serviceTenants.d.ts.map +1 -0
- package/dist/serviceTenants.js +58 -0
- package/dist/serviceTenants.js.map +1 -0
- package/dist/tools.d.ts +59 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +60 -0
- package/dist/tools.js.map +1 -0
- package/dist/transport.d.ts +34 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +227 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +399 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webhooks.d.ts +38 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +73 -0
- package/dist/webhooks.js.map +1 -0
- package/examples/letter-customer-research-demo.mjs +54 -0
- package/examples/letter-customer-research-store.d.mts +38 -0
- package/examples/letter-customer-research-store.mjs +186 -0
- package/examples/letter-customer-research.d.mts +39 -0
- package/examples/letter-customer-research.mjs +194 -0
- package/examples/letter-event-receiver.d.mts +10 -0
- package/examples/letter-event-receiver.mjs +55 -0
- package/examples/letter-event-store.d.mts +35 -0
- package/examples/letter-event-store.mjs +84 -0
- package/examples/letter-seer.d.mts +31 -0
- package/examples/letter-seer.mjs +78 -0
- package/examples/letter-service-tenants.d.mts +12 -0
- package/examples/letter-service-tenants.mjs +49 -0
- package/examples/research-webhook-receiver.mjs +29 -0
- package/package.json +75 -4
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Transport, type MutationOptions, type RequestOptions } from './transport.js';
|
|
2
|
+
import type { OffsetPage, Webhook, WebhookDelivery, WebhookDeliveryReceipt, WebhookInput, WebhookReplay, WebhookRevision, WebhookSecretReceipt, WebhookUpdate } from './types.js';
|
|
3
|
+
export declare class ResearchWebhooks {
|
|
4
|
+
private readonly transport;
|
|
5
|
+
constructor(transport: Transport);
|
|
6
|
+
/** A repeated successful create returns secret:null. It never rotates implicitly. */
|
|
7
|
+
create(collectionId: string, input: WebhookInput, options: MutationOptions): Promise<WebhookSecretReceipt>;
|
|
8
|
+
list(collectionId: string, options?: RequestOptions): Promise<{
|
|
9
|
+
items: Webhook[];
|
|
10
|
+
}>;
|
|
11
|
+
get(id: string, options?: RequestOptions): Promise<Webhook>;
|
|
12
|
+
update(id: string, input: WebhookUpdate, options?: RequestOptions): Promise<Webhook>;
|
|
13
|
+
remove(id: string, input: WebhookRevision, options?: RequestOptions): Promise<Webhook>;
|
|
14
|
+
rotate(id: string, input: WebhookRevision, options: MutationOptions): Promise<WebhookSecretReceipt>;
|
|
15
|
+
test(id: string, input: WebhookRevision, options: MutationOptions): Promise<WebhookDeliveryReceipt>;
|
|
16
|
+
deliveries(id: string, options?: RequestOptions & {
|
|
17
|
+
limit?: number;
|
|
18
|
+
offset?: number;
|
|
19
|
+
}): Promise<OffsetPage<WebhookDelivery>>;
|
|
20
|
+
replay(id: string, deliveryId: string, input: WebhookReplay, options: MutationOptions): Promise<WebhookDeliveryReceipt>;
|
|
21
|
+
}
|
|
22
|
+
export type WebhookSignatureInput = {
|
|
23
|
+
secret: string;
|
|
24
|
+
body: Uint8Array;
|
|
25
|
+
headers: {
|
|
26
|
+
'webhook-id': string;
|
|
27
|
+
'webhook-timestamp': string;
|
|
28
|
+
'webhook-signature': string;
|
|
29
|
+
};
|
|
30
|
+
toleranceSeconds?: number;
|
|
31
|
+
nowSeconds?: number;
|
|
32
|
+
};
|
|
33
|
+
/** Verifies raw bytes before JSON parsing. Delivery-ID deduplication is the receiver's responsibility. */
|
|
34
|
+
export declare function verifyResearchWebhook(input: WebhookSignatureInput): Promise<{
|
|
35
|
+
deliveryId: string;
|
|
36
|
+
timestamp: number;
|
|
37
|
+
} | null>;
|
|
38
|
+
//# sourceMappingURL=webhooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../src/webhooks.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,SAAS,EAAsC,KAAK,eAAe,EAAC,KAAK,cAAc,EAAC,MAAM,gBAAgB,CAAC;AACvH,OAAO,KAAK,EAAC,UAAU,EAAC,OAAO,EAAC,eAAe,EAAC,sBAAsB,EAAC,YAAY,EAAC,aAAa,EAAC,eAAe,EAAC,oBAAoB,EAAC,aAAa,EAAC,MAAM,YAAY,CAAC;AAGxK,qBAAa,gBAAgB;IAChB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAC,SAAS;IAChD,qFAAqF;IACrF,MAAM,CAAC,YAAY,EAAC,MAAM,EAAC,KAAK,EAAC,YAAY,EAAC,OAAO,EAAC,eAAe,GAAE,OAAO,CAAC,oBAAoB,CAAC;IAM9F,IAAI,CAAC,YAAY,EAAC,MAAM,EAAC,OAAO,GAAC,cAAiB,GAAE,OAAO,CAAC;QAAC,KAAK,EAAC,OAAO,EAAE,CAAA;KAAC,CAAC;IACpF,GAAG,CAAC,EAAE,EAAC,MAAM,EAAC,OAAO,GAAC,cAAiB,GAAE,OAAO,CAAC,OAAO,CAAC;IACzD,MAAM,CAAC,EAAE,EAAC,MAAM,EAAC,KAAK,EAAC,aAAa,EAAC,OAAO,GAAC,cAAiB,GAAE,OAAO,CAAC,OAAO,CAAC;IAChF,MAAM,CAAC,EAAE,EAAC,MAAM,EAAC,KAAK,EAAC,eAAe,EAAC,OAAO,GAAC,cAAiB,GAAE,OAAO,CAAC,OAAO,CAAC;IAClF,MAAM,CAAC,EAAE,EAAC,MAAM,EAAC,KAAK,EAAC,eAAe,EAAC,OAAO,EAAC,eAAe,GAAE,OAAO,CAAC,oBAAoB,CAAC;IAC7F,IAAI,CAAC,EAAE,EAAC,MAAM,EAAC,KAAK,EAAC,eAAe,EAAC,OAAO,EAAC,eAAe,GAAE,OAAO,CAAC,sBAAsB,CAAC;IACvF,UAAU,CAAC,EAAE,EAAC,MAAM,EAAC,OAAO,GAAC,cAAc,GAAC;QAAC,KAAK,CAAC,EAAC,MAAM,CAAC;QAAA,MAAM,CAAC,EAAC,MAAM,CAAA;KAAI,GAAE,OAAO,CAAC,UAAU,CAAC,eAAe,CAAC,CAAC;IACzH,MAAM,CAAC,EAAE,EAAC,MAAM,EAAC,UAAU,EAAC,MAAM,EAAC,KAAK,EAAC,aAAa,EAAC,OAAO,EAAC,eAAe,GAAE,OAAO,CAAC,sBAAsB,CAAC;CAC/G;AAED,MAAM,MAAM,qBAAqB,GAAC;IAAC,MAAM,EAAC,MAAM,CAAC;IAAA,IAAI,EAAC,UAAU,CAAC;IAAA,OAAO,EAAC;QAAC,YAAY,EAAC,MAAM,CAAC;QAAA,mBAAmB,EAAC,MAAM,CAAC;QAAA,mBAAmB,EAAC,MAAM,CAAA;KAAC,CAAC;IAAA,gBAAgB,CAAC,EAAC,MAAM,CAAC;IAAA,UAAU,CAAC,EAAC,MAAM,CAAA;CAAC,CAAC;AAElM,0GAA0G;AAC1G,wBAAsB,qBAAqB,CAAC,KAAK,EAAC,qBAAqB,GAAE,OAAO,CAAC;IAAC,UAAU,EAAC,MAAM,CAAC;IAAA,SAAS,EAAC,MAAM,CAAA;CAAC,GAAC,IAAI,CAAC,CAa1H"}
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { OpenCrawlProtocolError, OpenCrawlValidationError } from './errors.js';
|
|
2
|
+
import { boundedInteger, idPath, idempotencyKey } from './transport.js';
|
|
3
|
+
function revision(value) { boundedInteger(value.expected_revision, 'expected_revision', 1, Number.MAX_SAFE_INTEGER); }
|
|
4
|
+
export class ResearchWebhooks {
|
|
5
|
+
transport;
|
|
6
|
+
constructor(transport) {
|
|
7
|
+
this.transport = transport;
|
|
8
|
+
}
|
|
9
|
+
/** A repeated successful create returns secret:null. It never rotates implicitly. */
|
|
10
|
+
create(collectionId, input, options) {
|
|
11
|
+
idempotencyKey(options?.idempotencyKey);
|
|
12
|
+
boundedInteger(input.expected_collection_revision, 'expected_collection_revision', 1, Number.MAX_SAFE_INTEGER);
|
|
13
|
+
let endpoint;
|
|
14
|
+
try {
|
|
15
|
+
endpoint = new URL(input.endpoint);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new OpenCrawlValidationError('The webhook endpoint must be an HTTPS URL.');
|
|
19
|
+
}
|
|
20
|
+
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash)
|
|
21
|
+
throw new OpenCrawlValidationError('The webhook endpoint must use HTTPS without credentials, query or fragment.');
|
|
22
|
+
return this.transport.request(`/api/research/collections/${idPath(collectionId)}/webhooks`, { ...options, method: 'POST', body: input });
|
|
23
|
+
}
|
|
24
|
+
async list(collectionId, options = {}) { const value = await this.transport.request(`/api/research/collections/${idPath(collectionId)}/webhooks`, options); if (!value || !Array.isArray(value.items))
|
|
25
|
+
throw new OpenCrawlProtocolError('OpenCrawl returned an invalid webhook list.'); return value; }
|
|
26
|
+
get(id, options = {}) { return this.transport.request(`/api/research/webhooks/${idPath(id)}`, options); }
|
|
27
|
+
update(id, input, options = {}) { revision(input); if (!['active', 'paused'].includes(input.status))
|
|
28
|
+
throw new OpenCrawlValidationError('status must be active or paused.'); return this.transport.request(`/api/research/webhooks/${idPath(id)}`, { ...options, method: 'PATCH', body: input }); }
|
|
29
|
+
remove(id, input, options = {}) { revision(input); return this.transport.request(`/api/research/webhooks/${idPath(id)}`, { ...options, method: 'DELETE', body: input }); }
|
|
30
|
+
rotate(id, input, options) { revision(input); idempotencyKey(options?.idempotencyKey); return this.transport.request(`/api/research/webhooks/${idPath(id)}/rotate`, { ...options, method: 'POST', body: input }); }
|
|
31
|
+
test(id, input, options) { revision(input); idempotencyKey(options?.idempotencyKey); return this.transport.request(`/api/research/webhooks/${idPath(id)}/test`, { ...options, method: 'POST', body: input }); }
|
|
32
|
+
async deliveries(id, options = {}) { const limit = boundedInteger(options.limit ?? 20, 'limit', 1, 50), offset = boundedInteger(options.offset ?? 0, 'offset', 0, 1000000); const value = await this.transport.request(`/api/research/webhooks/${idPath(id)}/deliveries?${new URLSearchParams({ limit: String(limit), offset: String(offset) })}`, options); if (!value || !Array.isArray(value.items) || !Number.isSafeInteger(value.total) || !Number.isSafeInteger(value.offset) || !Number.isSafeInteger(value.limit) || typeof value.has_more !== 'boolean')
|
|
33
|
+
throw new OpenCrawlProtocolError('OpenCrawl returned an invalid delivery page.'); return value; }
|
|
34
|
+
replay(id, deliveryId, input, options) { revision(input); boundedInteger(input.expected_replay_count, 'expected_replay_count', 0, Number.MAX_SAFE_INTEGER); idempotencyKey(options?.idempotencyKey); return this.transport.request(`/api/research/webhooks/${idPath(id)}/deliveries/${idPath(deliveryId)}/replay`, { ...options, method: 'POST', body: input }); }
|
|
35
|
+
}
|
|
36
|
+
function decode(value) { try {
|
|
37
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0)
|
|
38
|
+
return null;
|
|
39
|
+
const bytes = Uint8Array.from(atob(value), character => character.charCodeAt(0));
|
|
40
|
+
return bytes.length === 32 ? bytes : null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
} }
|
|
45
|
+
/** Verifies raw bytes before JSON parsing. Delivery-ID deduplication is the receiver's responsibility. */
|
|
46
|
+
export async function verifyResearchWebhook(input) {
|
|
47
|
+
if ('window' in globalThis && 'document' in globalThis)
|
|
48
|
+
throw new OpenCrawlValidationError('Verify webhook signatures on your server.');
|
|
49
|
+
const tolerance = boundedInteger(input.toleranceSeconds ?? 300, 'toleranceSeconds', 0, 3600), current = boundedInteger(input.nowSeconds ?? Math.floor(Date.now() / 1000), 'nowSeconds', 0, Number.MAX_SAFE_INTEGER);
|
|
50
|
+
const secret = typeof input.secret === 'string' && input.secret.startsWith('whsec_') ? decode(input.secret.slice(6)) : null;
|
|
51
|
+
if (!secret)
|
|
52
|
+
throw new OpenCrawlValidationError('A webhook signing secret is required.');
|
|
53
|
+
const headers = input.headers, id = headers?.['webhook-id'], rawTime = headers?.['webhook-timestamp'], signatures = headers?.['webhook-signature'];
|
|
54
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(id) || typeof rawTime !== 'string' || !/^\d{1,16}$/.test(rawTime) || typeof signatures !== 'string' || signatures.length > 1024 || !(input.body instanceof Uint8Array) || input.body.byteLength > 16384)
|
|
55
|
+
return null;
|
|
56
|
+
const timestamp = Number(rawTime);
|
|
57
|
+
if (!Number.isSafeInteger(timestamp) || Math.abs(current - timestamp) > tolerance)
|
|
58
|
+
return null;
|
|
59
|
+
const candidates = signatures.split(/\s+/);
|
|
60
|
+
if (candidates.length > 10)
|
|
61
|
+
return null;
|
|
62
|
+
const prefix = new TextEncoder().encode(`${id}.${rawTime}.`), bytes = new Uint8Array(prefix.length + input.body.length);
|
|
63
|
+
bytes.set(prefix);
|
|
64
|
+
bytes.set(input.body, prefix.length);
|
|
65
|
+
const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const signature = candidate.startsWith('v1,') ? decode(candidate.slice(3)) : null;
|
|
68
|
+
if (signature && await crypto.subtle.verify('HMAC', key, signature, bytes))
|
|
69
|
+
return { deliveryId: id, timestamp };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=webhooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../src/webhooks.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Runnable offline composition. Node24: node letter-customer-research-demo.mjs --mock
|
|
2
|
+
* This fake transport never reaches a provider or API. Replace the authorization,
|
|
3
|
+
* encrypted vault, and durable provisioning receipt adapters in YOUR server;
|
|
4
|
+
* do not use the mock's in-memory vault for customer credentials.
|
|
5
|
+
*/
|
|
6
|
+
import {OpenCrawl} from '@getopencrawl/sdk';
|
|
7
|
+
import {provisionCustomer} from './letter-service-tenants.mjs';
|
|
8
|
+
import {LetterCustomerResearchStore} from './letter-customer-research-store.mjs';
|
|
9
|
+
import {createLetterCustomerResearch} from './letter-customer-research.mjs';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import {pathToFileURL} from 'node:url';
|
|
14
|
+
|
|
15
|
+
export async function runCustomerResearchMock(){
|
|
16
|
+
const directory=fs.mkdtempSync(path.join(os.tmpdir(),'letter-research-mock-'));fs.chmodSync(directory,0o700);
|
|
17
|
+
const store=new LetterCustomerResearchStore(path.join(directory,'research.sqlite')),vault=new Map();
|
|
18
|
+
const customerId='customer_demo',tenantId='stn_'+'c'.repeat(32),agentId='ragent_'+'a'.repeat(32),runId='job_'+'b'.repeat(32),stamp='2026-10-24T00:00:00.000Z';
|
|
19
|
+
const childKey='synthetic-child-key-not-a-credential',parentKey='synthetic-parent-key-not-a-credential';
|
|
20
|
+
const brief={customer:{name:'Demo customer',website:'https://customer.example/',context:''},goal:'Find a documented workflow and preserve restrictions.',collection_ids:[],schedule_minutes:0};
|
|
21
|
+
const queued={id:runId,agent_id:agentId,agent_revision:1,status:'queued',phase:'queued',created_at:stamp,started_at:null,finished_at:null,outcome:null,findings:[],gaps:[],usage:{},error:null};
|
|
22
|
+
const agent={id:agentId,...brief,revision:1,status:'active',created_at:stamp,updated_at:stamp,latest_run:queued};
|
|
23
|
+
const finished={...queued,status:'partial',phase:'finished',started_at:stamp,finished_at:stamp,outcome:'no_supported_findings',gaps:['No supported findings were returned in this synthetic example.']};
|
|
24
|
+
const tenant={id:tenantId,external_customer_id:customerId,name:'Demo customer',workspace_id:'ws_demo',machine_user_id:'usr_demo',status:'active',access_state:'active',revision:1,provisioning_key_id:'parent_demo',credential:null,created_at:stamp,updated_at:stamp,revoked_at:null};
|
|
25
|
+
let admissions=0,reads=0;
|
|
26
|
+
const json=value=>new Response(JSON.stringify(value));
|
|
27
|
+
try{
|
|
28
|
+
// Host production code persists its exact provisioning descriptor/key BEFORE
|
|
29
|
+
// provisionCustomer. Recover uncertain receipts; missing first-response secret
|
|
30
|
+
// needs explicit rotation, never automatic tenant recreation. This mock has
|
|
31
|
+
// no credential lifecycle/remote provisioning and demonstrates composition only.
|
|
32
|
+
const parent=new OpenCrawl({baseUrl:'https://api.example.invalid',apiKey:parentKey,maxRetries:0,fetch:async()=>json({tenant,receipt:{id:'streq_demo',operation:'create',tenant_id:tenantId,target_revision:1,created_at:stamp},key:childKey,secret_available:true,replayed:false})});
|
|
33
|
+
await provisionCustomer(parent,{customerId,name:'Demo customer',createKey:'mock-provision-key-00001',secrets:{put:async(id,key)=>vault.set(id,key)},recordReceipt:async({tenant})=>store.bindCustomer({customerId,tenantId:tenant.id,secretRef:tenant.id,active:true})});
|
|
34
|
+
const recipe=createLetterCustomerResearch({store,baseUrl:parent.baseUrl,
|
|
35
|
+
authorize:async(session,id)=>session?.authenticated===true&&session.customerIds.includes(id),
|
|
36
|
+
// A real implementation resolves this logical reference via an encrypted
|
|
37
|
+
// server vault. This Map contains only deliberately synthetic mock values.
|
|
38
|
+
secrets:{get:async ref=>vault.get(ref)},
|
|
39
|
+
fetch:async(url,init)=>{
|
|
40
|
+
if(init.headers.Authorization!==`Bearer ${childKey}`)throw Error('Mock child isolation failed');
|
|
41
|
+
if(init.method==='POST'&&new URL(url).pathname==='/api/research/agents'){admissions++;return json({agent,run:queued,replayed:false})}
|
|
42
|
+
if(init.method!=='GET')throw Error('Unexpected mock write');reads++;
|
|
43
|
+
return new URL(url).search?json({items:[finished],next_cursor:'checkpoint_demo',has_more:false}):json(finished);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const session={authenticated:true,customerIds:[customerId]},accepted=await recipe.start(session,customerId,brief);
|
|
47
|
+
const saved=await recipe.read(session,customerId,accepted.runId),polled=await recipe.poll(session,customerId);
|
|
48
|
+
return {mock:true,admissions,reads,terminalStatus:saved.status,findings:saved.findings.length,pending:polled.pending,checkpointSaved:store.snapshot(customerId).cursor!==null,credentialsPersisted:false};
|
|
49
|
+
}finally{vault.clear();store.close();fs.rmSync(directory,{recursive:true,force:true})}
|
|
50
|
+
}
|
|
51
|
+
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
|
52
|
+
if(process.argv.length!==3||process.argv[2]!=='--mock')throw new Error('Only --mock is available; import the recipe into your authorized server.');
|
|
53
|
+
try{console.log(JSON.stringify(await runCustomerResearchMock()))}catch{console.error('Mock recipe did not complete.');process.exitCode=1}
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {ResearchAgent,ResearchAgentInput,ResearchAgentReceipt,ResearchAgentRun,ResearchAgentRunPage} from '@getopencrawl/sdk';
|
|
2
|
+
export class CustomerResearchError extends Error { readonly code:string }
|
|
3
|
+
export type CustomerResearchMapping={customerId:string;tenantId:string;secretRef:string;revision:number;active:boolean;agentId:string|null};
|
|
4
|
+
export type CustomerResearchClaim={mapping:CustomerResearchMapping;token:string;until:number};
|
|
5
|
+
export type CustomerResearchIntent={key:string;brief:ResearchAgentInput;receipt:Omit<ResearchAgentReceipt,'replayed'>|null};
|
|
6
|
+
export type CustomerResearchRerunIntent={key:string;agentId:string;previousRunId:string;expectedRevision:number;runId:string|null;receiptHash:string|null;refusalStatus:409|429|null;refusalMappingRevision:number|null};
|
|
7
|
+
/** Private local metadata/results database; never store credentials here. */
|
|
8
|
+
export class LetterCustomerResearchStore {
|
|
9
|
+
constructor(filename:string,options?:{maxRuns?:number;maxBytes?:number;clock?:()=>number});
|
|
10
|
+
close():void;
|
|
11
|
+
/** Server administration only, after provisioning/receipt recovery. Bump on key rotation. */
|
|
12
|
+
bindCustomer(input:{customerId:string;tenantId:string;secretRef:string;active:boolean}):CustomerResearchMapping;
|
|
13
|
+
mapping(customerId:string):CustomerResearchMapping|null;
|
|
14
|
+
claim(customerId:string,leaseMs:number):CustomerResearchClaim;
|
|
15
|
+
check(claim:CustomerResearchClaim):void;
|
|
16
|
+
release(claim:CustomerResearchClaim):void;
|
|
17
|
+
intent(customerId:string):CustomerResearchIntent|null;
|
|
18
|
+
begin(claim:CustomerResearchClaim,brief:ResearchAgentInput):{key:string;brief:ResearchAgentInput};
|
|
19
|
+
accept(claim:CustomerResearchClaim,receipt:ResearchAgentReceipt):{agentId:string;runId:string};
|
|
20
|
+
rerunIntent(customerId:string,previousRunId:string):CustomerResearchRerunIntent|null;
|
|
21
|
+
retiredRerun(customerId:string,previousRunId:string):CustomerResearchRerunIntent|null;
|
|
22
|
+
/** Trusted metadata for explicit recovery after a restart, not evidence. */
|
|
23
|
+
pendingRerun(customerId:string):CustomerResearchRerunIntent|null;
|
|
24
|
+
beginRerun(claim:CustomerResearchClaim,previousRunId:string,agent:ResearchAgent):CustomerResearchRerunIntent;
|
|
25
|
+
acceptRerun(claim:CustomerResearchClaim,previousRunId:string,receipt:ResearchAgentReceipt):{agentId:string;runId:string};
|
|
26
|
+
refuseRerun(claim:CustomerResearchClaim,previousRunId:string,key:string,status:409|429):void;
|
|
27
|
+
retireRerun(claim:CustomerResearchClaim,previousRunId:string,key:string,agent:ResearchAgent):{refused:true;previousRunId:string};
|
|
28
|
+
saveRun(claim:CustomerResearchClaim,run:ResearchAgentRun):void;
|
|
29
|
+
commitPage(claim:CustomerResearchClaim,page:ResearchAgentRunPage):void;
|
|
30
|
+
cursor(customerId:string):string|undefined;
|
|
31
|
+
/** Next bounded batch, at most25 IDs. */
|
|
32
|
+
pending(customerId:string):string[];
|
|
33
|
+
pendingCount(customerId:string):number;
|
|
34
|
+
storedRuns(customerId:string,options?:{offset?:number;limit?:number}):{items:Array<{runId:string;status:ResearchAgentRun['status']}>;hasMore:boolean};
|
|
35
|
+
knowsRun(customerId:string,runId:string):boolean;
|
|
36
|
+
/** Metadata only; use the recipe's exact authorized read to display evidence. */
|
|
37
|
+
snapshot(customerId:string):{mapping:CustomerResearchMapping|null;intent:{key:string;accepted:boolean;agentId:string|null;runId:string|null}|null;cursor:string|null;pendingIds:string[];pendingCount:number;savedRuns:number};
|
|
38
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** Node24 customer-side example store, never the OpenCrawl service database.
|
|
2
|
+
* Use a private local directory. Results are private plaintext source data;
|
|
3
|
+
* credentials belong only in the host's encrypted secret adapter.
|
|
4
|
+
*/
|
|
5
|
+
import {DatabaseSync} from 'node:sqlite';
|
|
6
|
+
import {randomUUID,createHash} from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {readResearchCompletion} from '../dist/researchCompletion.js';
|
|
10
|
+
export class CustomerResearchError extends Error {
|
|
11
|
+
constructor(code){super(`Customer research: ${code}.`);this.name='CustomerResearchError';this.code=code;}
|
|
12
|
+
}
|
|
13
|
+
const fail=code=>{throw new CustomerResearchError(code)};
|
|
14
|
+
const id=value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,200}$/.test(value);
|
|
15
|
+
const positive=(value,max)=>Number.isSafeInteger(value)&&value>0&&value<=max;
|
|
16
|
+
const pending=run=>run.status==='queued'||run.status==='running';
|
|
17
|
+
const encoded=value=>JSON.stringify(value);
|
|
18
|
+
const canonical=value=>value!==null&&typeof value==='object'?(Array.isArray(value)?value.map(canonical):Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])]))):value;
|
|
19
|
+
const same=(a,b)=>encoded(canonical(a))===encoded(canonical(b));
|
|
20
|
+
export class LetterCustomerResearchStore {
|
|
21
|
+
#db;#clock;#maxRuns;#maxBytes;
|
|
22
|
+
constructor(filename,{maxRuns=1000,maxBytes=33554432,clock=()=>Date.now()}={}){
|
|
23
|
+
if(Number(process.versions.node.split('.')[0])<24||!path.isAbsolute(filename)||!positive(maxRuns,10000)||!positive(maxBytes,134217728))fail('invalid_store');
|
|
24
|
+
const parent=fs.lstatSync(path.dirname(filename));
|
|
25
|
+
if(!parent.isDirectory()||parent.isSymbolicLink()||(parent.mode&0o077)||parent.uid!==process.getuid?.())fail('private_directory_required');
|
|
26
|
+
try{const fd=fs.openSync(filename,'wx',0o600);fs.closeSync(fd)}catch(error){if(error.code!=='EEXIST')throw error}
|
|
27
|
+
const stat=fs.lstatSync(filename);
|
|
28
|
+
if(!stat.isFile()||stat.isSymbolicLink()||(stat.mode&0o077)||stat.uid!==process.getuid?.())fail('private_file_required');
|
|
29
|
+
this.#db=new DatabaseSync(filename);this.#clock=clock;this.#maxRuns=maxRuns;this.#maxBytes=maxBytes;
|
|
30
|
+
this.#db.exec(`PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;
|
|
31
|
+
CREATE TABLE IF NOT EXISTS lcr_customers(customer_id TEXT PRIMARY KEY,tenant_id TEXT UNIQUE NOT NULL,secret_ref TEXT UNIQUE NOT NULL,revision INTEGER NOT NULL,active INTEGER NOT NULL,agent_id TEXT,lease TEXT,lease_until INTEGER);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS lcr_intents(customer_id TEXT PRIMARY KEY REFERENCES lcr_customers(customer_id),request_key TEXT UNIQUE NOT NULL,brief TEXT NOT NULL,receipt TEXT);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS lcr_runs(customer_id TEXT NOT NULL REFERENCES lcr_customers(customer_id),run_id TEXT NOT NULL,agent_id TEXT NOT NULL,record TEXT NOT NULL,bytes INTEGER NOT NULL,pending INTEGER NOT NULL,PRIMARY KEY(customer_id,run_id));
|
|
34
|
+
CREATE TABLE IF NOT EXISTS lcr_cursors(customer_id TEXT PRIMARY KEY REFERENCES lcr_customers(customer_id),cursor TEXT NOT NULL);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS lcr_rerun_intents(customer_id TEXT NOT NULL REFERENCES lcr_customers(customer_id),previous_run_id TEXT NOT NULL,request_key TEXT UNIQUE NOT NULL,agent_id TEXT NOT NULL,expected_revision INTEGER NOT NULL,run_id TEXT,receipt_hash TEXT,refusal_status INTEGER,refusal_mapping_revision INTEGER,PRIMARY KEY(customer_id,previous_run_id));
|
|
36
|
+
CREATE TABLE IF NOT EXISTS lcr_rerun_refusals(customer_id TEXT NOT NULL REFERENCES lcr_customers(customer_id),previous_run_id TEXT NOT NULL,request_key TEXT PRIMARY KEY,agent_id TEXT NOT NULL,expected_revision INTEGER NOT NULL,refusal_status INTEGER NOT NULL,refusal_mapping_revision INTEGER NOT NULL);`);
|
|
37
|
+
// Older local intents have no refusal witness; never infer one from absence.
|
|
38
|
+
const columns=this.#db.prepare('PRAGMA table_info(lcr_rerun_intents)').all().map(row=>row.name);
|
|
39
|
+
for(const column of ['refusal_status','refusal_mapping_revision'])if(!columns.includes(column))this.#db.exec(`ALTER TABLE lcr_rerun_intents ADD COLUMN ${column} INTEGER`);
|
|
40
|
+
}
|
|
41
|
+
close(){this.#db.close()}
|
|
42
|
+
#tx(fn){this.#db.exec('BEGIN IMMEDIATE');try{const value=fn();this.#db.exec('COMMIT');return value}catch(error){this.#db.exec('ROLLBACK');throw error}}
|
|
43
|
+
/** Trusted local administrative binding, not a web request. Provision/recover
|
|
44
|
+
* with the existing service-tenant helper first. Bump this generation whenever
|
|
45
|
+
* replacing a stored key, even if the secret reference itself is unchanged.
|
|
46
|
+
*/
|
|
47
|
+
bindCustomer({customerId,tenantId,secretRef,active}){
|
|
48
|
+
if(typeof customerId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(customerId)||typeof tenantId!=='string'||!/^stn_[0-9a-f]{32}$/.test(tenantId)||!id(secretRef)||typeof active!=='boolean')fail('invalid_mapping');
|
|
49
|
+
return this.#tx(()=>{
|
|
50
|
+
const old=this.mapping(customerId);
|
|
51
|
+
if(old&&old.tenantId!==tenantId)fail('mapping_identity_changed');
|
|
52
|
+
if(!old&&this.#db.prepare('SELECT COUNT(*) n FROM lcr_customers').get().n>=1000)fail('capacity');
|
|
53
|
+
this.#db.prepare(`INSERT INTO lcr_customers VALUES(?,?,?,?,?,NULL,NULL,NULL)
|
|
54
|
+
ON CONFLICT(customer_id) DO UPDATE SET secret_ref=excluded.secret_ref,revision=excluded.revision,active=excluded.active,lease=NULL,lease_until=NULL`).run(customerId,tenantId,secretRef,(old?.revision??0)+1,Number(active));
|
|
55
|
+
return this.mapping(customerId);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
mapping(customerId){const r=this.#db.prepare('SELECT * FROM lcr_customers WHERE customer_id=?').get(customerId);return r?{customerId:r.customer_id,tenantId:r.tenant_id,secretRef:r.secret_ref,revision:r.revision,active:!!r.active,agentId:r.agent_id}:null}
|
|
59
|
+
claim(customerId,leaseMs){
|
|
60
|
+
if(typeof customerId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(customerId)||!positive(leaseMs,65000))fail('invalid_operation');
|
|
61
|
+
return this.#tx(()=>{
|
|
62
|
+
const row=this.#db.prepare('SELECT * FROM lcr_customers WHERE customer_id=?').get(customerId);
|
|
63
|
+
if(!row?.active)fail('customer_unavailable');
|
|
64
|
+
if(row.lease&&row.lease_until>this.#clock())fail('busy');
|
|
65
|
+
const claim={mapping:this.mapping(customerId),token:randomUUID(),until:this.#clock()+leaseMs};
|
|
66
|
+
this.#db.prepare('UPDATE lcr_customers SET lease=?,lease_until=? WHERE customer_id=?').run(claim.token,claim.until,customerId);return claim;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
check(claim){
|
|
70
|
+
const row=this.#db.prepare('SELECT * FROM lcr_customers WHERE customer_id=?').get(claim.mapping.customerId);
|
|
71
|
+
if(!row?.active||row.revision!==claim.mapping.revision||row.tenant_id!==claim.mapping.tenantId||row.secret_ref!==claim.mapping.secretRef||row.lease!==claim.token||row.lease_until<=this.#clock())fail('mapping_or_lease_changed');
|
|
72
|
+
}
|
|
73
|
+
release(claim){this.#db.prepare('UPDATE lcr_customers SET lease=NULL,lease_until=NULL WHERE customer_id=? AND lease=?').run(claim.mapping.customerId,claim.token)}
|
|
74
|
+
intent(customerId){const r=this.#db.prepare('SELECT * FROM lcr_intents WHERE customer_id=?').get(customerId);return r?{key:r.request_key,brief:JSON.parse(r.brief),receipt:r.receipt?JSON.parse(r.receipt):null}:null}
|
|
75
|
+
begin(claim,brief){return this.#tx(()=>{
|
|
76
|
+
this.check(claim);
|
|
77
|
+
if(this.intent(claim.mapping.customerId)||this.mapping(claim.mapping.customerId).agentId)fail('intent_exists_use_recovery');
|
|
78
|
+
const value=encoded(brief);if(Buffer.byteLength(value)>32768)fail('brief_too_large');
|
|
79
|
+
const key=randomUUID();
|
|
80
|
+
this.#db.prepare('INSERT INTO lcr_intents VALUES(?,?,?,NULL)').run(claim.mapping.customerId,key,value);
|
|
81
|
+
return {key,brief:JSON.parse(value)};
|
|
82
|
+
})}
|
|
83
|
+
accept(claim,value){return this.#tx(()=>{
|
|
84
|
+
this.check(claim);const customer=claim.mapping.customerId,intent=this.intent(customer);
|
|
85
|
+
if(!intent)fail('missing_intent');
|
|
86
|
+
const {agent,run}=value,brief={customer:agent.customer,goal:agent.goal,collection_ids:agent.collection_ids,schedule_minutes:agent.schedule_minutes};
|
|
87
|
+
if(Object.hasOwn(intent.brief,'research_policy')){
|
|
88
|
+
// Policy is per run, not an agent field. The immutable create receipt carries
|
|
89
|
+
// admitted metadata on both run views; never certify policy from intent alone.
|
|
90
|
+
const completion=readResearchCompletion(run).completion,latest=readResearchCompletion(agent.latest_run).completion;
|
|
91
|
+
if(!['completion_v1','completion_v2','completion_v3'].includes(intent.brief.research_policy)||!completion||completion.policy!==intent.brief.research_policy||completion.reason!=='admitted'||!same(completion,latest))fail('receipt_mismatch');
|
|
92
|
+
brief.research_policy=completion.policy;
|
|
93
|
+
}
|
|
94
|
+
if(!same(brief,intent.brief)||agent.revision!==1||run.agent_id!==agent.id||agent.latest_run?.id!==run.id)fail('receipt_mismatch');
|
|
95
|
+
const receipt={agent,run};
|
|
96
|
+
if(intent.receipt&&!same(intent.receipt,receipt))fail('receipt_mismatch');
|
|
97
|
+
const old=this.mapping(customer).agentId;if(old&&old!==agent.id)fail('receipt_mismatch');
|
|
98
|
+
this.#db.prepare('UPDATE lcr_customers SET agent_id=? WHERE customer_id=?').run(agent.id,customer);
|
|
99
|
+
this.#db.prepare('UPDATE lcr_intents SET receipt=? WHERE customer_id=?').run(encoded(receipt),customer);
|
|
100
|
+
// Recovering an old queued receipt must never overwrite a newer saved result.
|
|
101
|
+
if(!this.#db.prepare('SELECT 1 FROM lcr_runs WHERE customer_id=? AND run_id=?').get(customer,run.id))this.#put(customer,agent.id,run);
|
|
102
|
+
return {agentId:agent.id,runId:run.id};
|
|
103
|
+
})}
|
|
104
|
+
/** Trusted metadata only. Each predecessor identifies one deliberate rerun. */
|
|
105
|
+
rerunIntent(customerId,previousRunId){const r=this.#db.prepare('SELECT * FROM lcr_rerun_intents WHERE customer_id=? AND previous_run_id=?').get(customerId,previousRunId);return r?{key:r.request_key,agentId:r.agent_id,previousRunId:r.previous_run_id,expectedRevision:r.expected_revision,runId:r.run_id,receiptHash:r.receipt_hash,refusalStatus:r.refusal_status,refusalMappingRevision:r.refusal_mapping_revision}:null}
|
|
106
|
+
retiredRerun(customerId,previousRunId){const r=this.#db.prepare('SELECT * FROM lcr_rerun_refusals WHERE customer_id=? AND previous_run_id=? ORDER BY rowid DESC LIMIT 1').get(customerId,previousRunId);return r?{key:r.request_key,agentId:r.agent_id,previousRunId:r.previous_run_id,expectedRevision:r.expected_revision,runId:null,receiptHash:null,refusalStatus:r.refusal_status,refusalMappingRevision:r.refusal_mapping_revision}:null}
|
|
107
|
+
pendingRerun(customerId){const r=this.#db.prepare('SELECT previous_run_id FROM lcr_rerun_intents WHERE customer_id=? AND receipt_hash IS NULL').get(customerId);return r?this.rerunIntent(customerId,r.previous_run_id):null}
|
|
108
|
+
#sameBrief(customer,agent){const original=this.intent(customer)?.receipt?.agent;return original&&same([agent.customer,agent.goal,agent.collection_ids],[original.customer,original.goal,original.collection_ids])}
|
|
109
|
+
beginRerun(claim,previousRunId,agent){return this.#tx(()=>{
|
|
110
|
+
this.check(claim);const customer=claim.mapping.customerId,agentId=this.mapping(customer).agentId;
|
|
111
|
+
if(!agentId||!this.knowsRun(customer,previousRunId))fail('unknown_run');
|
|
112
|
+
if(this.rerunIntent(customer,previousRunId)||this.pendingRerun(customer))fail('rerun_exists_use_recovery');
|
|
113
|
+
if(agent.id!==agentId||!this.#sameBrief(customer,agent))fail('rerun_scope_mismatch');
|
|
114
|
+
if(agent.status!=='active'||agent.latest_run?.id!==previousRunId||pending(agent.latest_run)||!positive(agent.revision,Number.MAX_SAFE_INTEGER))fail('rerun_predecessor_changed');
|
|
115
|
+
if(this.#db.prepare('SELECT (SELECT COUNT(*) FROM lcr_rerun_intents)+(SELECT COUNT(*) FROM lcr_rerun_refusals) n').get().n>=this.#maxRuns||this.#db.prepare('SELECT COUNT(*) n FROM lcr_runs').get().n>=this.#maxRuns)fail('capacity');
|
|
116
|
+
// Persist the fresh predecessor and new intent atomically before any POST.
|
|
117
|
+
this.#put(customer,agentId,agent.latest_run);
|
|
118
|
+
const key=randomUUID();
|
|
119
|
+
this.#db.prepare('INSERT INTO lcr_rerun_intents(customer_id,previous_run_id,request_key,agent_id,expected_revision) VALUES(?,?,?,?,?)').run(customer,previousRunId,key,agentId,agent.revision);
|
|
120
|
+
return this.rerunIntent(customer,previousRunId);
|
|
121
|
+
})}
|
|
122
|
+
acceptRerun(claim,previousRunId,value){return this.#tx(()=>{
|
|
123
|
+
this.check(claim);const customer=claim.mapping.customerId,intent=this.rerunIntent(customer,previousRunId);
|
|
124
|
+
if(!intent)fail('missing_rerun_intent');
|
|
125
|
+
const {agent,run}=value;
|
|
126
|
+
if(this.mapping(customer).agentId!==intent.agentId||agent.id!==intent.agentId||!this.#sameBrief(customer,agent)||agent.revision!==intent.expectedRevision||run.agent_id!==intent.agentId||run.agent_revision!==intent.expectedRevision||run.id===previousRunId||run.status!=='queued'||!same(agent.latest_run,run))fail('receipt_mismatch');
|
|
127
|
+
const digest=createHash('sha256').update(encoded(canonical({agent,run}))).digest('hex');
|
|
128
|
+
if(intent.receiptHash&&(intent.receiptHash!==digest||intent.runId!==run.id))fail('receipt_mismatch');
|
|
129
|
+
this.#db.prepare('UPDATE lcr_rerun_intents SET run_id=?,receipt_hash=? WHERE customer_id=? AND previous_run_id=?').run(run.id,digest,customer,previousRunId);
|
|
130
|
+
// A historical queued receipt must not replace a newer exact run read.
|
|
131
|
+
if(!this.knowsRun(customer,run.id))this.#put(customer,intent.agentId,run);
|
|
132
|
+
return {agentId:intent.agentId,runId:run.id};
|
|
133
|
+
})}
|
|
134
|
+
/** Called only for the original POST's observed HTTP409/429, after authority. */
|
|
135
|
+
refuseRerun(claim,previousRunId,key,status){return this.#tx(()=>{
|
|
136
|
+
this.check(claim);const customer=claim.mapping.customerId,intent=this.rerunIntent(customer,previousRunId);
|
|
137
|
+
if(!intent||intent.key!==key||intent.runId||intent.receiptHash||![409,429].includes(status))fail('rerun_not_refused');
|
|
138
|
+
this.#db.prepare('UPDATE lcr_rerun_intents SET refusal_status=?,refusal_mapping_revision=? WHERE customer_id=? AND previous_run_id=? AND request_key=?').run(status,claim.mapping.revision,customer,previousRunId,key);
|
|
139
|
+
})}
|
|
140
|
+
/** Explicit recovery proved this refused key absent and freshly read its agent. */
|
|
141
|
+
retireRerun(claim,previousRunId,key,agent){return this.#tx(()=>{
|
|
142
|
+
this.check(claim);const customer=claim.mapping.customerId,active=this.rerunIntent(customer,previousRunId),intent=active??this.retiredRerun(customer,previousRunId);
|
|
143
|
+
if(!intent||intent.key!==key||intent.runId||intent.receiptHash||![409,429].includes(intent.refusalStatus)||intent.refusalMappingRevision!==claim.mapping.revision)fail('rerun_not_refused');
|
|
144
|
+
if(intent.agentId!==this.mapping(customer).agentId||agent.id!==intent.agentId||!this.#sameBrief(customer,agent))fail('rerun_scope_mismatch');
|
|
145
|
+
if(active){
|
|
146
|
+
this.#db.prepare('INSERT INTO lcr_rerun_refusals VALUES(?,?,?,?,?,?,?)').run(customer,previousRunId,key,intent.agentId,intent.expectedRevision,intent.refusalStatus,intent.refusalMappingRevision);
|
|
147
|
+
this.#db.prepare('DELETE FROM lcr_rerun_intents WHERE customer_id=? AND previous_run_id=? AND request_key=?').run(customer,previousRunId,key);
|
|
148
|
+
}
|
|
149
|
+
return {refused:true,previousRunId};
|
|
150
|
+
})}
|
|
151
|
+
#put(customer,agentId,run){
|
|
152
|
+
if(run.agent_id!==agentId||!id(run.id))fail('run_scope_mismatch');
|
|
153
|
+
const body=encoded(run),bytes=Buffer.byteLength(body);if(bytes>131072)fail('run_too_large');
|
|
154
|
+
const old=this.#db.prepare('SELECT * FROM lcr_runs WHERE customer_id=? AND run_id=?').get(customer,run.id);
|
|
155
|
+
if(old&&old.agent_id!==agentId)fail('run_scope_mismatch');
|
|
156
|
+
// Admission pages can repeat a stale pending snapshot after an exact read.
|
|
157
|
+
if(old&&!old.pending){if(pending(run))return;if(!same(JSON.parse(old.record),run))fail('terminal_run_changed');return}
|
|
158
|
+
const usage=this.#db.prepare('SELECT COUNT(*) n,COALESCE(SUM(bytes),0) bytes FROM lcr_runs').get();
|
|
159
|
+
if(!old&&usage.n>=this.#maxRuns||usage.bytes-(old?.bytes??0)+bytes>this.#maxBytes)fail('capacity');
|
|
160
|
+
this.#db.prepare(`INSERT INTO lcr_runs VALUES(?,?,?,?,?,?) ON CONFLICT(customer_id,run_id) DO UPDATE SET record=excluded.record,bytes=excluded.bytes,pending=excluded.pending`).run(customer,run.id,agentId,body,bytes,Number(pending(run)));
|
|
161
|
+
}
|
|
162
|
+
saveRun(claim,run){return this.#tx(()=>{this.check(claim);const agentId=this.mapping(claim.mapping.customerId).agentId;if(!agentId)fail('missing_agent');this.#put(claim.mapping.customerId,agentId,run)})}
|
|
163
|
+
commitPage(claim,page){return this.#tx(()=>{
|
|
164
|
+
this.check(claim);const customer=claim.mapping.customerId,agentId=this.mapping(customer).agentId;
|
|
165
|
+
if(!agentId)fail('missing_agent');
|
|
166
|
+
for(const run of page.items)this.#put(customer,agentId,run);
|
|
167
|
+
this.#db.prepare('INSERT INTO lcr_cursors VALUES(?,?) ON CONFLICT(customer_id) DO UPDATE SET cursor=excluded.cursor').run(customer,page.next_cursor);
|
|
168
|
+
})}
|
|
169
|
+
cursor(customerId){return this.#db.prepare('SELECT cursor FROM lcr_cursors WHERE customer_id=?').get(customerId)?.cursor}
|
|
170
|
+
pendingCount(customerId){return this.#db.prepare('SELECT COUNT(*) n FROM lcr_runs WHERE customer_id=? AND pending=1').get(customerId).n}
|
|
171
|
+
pending(customerId){return this.#db.prepare('SELECT run_id FROM lcr_runs WHERE customer_id=? AND pending=1 ORDER BY rowid LIMIT 25').all(customerId).map(r=>r.run_id)}
|
|
172
|
+
knowsRun(customerId,runId){return !!this.#db.prepare('SELECT 1 FROM lcr_runs WHERE customer_id=? AND run_id=?').get(customerId,runId)}
|
|
173
|
+
/** Trusted local metadata enumeration after host authorization. No source
|
|
174
|
+
* text is returned; use the recipe's fresh exact read for display. This local
|
|
175
|
+
* offset is not an OpenCrawl admission cursor or completion-delivery promise.
|
|
176
|
+
*/
|
|
177
|
+
storedRuns(customerId,{offset=0,limit=25}={}){
|
|
178
|
+
if(!Number.isSafeInteger(offset)||offset<0||offset>10000||!positive(limit,25))fail('invalid_page');
|
|
179
|
+
const rows=this.#db.prepare('SELECT run_id,record FROM lcr_runs WHERE customer_id=? ORDER BY rowid LIMIT ? OFFSET ?').all(customerId,limit+1,offset);
|
|
180
|
+
return {items:rows.slice(0,limit).map(row=>({runId:row.run_id,status:JSON.parse(row.record).status})),hasMore:rows.length>limit};
|
|
181
|
+
}
|
|
182
|
+
/** Metadata only. Use recipe.read() for an authorized remote result; local
|
|
183
|
+
* persisted evidence is never an automatic fallback on remote denial.
|
|
184
|
+
*/
|
|
185
|
+
snapshot(customerId){const mapping=this.mapping(customerId),intent=this.intent(customerId);return {mapping,intent:intent?{key:intent.key,accepted:!!intent.receipt,agentId:intent.receipt?.agent.id??null,runId:intent.receipt?.run.id??null}:null,cursor:this.cursor(customerId)??null,pendingIds:this.pending(customerId),pendingCount:this.pendingCount(customerId),savedRuns:this.#db.prepare('SELECT COUNT(*) n FROM lcr_runs WHERE customer_id=?').get(customerId).n}}
|
|
186
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {Fetch,ResearchAgentInput,ResearchAgentRun,ResearchAgentSourceReceipt,ResearchAgentSchedule} from '@getopencrawl/sdk';
|
|
2
|
+
import type {ResearchAgentFeedbackInput,ResearchAgentFeedbackReceipt} from '@getopencrawl/sdk';
|
|
3
|
+
import type {LetterCustomerResearchStore} from './letter-customer-research-store.mjs';
|
|
4
|
+
import {CustomerResearchError} from './letter-customer-research-store.mjs';
|
|
5
|
+
export type CustomerResearchRemoteCode='request_rejected'|'authentication_required'|'access_denied'|'source_not_retained'|'not_found'|'conflict'|'rate_limited';
|
|
6
|
+
/** Finite known HTTP refusal. No response body or cause. It never permits
|
|
7
|
+
* replay of a saved admission intent or an uncertain mutation. */
|
|
8
|
+
export class CustomerResearchRemoteError extends CustomerResearchError {
|
|
9
|
+
constructor(code:CustomerResearchRemoteCode,status:number);
|
|
10
|
+
readonly status:number;
|
|
11
|
+
}
|
|
12
|
+
export type CustomerResearchOptions={signal?:AbortSignal;timeoutMs?:number};
|
|
13
|
+
export type LetterCustomerResearch<Context>={
|
|
14
|
+
start(context:Context,customerId:string,input:ResearchAgentInput,options?:CustomerResearchOptions):Promise<{agentId:string;runId:string}>;
|
|
15
|
+
recover(context:Context,customerId:string,options?:CustomerResearchOptions):Promise<{agentId:string;runId:string}>;
|
|
16
|
+
/** Deliberately repeat the same brief after this known latest terminal run. */
|
|
17
|
+
rerun(context:Context,customerId:string,previousRunId:string,options?:CustomerResearchOptions):Promise<{agentId:string;runId:string}>;
|
|
18
|
+
/** GET-only recovery. A confirmed refusal can be retired explicitly; a new
|
|
19
|
+
* deliberate rerun is a separate action, never performed by this method. */
|
|
20
|
+
recoverRerun(context:Context,customerId:string,previousRunId:string,options?:CustomerResearchOptions):Promise<{agentId:string;runId:string}|{refused:true;previousRunId:string}>;
|
|
21
|
+
read(context:Context,customerId:string,runId:string,options?:CustomerResearchOptions):Promise<ResearchAgentRun>;
|
|
22
|
+
source(context:Context,customerId:string,runId:string,sourceId:string,options?:CustomerResearchOptions):Promise<ResearchAgentSourceReceipt>;
|
|
23
|
+
schedule(context:Context,customerId:string,options?:CustomerResearchOptions):Promise<ResearchAgentSchedule>;
|
|
24
|
+
setSchedule(context:Context,customerId:string,input:{expected_revision:number;enabled:boolean},options?:CustomerResearchOptions):Promise<ResearchAgentSchedule>;
|
|
25
|
+
feedback(context:Context,customerId:string,runId:string,findingId:string,options?:CustomerResearchOptions):Promise<ResearchAgentFeedbackReceipt>;
|
|
26
|
+
setFeedback(context:Context,customerId:string,runId:string,findingId:string,input:ResearchAgentFeedbackInput,options?:CustomerResearchOptions):Promise<ResearchAgentFeedbackReceipt>;
|
|
27
|
+
deleteFeedback(context:Context,customerId:string,runId:string,findingId:string,expectedRevision:number,options?:CustomerResearchOptions):Promise<ResearchAgentFeedbackReceipt>;
|
|
28
|
+
poll(context:Context,customerId:string,options?:CustomerResearchOptions):Promise<{received:number;checkedPending:number;hasMoreAdmissions:boolean;pending:number;observedRunIds:string[]}>;
|
|
29
|
+
};
|
|
30
|
+
/** Server-only, Node24. The host must authorize each customer using trusted
|
|
31
|
+
* request/session context and resolve secret references to encrypted storage.
|
|
32
|
+
* No automatic tenant provisioning, create replay, daily runs or timers.
|
|
33
|
+
*/
|
|
34
|
+
export function createLetterCustomerResearch<Context>(options:{
|
|
35
|
+
store:LetterCustomerResearchStore;baseUrl:string;
|
|
36
|
+
authorize:(context:Context,customerId:string)=>Promise<boolean>;
|
|
37
|
+
secrets:{get:(secretRef:string)=>Promise<string|null|undefined>};
|
|
38
|
+
fetch?:Fetch;
|
|
39
|
+
}):LetterCustomerResearch<Context>;
|