@isomorph.ai/app-sdk 1.31.0 → 1.32.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 +24 -3
- package/dist/index.d.ts +14 -0
- package/dist/jobs.d.ts +13 -2
- package/dist/jobs.js +16 -2
- package/dist/lambda-events-entry.js +5 -3
- package/dist/runtime.d.ts +11 -8
- package/dist/runtime.js +74 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -485,7 +485,7 @@ On a serverless cell Harbour runs an app's server code as Lambda functions, from
|
|
|
485
485
|
|
|
486
486
|
### Email-arrival jobs
|
|
487
487
|
|
|
488
|
-
On managed serverless hosting, an ordinary job can declare an inbox trigger instead of a schedule:
|
|
488
|
+
On managed same-account serverless or Kubernetes hosting, an ordinary job can declare an inbox trigger instead of a schedule:
|
|
489
489
|
|
|
490
490
|
```ts
|
|
491
491
|
export const trigger = {
|
|
@@ -506,8 +506,29 @@ may additionally require an exact sender. Deployment registers the job; activati
|
|
|
506
506
|
the app owner's approved read access and background consent for that environment.
|
|
507
507
|
Multiple apps share one provider watch per mailbox. Notifications enqueue the existing durable
|
|
508
508
|
job runtime; duplicate deliveries retain the same run instead of rerunning a completed job.
|
|
509
|
-
This does not add a per-app polling timer.
|
|
510
|
-
supported by this first release. Local manual job execution tests the handler, not the provider
|
|
509
|
+
This does not add a per-app polling timer. Cross-account delivery is not supported. Local manual job execution tests the handler, not the provider
|
|
511
510
|
subscription; verify a real incoming email after deployment before calling the trigger live.
|
|
512
511
|
|
|
513
512
|
For PO-linked Zoho bills, first read the PO and send each returned `lineItemId` as the bill line’s `purchaseOrderItemId`. `itemId` identifies the catalog item; it does not identify the PO line. Purchase-order list results are summaries; read the document before comparing lines.
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
### Folder-arrival jobs
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
export const trigger = {
|
|
519
|
+
type: 'file.created', connection: 'company-drive', resource: 'my-drive',
|
|
520
|
+
folderId: 'provider-folder-id', mimeTypes: ['application/pdf'],
|
|
521
|
+
};
|
|
522
|
+
export default async (ctx) => {
|
|
523
|
+
// ctx.input is FileArrivalInput: provider/sourceId/fileId/folderId/driveId?,
|
|
524
|
+
// name/mimeType/size/createdAt/observedAt. Read bytes through the existing integration.
|
|
525
|
+
};
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
SharePoint uses its configured site alias as `resource` and also requires `driveId`.
|
|
529
|
+
The trigger covers newly created direct-child PDF/XLSX files observed after activation;
|
|
530
|
+
omit `mimeTypes` for both formats. Existing files moved into the folder, edits, renames,
|
|
531
|
+
subfolders, shortcuts and native Google documents are excluded. No historical backfill.
|
|
532
|
+
Background read consent is required. Existing file-read size limits still apply. Provider
|
|
533
|
+
subscriptions and real upload acceptance must be verified after deployment. Retries retain
|
|
534
|
+
one durable job run; accounting/email side effects still need their own idempotency keys.
|
package/dist/index.d.ts
CHANGED
|
@@ -1962,3 +1962,17 @@ export type HarbourResult<T> = IsomorphResult<T>;
|
|
|
1962
1962
|
export type HarbourClient = IsomorphClient;
|
|
1963
1963
|
/** @deprecated Internal transformation lane only; a kit app uses IsomorphCompatSource. */
|
|
1964
1964
|
export type HarbourCompatSource = import('./supabase-compat.js').IsomorphCompatSource;
|
|
1965
|
+
/** Provider file metadata delivered to a file.created job; bytes are read through its connection. */
|
|
1966
|
+
export interface FileArrivalInput {
|
|
1967
|
+
type: 'file.created';
|
|
1968
|
+
provider: 'drive' | 'sharepoint';
|
|
1969
|
+
sourceId: string;
|
|
1970
|
+
fileId: string;
|
|
1971
|
+
folderId: string;
|
|
1972
|
+
driveId?: string;
|
|
1973
|
+
name: string;
|
|
1974
|
+
mimeType: string;
|
|
1975
|
+
size: number;
|
|
1976
|
+
createdAt: string;
|
|
1977
|
+
observedAt: string;
|
|
1978
|
+
}
|
package/dist/jobs.d.ts
CHANGED
|
@@ -8,12 +8,23 @@ export interface EmailArrivalTrigger {
|
|
|
8
8
|
subjectContains?: string;
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
|
+
export declare const FILE_MIME_TYPES: readonly ["application/pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"];
|
|
12
|
+
export declare const providerFileId: (value: unknown) => value is string;
|
|
13
|
+
export interface FileCreatedTrigger {
|
|
14
|
+
type: 'file.created';
|
|
15
|
+
connection: string;
|
|
16
|
+
resource: string;
|
|
17
|
+
folderId: string;
|
|
18
|
+
driveId?: string;
|
|
19
|
+
mimeTypes?: string[];
|
|
20
|
+
}
|
|
21
|
+
export type EventTrigger = EmailArrivalTrigger | FileCreatedTrigger;
|
|
11
22
|
export interface JobDefinition {
|
|
12
23
|
name: string;
|
|
13
24
|
schedule?: string;
|
|
14
|
-
trigger?:
|
|
25
|
+
trigger?: EventTrigger;
|
|
15
26
|
}
|
|
16
|
-
export declare function
|
|
27
|
+
export declare function eventTrigger(value: unknown): EventTrigger;
|
|
17
28
|
export interface JobContext {
|
|
18
29
|
scheduledAt: string;
|
|
19
30
|
runId?: string;
|
package/dist/jobs.js
CHANGED
|
@@ -6,10 +6,14 @@ import { stripTypeScriptTypes } from 'node:module';
|
|
|
6
6
|
import { parse } from 'acorn';
|
|
7
7
|
export const JOBS_DIR = 'jobs';
|
|
8
8
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
9
|
+
export const FILE_MIME_TYPES = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
|
|
10
|
+
export const providerFileId = (value) => typeof value === 'string' && /^[A-Za-z0-9!._,=-]{1,256}$/.test(value);
|
|
9
11
|
function literalObject(node) {
|
|
10
12
|
const value = node;
|
|
11
13
|
if (value?.type === 'Literal')
|
|
12
14
|
return value.value;
|
|
15
|
+
if (value?.type === 'ArrayExpression')
|
|
16
|
+
return value.elements.map(literalObject);
|
|
13
17
|
if (value?.type !== 'ObjectExpression')
|
|
14
18
|
throw new Error('trigger must be a literal object');
|
|
15
19
|
const result = {};
|
|
@@ -23,7 +27,17 @@ function literalObject(node) {
|
|
|
23
27
|
}
|
|
24
28
|
return result;
|
|
25
29
|
}
|
|
26
|
-
export function
|
|
30
|
+
export function eventTrigger(value) {
|
|
31
|
+
if (value?.type === 'file.created') {
|
|
32
|
+
const t = value;
|
|
33
|
+
if (Object.keys(t).some(k => !['type', 'connection', 'resource', 'folderId', 'driveId', 'mimeTypes'].includes(k))
|
|
34
|
+
|| typeof t.connection !== 'string' || !LOGICAL_NAME.test(t.connection)
|
|
35
|
+
|| typeof t.resource !== 'string' || !t.resource.trim() || t.resource.length > 320
|
|
36
|
+
|| !providerFileId(t.folderId) || (t.driveId !== undefined && !providerFileId(t.driveId))
|
|
37
|
+
|| (t.mimeTypes !== undefined && (!Array.isArray(t.mimeTypes) || !t.mimeTypes.length || t.mimeTypes.length > 2 || new Set(t.mimeTypes).size !== t.mimeTypes.length || t.mimeTypes.some(m => !FILE_MIME_TYPES.includes(m)))))
|
|
38
|
+
throw new Error('file.created requires a connection, resource, folder ID and optional PDF/XLSX MIME types');
|
|
39
|
+
return t;
|
|
40
|
+
}
|
|
27
41
|
const trigger = value;
|
|
28
42
|
if (!trigger || typeof trigger !== 'object' || Object.keys(trigger).some(key => !['type', 'connection', 'resource', 'filter'].includes(key))
|
|
29
43
|
|| trigger.type !== 'email.arrived' || trigger.resource !== 'inbox' || typeof trigger.connection !== 'string' || !LOGICAL_NAME.test(trigger.connection))
|
|
@@ -102,7 +116,7 @@ export async function discoverJobs(root) {
|
|
|
102
116
|
if (statement.declaration.kind !== 'const')
|
|
103
117
|
fail('export one literal trigger with export const trigger.');
|
|
104
118
|
try {
|
|
105
|
-
trigger =
|
|
119
|
+
trigger = eventTrigger(literalObject(declaration.init));
|
|
106
120
|
}
|
|
107
121
|
catch (error) {
|
|
108
122
|
fail(String(error));
|
|
@@ -18,9 +18,11 @@ function eventRuntime() {
|
|
|
18
18
|
return runtime;
|
|
19
19
|
}
|
|
20
20
|
export async function handler(event) {
|
|
21
|
-
const { job, Records: records, mailArrival } = (event && typeof event === 'object' ? event : {});
|
|
22
|
-
if (mailArrival !== undefined)
|
|
23
|
-
|
|
21
|
+
const { job, Records: records, mailArrival, eventArrival } = (event && typeof event === 'object' ? event : {});
|
|
22
|
+
if (eventArrival !== undefined && mailArrival !== undefined)
|
|
23
|
+
throw new Error('ambiguous arrival envelope');
|
|
24
|
+
if (eventArrival !== undefined || mailArrival !== undefined)
|
|
25
|
+
return eventRuntime().acceptEventArrival(eventArrival ?? mailArrival);
|
|
24
26
|
if (typeof job === 'string' && job) {
|
|
25
27
|
if (!(await eventRuntime().runJobOnce(job)))
|
|
26
28
|
throw new Error(`jobs/${job}.ts did not run to completion; the log above names why`);
|
package/dist/runtime.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export declare class Runs {
|
|
|
50
50
|
enqueue(job: string, input: unknown, owner: string): Promise<{
|
|
51
51
|
id: string;
|
|
52
52
|
}>;
|
|
53
|
-
|
|
53
|
+
enqueueEvent(delivery: {
|
|
54
54
|
deliveryId: string;
|
|
55
55
|
job: string;
|
|
56
56
|
input: unknown;
|
|
@@ -61,7 +61,7 @@ export declare class Runs {
|
|
|
61
61
|
get(id: string, owner: string): Promise<JobRun | null>;
|
|
62
62
|
cancel(id: string, owner: string): Promise<JobRun | null>;
|
|
63
63
|
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
64
|
-
reclaim(now: Date): Promise<number>;
|
|
64
|
+
reclaim(now: Date, active?: ReadonlySet<string>): Promise<number>;
|
|
65
65
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
66
66
|
claim(now: Date): Promise<JobRun | null>;
|
|
67
67
|
progress(id: string, percent: number, note?: string): Promise<void>;
|
|
@@ -106,8 +106,10 @@ export declare class Runtime {
|
|
|
106
106
|
private readonly timers;
|
|
107
107
|
private stopped;
|
|
108
108
|
private aiBinding;
|
|
109
|
-
private
|
|
109
|
+
private queueWorkers;
|
|
110
110
|
private queueWake;
|
|
111
|
+
private claimTail;
|
|
112
|
+
private readonly activeRuns;
|
|
111
113
|
private readonly runs;
|
|
112
114
|
private session;
|
|
113
115
|
private readonly log;
|
|
@@ -127,8 +129,8 @@ export declare class Runtime {
|
|
|
127
129
|
*/
|
|
128
130
|
drainQueue(): Promise<void>;
|
|
129
131
|
/**
|
|
130
|
-
* Starts
|
|
131
|
-
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
132
|
+
* Starts one free execution slot, up to the fixed local bound. Its first
|
|
133
|
+
* pass runs at once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
132
134
|
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
133
135
|
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
134
136
|
* timer armed, so an idle runtime makes no data calls at all.
|
|
@@ -141,7 +143,7 @@ export declare class Runtime {
|
|
|
141
143
|
* would have been. False when the app has no such job, the gateway's data
|
|
142
144
|
* path never answered, or the handler threw; the log names which.
|
|
143
145
|
*/
|
|
144
|
-
|
|
146
|
+
acceptEventArrival(value: unknown): Promise<void>;
|
|
145
147
|
runJobOnce(name: string, now?: Date): Promise<boolean>;
|
|
146
148
|
private awaitDataPath;
|
|
147
149
|
stop(): Promise<void>;
|
|
@@ -152,10 +154,11 @@ export declare class Runtime {
|
|
|
152
154
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
153
155
|
private runScheduled;
|
|
154
156
|
/**
|
|
155
|
-
* One pass
|
|
157
|
+
* One worker pass: reclaim interrupted leases, claim one run, then execute
|
|
158
|
+
* outside the claim lock so another slot can handle unrelated work.
|
|
156
159
|
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
157
160
|
* run it finished left a retry queued — so the loop should look again; false
|
|
158
|
-
* when the queue is empty
|
|
161
|
+
* when the queue is empty or both execution slots are occupied.
|
|
159
162
|
*/
|
|
160
163
|
work(): Promise<boolean>;
|
|
161
164
|
private serve;
|
package/dist/runtime.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// database calls: the runtime is the process that serves the enqueue action
|
|
21
21
|
// and the one that finishes every run, so it knows when work exists. It runs
|
|
22
22
|
// one pass at start (reclaiming whatever a crashed predecessor left running
|
|
23
|
-
// and draining any backlog),
|
|
23
|
+
// and draining any backlog), filling at most two local execution slots. Each
|
|
24
|
+
// slot loops every QUEUE_POLL_MS only while a pass
|
|
24
25
|
// found something — a claimed run, a reclaimed lease, a run that finished with
|
|
25
26
|
// retries queued — and stops with no timer armed once the queue is empty. An
|
|
26
27
|
// enqueue starts the loop again. This holds because kit-runtime is pinned to
|
|
@@ -67,7 +68,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
67
68
|
import { createClient, gatewayRequest, IsomorphError } from './index.js';
|
|
68
69
|
import { userGroupsFromHeader } from './user-groups.js';
|
|
69
70
|
import { userRole } from './user-role.js';
|
|
70
|
-
import { discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
|
|
71
|
+
import { FILE_MIME_TYPES, providerFileId, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
|
|
71
72
|
import { LocalGateway } from './local-gateway.js';
|
|
72
73
|
export { JOBS_DIR } from './jobs.js';
|
|
73
74
|
const actionIdentity = new AsyncLocalStorage();
|
|
@@ -83,6 +84,7 @@ export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
|
|
|
83
84
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
84
85
|
const MAX_BODY_BYTES = 1 << 20;
|
|
85
86
|
const LEASE_MS = 5 * 60_000;
|
|
87
|
+
const QUEUE_WORKERS = 2;
|
|
86
88
|
const QUEUE_POLL_MS = 2_000;
|
|
87
89
|
const TOKEN_REFRESH_MS = 60_000;
|
|
88
90
|
// A gateway session lasts an hour. An action runs at most 300 s, so a request
|
|
@@ -204,7 +206,7 @@ export class Runs {
|
|
|
204
206
|
const { data } = await this.table().insert({ job, input: input ?? null, status: 'queued', attempt: 0, max_attempts: DEFAULT_MAX_ATTEMPTS, progress: 0, owner_subject: owner, cancel_requested: false }).select('id').single();
|
|
205
207
|
return { id: data.id };
|
|
206
208
|
}
|
|
207
|
-
async
|
|
209
|
+
async enqueueEvent(delivery) {
|
|
208
210
|
const hex = createHash('sha256').update(`mail:${delivery.deliveryId}`).digest('hex');
|
|
209
211
|
const id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
210
212
|
try {
|
|
@@ -230,13 +232,14 @@ export class Runs {
|
|
|
230
232
|
return data;
|
|
231
233
|
}
|
|
232
234
|
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
233
|
-
async reclaim(now) {
|
|
235
|
+
async reclaim(now, active = new Set()) {
|
|
234
236
|
const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
|
|
235
|
-
|
|
237
|
+
const interrupted = expired.filter(run => !active.has(run.id));
|
|
238
|
+
for (const run of interrupted) {
|
|
236
239
|
const exhausted = run.attempt >= run.max_attempts;
|
|
237
240
|
await this.table().update({ status: exhausted ? 'failed' : 'queued', error: exhausted ? 'the worker was interrupted and no attempts remain' : run.error, lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running');
|
|
238
241
|
}
|
|
239
|
-
return
|
|
242
|
+
return interrupted.length;
|
|
240
243
|
}
|
|
241
244
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
242
245
|
async claim(now) {
|
|
@@ -270,10 +273,10 @@ export class Runtime {
|
|
|
270
273
|
timers = [];
|
|
271
274
|
stopped = false;
|
|
272
275
|
aiBinding;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
queueWorkers = 0;
|
|
277
|
+
queueWake = 0;
|
|
278
|
+
claimTail = Promise.resolve();
|
|
279
|
+
activeRuns = new Set();
|
|
277
280
|
runs;
|
|
278
281
|
// Built on first use: the AWS signing modules load only on a serverless
|
|
279
282
|
// cell, so a cluster runtime never imports them.
|
|
@@ -339,8 +342,8 @@ export class Runtime {
|
|
|
339
342
|
await new Promise((wake) => setTimeout(wake, this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
340
343
|
}
|
|
341
344
|
/**
|
|
342
|
-
* Starts
|
|
343
|
-
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
345
|
+
* Starts one free execution slot, up to the fixed local bound. Its first
|
|
346
|
+
* pass runs at once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
344
347
|
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
345
348
|
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
346
349
|
* timer armed, so an idle runtime makes no data calls at all.
|
|
@@ -348,17 +351,16 @@ export class Runtime {
|
|
|
348
351
|
wakeQueue() {
|
|
349
352
|
if (this.stopped || !this.worksQueue())
|
|
350
353
|
return;
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
this.queueWake += 1;
|
|
355
|
+
if (this.queueWorkers >= QUEUE_WORKERS)
|
|
353
356
|
return;
|
|
354
|
-
|
|
355
|
-
this.queueLive = true;
|
|
357
|
+
this.queueWorkers += 1;
|
|
356
358
|
const pass = async () => {
|
|
357
359
|
if (this.stopped) {
|
|
358
|
-
this.
|
|
360
|
+
this.queueWorkers -= 1;
|
|
359
361
|
return;
|
|
360
362
|
}
|
|
361
|
-
this.queueWake
|
|
363
|
+
const wake = this.queueWake;
|
|
362
364
|
let again = true;
|
|
363
365
|
try {
|
|
364
366
|
again = await this.work();
|
|
@@ -366,14 +368,10 @@ export class Runtime {
|
|
|
366
368
|
catch (error) {
|
|
367
369
|
this.log(`queue pass failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
368
370
|
}
|
|
369
|
-
if (this.stopped)
|
|
370
|
-
this.queueLive = false;
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
if (again || this.queueWake)
|
|
371
|
+
if (!this.stopped && (again || wake !== this.queueWake))
|
|
374
372
|
this.timers.push(setTimeout(() => void pass(), this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
375
373
|
else
|
|
376
|
-
this.
|
|
374
|
+
this.queueWorkers -= 1;
|
|
377
375
|
};
|
|
378
376
|
void pass();
|
|
379
377
|
}
|
|
@@ -384,23 +382,36 @@ export class Runtime {
|
|
|
384
382
|
* would have been. False when the app has no such job, the gateway's data
|
|
385
383
|
* path never answered, or the handler threw; the log names which.
|
|
386
384
|
*/
|
|
387
|
-
async
|
|
385
|
+
async acceptEventArrival(value) {
|
|
388
386
|
const delivery = value;
|
|
389
387
|
if (!delivery || typeof delivery.deliveryId !== 'string' || !/^[a-f0-9]{64}$/.test(delivery.deliveryId)
|
|
390
|
-
|| typeof delivery.owner !== 'string' || !delivery.owner || delivery.owner.length > 320
|
|
391
|
-
|
|
392
|
-
|| typeof delivery.input.messageId !== 'string' || !delivery.input.messageId || delivery.input.messageId.length > 2048
|
|
393
|
-
|| typeof delivery.input.threadId !== 'string' || delivery.input.threadId.length > 2048
|
|
394
|
-
|| typeof delivery.input.receivedAt !== 'string' || !Number.isFinite(Date.parse(delivery.input.receivedAt)))
|
|
395
|
-
throw new Error('invalid mail arrival');
|
|
388
|
+
|| typeof delivery.owner !== 'string' || !delivery.owner || delivery.owner.length > 320 || !delivery.input || typeof delivery.input !== 'object')
|
|
389
|
+
throw new Error('invalid event arrival');
|
|
396
390
|
this.inventory = await discover(this.options.root);
|
|
397
391
|
const job = this.inventory.jobs.find(job => job.name === delivery.job);
|
|
398
|
-
|
|
399
|
-
|
|
392
|
+
const input = delivery.input;
|
|
393
|
+
if (!job?.trigger || job.trigger.connection !== input.sourceId)
|
|
394
|
+
throw new Error('event trigger is not declared by this deployed app');
|
|
395
|
+
if (job.trigger.type === 'file.created') {
|
|
396
|
+
const instant = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) && Number.isFinite(Date.parse(v));
|
|
397
|
+
if (Object.keys(input).some(k => !['type', 'provider', 'sourceId', 'fileId', 'folderId', 'driveId', 'name', 'mimeType', 'size', 'createdAt', 'observedAt'].includes(k))
|
|
398
|
+
|| input.type !== 'file.created' || !['drive', 'sharepoint'].includes(String(input.provider)) || !providerFileId(input.fileId) || input.folderId !== job.trigger.folderId
|
|
399
|
+
|| (input.driveId !== undefined && !providerFileId(input.driveId)) || (input.provider === 'sharepoint' && !input.driveId)
|
|
400
|
+
|| (job.trigger.driveId !== undefined && input.driveId !== job.trigger.driveId)
|
|
401
|
+
|| typeof input.name !== 'string' || !input.name || input.name.length > 1024 || !Number.isSafeInteger(input.size) || Number(input.size) < 0
|
|
402
|
+
|| !(job.trigger.mimeTypes ?? [...FILE_MIME_TYPES]).includes(String(input.mimeType))
|
|
403
|
+
|| !instant(input.createdAt) || !instant(input.observedAt))
|
|
404
|
+
throw new Error('invalid file arrival');
|
|
405
|
+
}
|
|
406
|
+
else if (input.type !== undefined || !['gmail', 'outlook'].includes(String(input.provider))
|
|
407
|
+
|| typeof input.messageId !== 'string' || !input.messageId || input.messageId.length > 2048
|
|
408
|
+
|| typeof input.threadId !== 'string' || input.threadId.length > 2048
|
|
409
|
+
|| typeof input.receivedAt !== 'string' || !Number.isFinite(Date.parse(input.receivedAt)))
|
|
410
|
+
throw new Error('invalid mail arrival');
|
|
400
411
|
await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
|
|
401
412
|
if (!this.options.gatewayUrl || this.options.mode === 'gate' || this.options.jobRuns === false || !(await this.awaitDataPath()))
|
|
402
|
-
throw new Error('
|
|
403
|
-
const queued = await this.runs.
|
|
413
|
+
throw new Error('event arrival requires durable job runs');
|
|
414
|
+
const queued = await this.runs.enqueueEvent(delivery);
|
|
404
415
|
await this.signalQueuedRun(queued.id);
|
|
405
416
|
}
|
|
406
417
|
async runJobOnce(name, now = new Date()) {
|
|
@@ -539,21 +550,38 @@ export class Runtime {
|
|
|
539
550
|
}
|
|
540
551
|
}
|
|
541
552
|
/**
|
|
542
|
-
* One pass
|
|
553
|
+
* One worker pass: reclaim interrupted leases, claim one run, then execute
|
|
554
|
+
* outside the claim lock so another slot can handle unrelated work.
|
|
543
555
|
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
544
556
|
* run it finished left a retry queued — so the loop should look again; false
|
|
545
|
-
* when the queue is empty
|
|
557
|
+
* when the queue is empty or both execution slots are occupied.
|
|
546
558
|
*/
|
|
547
559
|
async work() {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
this.
|
|
560
|
+
// Serialize only the claim, not the handler. A second slot must not race
|
|
561
|
+
// the same oldest row or reclaim a long handler still running here.
|
|
562
|
+
const previous = this.claimTail;
|
|
563
|
+
let release;
|
|
564
|
+
this.claimTail = new Promise(resolve => { release = resolve; });
|
|
565
|
+
await previous;
|
|
566
|
+
let run;
|
|
567
|
+
let reclaimed = 0;
|
|
568
|
+
try {
|
|
569
|
+
if (this.activeRuns.size >= QUEUE_WORKERS)
|
|
570
|
+
return false;
|
|
571
|
+
const now = new Date();
|
|
572
|
+
reclaimed = await this.runs.reclaim(now, this.activeRuns);
|
|
573
|
+
run = await this.runs.claim(now);
|
|
574
|
+
if (run)
|
|
575
|
+
this.activeRuns.add(run.id);
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
release();
|
|
579
|
+
}
|
|
580
|
+
if (!run)
|
|
581
|
+
return reclaimed > 0;
|
|
582
|
+
this.wakeQueue(); // Fill the spare slot when startup discovers a backlog.
|
|
551
583
|
try {
|
|
552
584
|
const now = new Date();
|
|
553
|
-
const reclaimed = await this.runs.reclaim(now);
|
|
554
|
-
const run = await this.runs.claim(now);
|
|
555
|
-
if (!run)
|
|
556
|
-
return reclaimed > 0;
|
|
557
585
|
if (!this.inventory.jobs.some((job) => job.name === run.job)) {
|
|
558
586
|
await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
|
|
559
587
|
return true;
|
|
@@ -574,7 +602,7 @@ export class Runtime {
|
|
|
574
602
|
return true;
|
|
575
603
|
}
|
|
576
604
|
finally {
|
|
577
|
-
this.
|
|
605
|
+
this.activeRuns.delete(run.id);
|
|
578
606
|
}
|
|
579
607
|
}
|
|
580
608
|
async serve(request, response) {
|
|
@@ -645,7 +673,7 @@ export class Runtime {
|
|
|
645
673
|
if (name === 'isomorph-mail-arrival') {
|
|
646
674
|
if (header(request, 'X-Harbour-Verified-Mail-Arrival') !== 'true')
|
|
647
675
|
return refuse(response, 403, 'FORBIDDEN', 'governed mail delivery is required', requestId);
|
|
648
|
-
await this.
|
|
676
|
+
await this.acceptEventArrival(input);
|
|
649
677
|
return answer(response, 200, { data: { accepted: true } }, requestId);
|
|
650
678
|
}
|
|
651
679
|
if (name.startsWith(RESERVED_PREFIX))
|