@isomorph.ai/app-sdk 1.22.0 → 1.25.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 +25 -2
- package/dist/aws.d.ts +25 -0
- package/dist/aws.js +62 -3
- package/dist/computer-use.d.ts +220 -0
- package/dist/computer-use.js +72 -0
- package/dist/lambda-entry.d.ts +1 -0
- package/dist/lambda-entry.js +10 -0
- package/dist/lambda-events-entry.d.ts +1 -0
- package/dist/lambda-events-entry.js +30 -0
- package/dist/runtime.d.ts +17 -1
- package/dist/runtime.js +64 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -370,6 +370,25 @@ try {
|
|
|
370
370
|
|
|
371
371
|
App code declares exact HTTPS pages and named click/fill selectors reviewed for reads. Tools provide navigate, observe, control, scroll, screenshot and close. Observations are untrusted webpage content. One isolated page per run, one active run per process, 50 actions, five minutes, ten-second operations, bounded text/screenshots. Private destinations, undeclared navigation, downloads, popups, WebSockets, service workers and non-GET/HEAD requests are blocked. GET-only filtering cannot infer website business semantics: never declare URLs/controls that perform external writes. Chromium's sandbox remains enabled; unsupported sandbox environments fail clearly.
|
|
372
372
|
|
|
373
|
+
## Computer use tool
|
|
374
|
+
|
|
375
|
+
A job's agent can hand one browser task, writes included, to Harbour's computer use agent. Use it only in `jobs/*.ts`. Install the optional peer `ai`.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
import { createComputerUseTool } from '@isomorph.ai/app-sdk/dist/computer-use.js';
|
|
379
|
+
// Inside a job handler:
|
|
380
|
+
const computerUse = createComputerUseTool({ runKey: ctx.runId ?? `my-job/${ctx.scheduledAt}`, progress: ctx.progress, cancelled: ctx.cancelled });
|
|
381
|
+
// Add { computerUse } to generateText's tools. It returns { status: 'done' | 'failed', summary }.
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- The tool calls the task API at `ISOMORPH_COMPUTER_USE_URL` (region `ISOMORPH_COMPUTER_USE_REGION`, default `us-east-1`); Provision sets both when the cell has the API. Each request is signed with SigV4 (`execute-api`) as the runtime's own role: the Lambda environment's credentials, or on a cluster pod its ServiceAccount role through STS `AssumeRoleWithWebIdentity`.
|
|
385
|
+
- It starts the task with `POST /tasks`, then checks `POST /tasks/check` every 5 s until the task is `done` or `failed`.
|
|
386
|
+
- It calls `progress(step count, note)` on every new step and at least once a minute, so the job's lease does not run out.
|
|
387
|
+
- When `cancelled()` turns true it sends `POST /tasks/cancel` once and keeps checking until the task has `failed`.
|
|
388
|
+
- One task record per `runKey`: a retried job run sends the same key and gets the queued, running or finished task back. Only a failed task starts a new attempt.
|
|
389
|
+
|
|
390
|
+
(1.25.0; data plane ADR 0078)
|
|
391
|
+
|
|
373
392
|
## Running and rollout
|
|
374
393
|
|
|
375
394
|
`isomorph dev` uses canned server-model fixtures. `dev --real` supplies an approved development connection to the action process. Hosted actions resolve their exact ACTIVE version key. The current V2 PLAN is published after LIVE; before key activation, an AI action fails and later actions retry. LIVE alone is not AI acceptance.
|
|
@@ -456,6 +475,10 @@ The action still has the 300 s action budget. (Unreleased)
|
|
|
456
475
|
|
|
457
476
|
## Running on Lambda
|
|
458
477
|
|
|
459
|
-
On a serverless cell Harbour runs an app's server code as Lambda functions; nothing in the app changes. `
|
|
478
|
+
On a serverless cell Harbour runs an app's server code as Lambda functions, from a `runtime.zip` Kit Build makes; nothing in the app changes. The Web Function starts `dist/lambda-entry.js` (the zip's `index.js` is `import "@isomorph.ai/app-sdk/dist/lambda-entry.js"`), which serves actions on the port in `AWS_LAMBDA_HTTP_ENDPOINT` and stops on SIGTERM. The Event Function's handler is `node_modules/@isomorph.ai/app-sdk/dist/lambda-events-entry.handler`: `{"job": "<name>"}` runs that job once, as `--run-job <name>` does, and SQS records work queued runs until none are left.
|
|
479
|
+
|
|
480
|
+
`ISOMORPH_GATEWAY_IDENTITY=sts-session` replaces `HARBOUR_WORKLOAD_TOKEN_FILE`: the runtime's bearer for the gateway is a one-hour session it gets by posting a presigned STS `GetCallerIdentity`, signed with the function's credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`), to `<gateway>/_harbour/internal/session`. A cluster runtime leaves it unset.
|
|
481
|
+
|
|
482
|
+
`ISOMORPH_JOBS_QUEUE_URL`: `isomorph.jobs.enqueue` writes the run, then sends one message to this SQS queue; the runtime never works the queue itself.
|
|
460
483
|
|
|
461
|
-
(1.
|
|
484
|
+
(1.24.0; data plane ADR 0065)
|
package/dist/aws.d.ts
CHANGED
|
@@ -11,3 +11,28 @@ export declare function awsEnvironment(env?: NodeJS.ProcessEnv): AwsEnvironment;
|
|
|
11
11
|
* alone (ADR 0068), so a signature over any other header could never verify.
|
|
12
12
|
*/
|
|
13
13
|
export declare function presignCallerIdentity(aws: AwsEnvironment, signingDate?: Date): Promise<string>;
|
|
14
|
+
/** SendMessage to a queue, over the JSON protocol at the queue URL's own host. */
|
|
15
|
+
export declare function sendQueueMessage(aws: AwsEnvironment, queueUrl: string, body: string, { fetch: fetcher, signingDate }?: {
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
signingDate?: Date;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
type Credentials = Omit<AwsEnvironment, 'region'>;
|
|
20
|
+
/**
|
|
21
|
+
* The runtime role's credentials: the Lambda environment's, or on a cluster
|
|
22
|
+
* pod the ServiceAccount role's (IRSA: AWS_ROLE_ARN and the projected token
|
|
23
|
+
* in AWS_WEB_IDENTITY_TOKEN_FILE), exchanged with STS
|
|
24
|
+
* AssumeRoleWithWebIdentity and kept until five minutes before they expire.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runtimeCredentials(env?: NodeJS.ProcessEnv, { fetch: fetcher, now }?: {
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}): Promise<Credentials>;
|
|
30
|
+
/** A JSON POST signed for `service` in `aws.region`, answered with its status and JSON body. */
|
|
31
|
+
export declare function signedJsonPost(aws: AwsEnvironment, service: string, target: string, payload: unknown, { fetch: fetcher, signingDate }?: {
|
|
32
|
+
fetch?: typeof fetch;
|
|
33
|
+
signingDate?: Date;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
status: number;
|
|
36
|
+
body: unknown;
|
|
37
|
+
}>;
|
|
38
|
+
export {};
|
package/dist/aws.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// The AWS calls a
|
|
2
|
-
//
|
|
3
|
-
// region come from the Lambda environment on every call
|
|
1
|
+
// The AWS calls a runtime makes with its own role, signed here rather than
|
|
2
|
+
// through an AWS SDK client (ADR 0065). On a serverless cell the credentials
|
|
3
|
+
// and the region come from the Lambda environment on every call; on a cluster
|
|
4
|
+
// pod, from the ServiceAccount's role (runtimeCredentials).
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
4
6
|
import { Sha256 } from '@aws-crypto/sha256-js';
|
|
5
7
|
import { HttpRequest } from '@smithy/protocol-http';
|
|
6
8
|
import { SignatureV4 } from '@smithy/signature-v4';
|
|
@@ -32,3 +34,60 @@ export async function presignCallerIdentity(aws, signingDate = new Date()) {
|
|
|
32
34
|
query.set(key, value);
|
|
33
35
|
return `https://${hostname}/?${query.toString()}`;
|
|
34
36
|
}
|
|
37
|
+
/** SendMessage to a queue, over the JSON protocol at the queue URL's own host. */
|
|
38
|
+
export async function sendQueueMessage(aws, queueUrl, body, { fetch: fetcher = fetch, signingDate = new Date() } = {}) {
|
|
39
|
+
const url = new URL(queueUrl);
|
|
40
|
+
const payload = JSON.stringify({ QueueUrl: queueUrl, MessageBody: body });
|
|
41
|
+
const request = new HttpRequest({
|
|
42
|
+
method: 'POST', protocol: url.protocol, hostname: url.hostname, ...(url.port ? { port: Number(url.port) } : {}), path: '/',
|
|
43
|
+
headers: { host: url.host, 'content-type': 'application/x-amz-json-1.0', 'x-amz-target': 'AmazonSQS.SendMessage' },
|
|
44
|
+
body: payload,
|
|
45
|
+
});
|
|
46
|
+
// fetch sets Host itself, from the same URL.
|
|
47
|
+
const { host: _host, ...headers } = (await signer(aws, 'sqs').sign(request, { signingDate })).headers;
|
|
48
|
+
const response = await fetcher(`${url.protocol}//${url.host}/`, { method: 'POST', headers, body: payload, signal: AbortSignal.timeout(10_000) });
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const answer = await response.json().catch(() => undefined);
|
|
51
|
+
throw new Error(`SQS refused the message (${response.status}${answer?.__type ? ` ${answer.__type}` : ''}${answer?.message ? `: ${answer.message}` : ''})`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let webIdentity;
|
|
55
|
+
/**
|
|
56
|
+
* The runtime role's credentials: the Lambda environment's, or on a cluster
|
|
57
|
+
* pod the ServiceAccount role's (IRSA: AWS_ROLE_ARN and the projected token
|
|
58
|
+
* in AWS_WEB_IDENTITY_TOKEN_FILE), exchanged with STS
|
|
59
|
+
* AssumeRoleWithWebIdentity and kept until five minutes before they expire.
|
|
60
|
+
*/
|
|
61
|
+
export async function runtimeCredentials(env = process.env, { fetch: fetcher = fetch, now = Date.now } = {}) {
|
|
62
|
+
if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)
|
|
63
|
+
return { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}) };
|
|
64
|
+
const roleArn = env.AWS_ROLE_ARN;
|
|
65
|
+
const tokenFile = env.AWS_WEB_IDENTITY_TOKEN_FILE;
|
|
66
|
+
if (!roleArn || !tokenFile)
|
|
67
|
+
throw new Error('the runtime has no AWS credentials (AWS_ACCESS_KEY_ID, or AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE)');
|
|
68
|
+
if (webIdentity?.roleArn === roleArn && webIdentity.expiresAt - now() > 5 * 60_000)
|
|
69
|
+
return webIdentity.credentials;
|
|
70
|
+
const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
71
|
+
// The token is read on each exchange: the kubelet rotates the file.
|
|
72
|
+
const body = new URLSearchParams({ Action: 'AssumeRoleWithWebIdentity', Version: '2011-06-15', RoleArn: roleArn, RoleSessionName: env.AWS_ROLE_SESSION_NAME || 'isomorph-runtime', WebIdentityToken: (await readFile(tokenFile, 'utf8')).trim() });
|
|
73
|
+
const response = await fetcher(`https://sts.${region}.amazonaws.com/`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(10_000) });
|
|
74
|
+
const answer = await response.text();
|
|
75
|
+
const field = (name) => answer.match(new RegExp(`<${name}>([^<]+)</${name}>`))?.[1];
|
|
76
|
+
const accessKeyId = field('AccessKeyId'), secretAccessKey = field('SecretAccessKey'), sessionToken = field('SessionToken'), expiration = Date.parse(field('Expiration') ?? '');
|
|
77
|
+
if (!response.ok || !accessKeyId || !secretAccessKey || !sessionToken || Number.isNaN(expiration))
|
|
78
|
+
throw new Error(`STS did not issue the runtime role's credentials (${response.status}${field('Code') ? ` ${field('Code')}` : ''})`);
|
|
79
|
+
webIdentity = { roleArn, credentials: { accessKeyId, secretAccessKey, sessionToken }, expiresAt: expiration };
|
|
80
|
+
return webIdentity.credentials;
|
|
81
|
+
}
|
|
82
|
+
/** A JSON POST signed for `service` in `aws.region`, answered with its status and JSON body. */
|
|
83
|
+
export async function signedJsonPost(aws, service, target, payload, { fetch: fetcher = fetch, signingDate = new Date() } = {}) {
|
|
84
|
+
const url = new URL(target);
|
|
85
|
+
const body = JSON.stringify(payload);
|
|
86
|
+
const request = new HttpRequest({
|
|
87
|
+
method: 'POST', protocol: url.protocol, hostname: url.hostname, ...(url.port ? { port: Number(url.port) } : {}), path: url.pathname,
|
|
88
|
+
headers: { host: url.host, 'content-type': 'application/json' }, body,
|
|
89
|
+
});
|
|
90
|
+
const { host: _host, ...headers } = (await signer(aws, service).sign(request, { signingDate })).headers;
|
|
91
|
+
const response = await fetcher(url.href, { method: 'POST', headers, body, signal: AbortSignal.timeout(30_000) });
|
|
92
|
+
return { status: response.status, body: await response.json().catch(() => undefined) };
|
|
93
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
export interface ComputerUseOptions {
|
|
2
|
+
/** The job's runId, or `<job name>/<scheduledAt>` for a scheduled job: one task record per job run. */
|
|
3
|
+
runKey: string;
|
|
4
|
+
/** The job's progress: called on each new step and at least once a minute, which renews the job's lease. */
|
|
5
|
+
progress(percent: number, note?: string): Promise<void> | void;
|
|
6
|
+
/** The job's cancelled(): when true the tool cancels the task and waits until the CUA stops. */
|
|
7
|
+
cancelled(): Promise<boolean> | boolean;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
/** The pause between checks; the tests shorten it. */
|
|
11
|
+
pollIntervalMs?: number;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
export interface ComputerUseResult {
|
|
15
|
+
status: 'done' | 'failed';
|
|
16
|
+
summary: string | null;
|
|
17
|
+
}
|
|
18
|
+
export declare function createComputerUseTool(options: ComputerUseOptions): ({
|
|
19
|
+
deferLoading?: boolean;
|
|
20
|
+
title?: string;
|
|
21
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
22
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
23
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
24
|
+
task: string;
|
|
25
|
+
}>;
|
|
26
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
27
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
28
|
+
task: string;
|
|
29
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
30
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
31
|
+
onInputDelta?: ((options: {
|
|
32
|
+
inputTextDelta: string;
|
|
33
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
34
|
+
onInputAvailable?: ((options: {
|
|
35
|
+
input: {
|
|
36
|
+
task: string;
|
|
37
|
+
};
|
|
38
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
39
|
+
toModelOutput?: ((options: {
|
|
40
|
+
toolCallId: string;
|
|
41
|
+
input: {
|
|
42
|
+
task: string;
|
|
43
|
+
};
|
|
44
|
+
output: NoInfer<ComputerUseResult>;
|
|
45
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
46
|
+
} & {
|
|
47
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
48
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
49
|
+
task: string;
|
|
50
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
51
|
+
} & {
|
|
52
|
+
description?: string | ((options: {
|
|
53
|
+
context: NoInfer<import("@ai-sdk/provider-utils").Context>;
|
|
54
|
+
experimental_sandbox?: import("ai").Experimental_SandboxSession;
|
|
55
|
+
}) => string) | undefined;
|
|
56
|
+
strict?: boolean;
|
|
57
|
+
inputExamples?: {
|
|
58
|
+
input: NoInfer<{
|
|
59
|
+
task: string;
|
|
60
|
+
}>;
|
|
61
|
+
}[] | undefined;
|
|
62
|
+
id?: never;
|
|
63
|
+
isProviderExecuted?: never;
|
|
64
|
+
args?: never;
|
|
65
|
+
supportsDeferredResults?: never;
|
|
66
|
+
} & {
|
|
67
|
+
type?: undefined | "function";
|
|
68
|
+
} & {
|
|
69
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
70
|
+
task: string;
|
|
71
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
72
|
+
}) | ({
|
|
73
|
+
deferLoading?: boolean;
|
|
74
|
+
title?: string;
|
|
75
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
76
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
77
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
78
|
+
task: string;
|
|
79
|
+
}>;
|
|
80
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
81
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
82
|
+
task: string;
|
|
83
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
84
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
85
|
+
onInputDelta?: ((options: {
|
|
86
|
+
inputTextDelta: string;
|
|
87
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
88
|
+
onInputAvailable?: ((options: {
|
|
89
|
+
input: {
|
|
90
|
+
task: string;
|
|
91
|
+
};
|
|
92
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
93
|
+
toModelOutput?: ((options: {
|
|
94
|
+
toolCallId: string;
|
|
95
|
+
input: {
|
|
96
|
+
task: string;
|
|
97
|
+
};
|
|
98
|
+
output: NoInfer<ComputerUseResult>;
|
|
99
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
100
|
+
} & {
|
|
101
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
102
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
103
|
+
task: string;
|
|
104
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
105
|
+
} & {
|
|
106
|
+
description?: string | ((options: {
|
|
107
|
+
context: NoInfer<import("@ai-sdk/provider-utils").Context>;
|
|
108
|
+
experimental_sandbox?: import("ai").Experimental_SandboxSession;
|
|
109
|
+
}) => string) | undefined;
|
|
110
|
+
strict?: boolean;
|
|
111
|
+
inputExamples?: {
|
|
112
|
+
input: NoInfer<{
|
|
113
|
+
task: string;
|
|
114
|
+
}>;
|
|
115
|
+
}[] | undefined;
|
|
116
|
+
id?: never;
|
|
117
|
+
isProviderExecuted?: never;
|
|
118
|
+
args?: never;
|
|
119
|
+
supportsDeferredResults?: never;
|
|
120
|
+
} & {
|
|
121
|
+
type: "dynamic";
|
|
122
|
+
} & {
|
|
123
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
124
|
+
task: string;
|
|
125
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
126
|
+
}) | ({
|
|
127
|
+
deferLoading?: boolean;
|
|
128
|
+
title?: string;
|
|
129
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
130
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
131
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
132
|
+
task: string;
|
|
133
|
+
}>;
|
|
134
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
135
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
136
|
+
task: string;
|
|
137
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
138
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
139
|
+
onInputDelta?: ((options: {
|
|
140
|
+
inputTextDelta: string;
|
|
141
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
142
|
+
onInputAvailable?: ((options: {
|
|
143
|
+
input: {
|
|
144
|
+
task: string;
|
|
145
|
+
};
|
|
146
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
147
|
+
toModelOutput?: ((options: {
|
|
148
|
+
toolCallId: string;
|
|
149
|
+
input: {
|
|
150
|
+
task: string;
|
|
151
|
+
};
|
|
152
|
+
output: NoInfer<ComputerUseResult>;
|
|
153
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
154
|
+
} & {
|
|
155
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
156
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
157
|
+
task: string;
|
|
158
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
159
|
+
} & {
|
|
160
|
+
type: "provider";
|
|
161
|
+
id: `${string}.${string}`;
|
|
162
|
+
args: Record<string, unknown>;
|
|
163
|
+
description?: never;
|
|
164
|
+
strict?: never;
|
|
165
|
+
inputExamples?: never;
|
|
166
|
+
} & {
|
|
167
|
+
isProviderExecuted: false;
|
|
168
|
+
supportsDeferredResults?: never;
|
|
169
|
+
} & {
|
|
170
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
171
|
+
task: string;
|
|
172
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
173
|
+
}) | ({
|
|
174
|
+
deferLoading?: boolean;
|
|
175
|
+
title?: string;
|
|
176
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
177
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
178
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
179
|
+
task: string;
|
|
180
|
+
}>;
|
|
181
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
182
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
183
|
+
task: string;
|
|
184
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
185
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
186
|
+
onInputDelta?: ((options: {
|
|
187
|
+
inputTextDelta: string;
|
|
188
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
189
|
+
onInputAvailable?: ((options: {
|
|
190
|
+
input: {
|
|
191
|
+
task: string;
|
|
192
|
+
};
|
|
193
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
194
|
+
toModelOutput?: ((options: {
|
|
195
|
+
toolCallId: string;
|
|
196
|
+
input: {
|
|
197
|
+
task: string;
|
|
198
|
+
};
|
|
199
|
+
output: NoInfer<ComputerUseResult>;
|
|
200
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
201
|
+
} & {
|
|
202
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
203
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
204
|
+
task: string;
|
|
205
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
206
|
+
} & {
|
|
207
|
+
type: "provider";
|
|
208
|
+
id: `${string}.${string}`;
|
|
209
|
+
args: Record<string, unknown>;
|
|
210
|
+
description?: never;
|
|
211
|
+
strict?: never;
|
|
212
|
+
inputExamples?: never;
|
|
213
|
+
} & {
|
|
214
|
+
isProviderExecuted: true;
|
|
215
|
+
supportsDeferredResults?: boolean;
|
|
216
|
+
} & {
|
|
217
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
218
|
+
task: string;
|
|
219
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
220
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The computer use tool: hands one browser task to Harbour's computer use
|
|
2
|
+
// agent (CUA) and waits for its result. Calls go to the CUA's task API, signed
|
|
3
|
+
// with SigV4 as the runtime's own role (ADR 0078).
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { jsonSchema, tool } from 'ai';
|
|
6
|
+
import { runtimeCredentials, signedJsonPost } from './aws.js';
|
|
7
|
+
const HEARTBEAT_MS = 60_000;
|
|
8
|
+
// Checks the API did not answer (a throttle, a 5xx, no connection) before the tool gives up: about a minute.
|
|
9
|
+
const MAX_MISSED_CHECKS = 12;
|
|
10
|
+
export function createComputerUseTool(options) {
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
const base = env.ISOMORPH_COMPUTER_USE_URL?.replace(/\/+$/, '');
|
|
13
|
+
if (!base)
|
|
14
|
+
throw new Error('This app has no computer use API (ISOMORPH_COMPUTER_USE_URL is not set).');
|
|
15
|
+
if (!options.runKey)
|
|
16
|
+
throw new Error('createComputerUseTool needs the job run as runKey.');
|
|
17
|
+
const region = env.ISOMORPH_COMPUTER_USE_REGION || 'us-east-1';
|
|
18
|
+
const now = options.now ?? Date.now;
|
|
19
|
+
const transport = options.fetch ? { fetch: options.fetch } : {};
|
|
20
|
+
const call = async (path, body) => signedJsonPost({ ...await runtimeCredentials(env, transport), region }, 'execute-api', `${base}${path}`, body, transport);
|
|
21
|
+
const refused = (path, status, body) => {
|
|
22
|
+
const error = body?.error;
|
|
23
|
+
return new Error(`The computer use API refused ${path} (${status}${typeof error === 'string' ? `: ${error}` : ''}).`);
|
|
24
|
+
};
|
|
25
|
+
return tool({
|
|
26
|
+
description: 'Do one task in a web browser, such as filling in and submitting a form on a website. Describe the whole task in plain words, with the website address and any details it needs. Waits until the task ends and returns its status and a summary.',
|
|
27
|
+
inputSchema: jsonSchema({ type: 'object', properties: { task: { type: 'string', minLength: 1 } }, required: ['task'], additionalProperties: false }),
|
|
28
|
+
execute: async ({ task }) => {
|
|
29
|
+
const started = await call('/tasks', { run_key: options.runKey, task });
|
|
30
|
+
if (started.status !== 200)
|
|
31
|
+
throw refused('/tasks', started.status, started.body);
|
|
32
|
+
await options.progress(0, 'Computer use task started.');
|
|
33
|
+
let reported = now();
|
|
34
|
+
let steps = 0;
|
|
35
|
+
let cancelSent = false;
|
|
36
|
+
let missed = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (!cancelSent && await options.cancelled()) {
|
|
39
|
+
const cancel = await call('/tasks/cancel', { run_key: options.runKey });
|
|
40
|
+
if (cancel.status !== 200)
|
|
41
|
+
throw refused('/tasks/cancel', cancel.status, cancel.body);
|
|
42
|
+
cancelSent = true;
|
|
43
|
+
}
|
|
44
|
+
let check;
|
|
45
|
+
try {
|
|
46
|
+
check = await call('/tasks/check', { run_key: options.runKey });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
check = undefined;
|
|
50
|
+
}
|
|
51
|
+
if (check?.status === 200) {
|
|
52
|
+
missed = 0;
|
|
53
|
+
const answer = check.body;
|
|
54
|
+
if (answer.status === 'done' || answer.status === 'failed')
|
|
55
|
+
return { status: answer.status, summary: answer.summary ?? null };
|
|
56
|
+
if (answer.steps > steps || now() - reported >= HEARTBEAT_MS) {
|
|
57
|
+
steps = Math.max(steps, answer.steps);
|
|
58
|
+
await options.progress(Math.min(steps, 99), answer.last_step ? `Computer use step ${steps}: ${answer.last_step}` : `Computer use task ${answer.status}.`);
|
|
59
|
+
reported = now();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (check && check.status !== 429 && check.status < 500) {
|
|
63
|
+
throw refused('/tasks/check', check.status, check.body);
|
|
64
|
+
}
|
|
65
|
+
else if (++missed > MAX_MISSED_CHECKS) {
|
|
66
|
+
throw new Error('The computer use API stopped answering.');
|
|
67
|
+
}
|
|
68
|
+
await sleep(options.pollIntervalMs ?? 5_000);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The kit runtime as a Lambda Web Function (ADR 0065). The app's runtime.zip
|
|
2
|
+
// has a one-line index.js, `import "@isomorph.ai/app-sdk/dist/lambda-entry.js"`,
|
|
3
|
+
// and the function starts it: the runtime serves on the port Lambda names in
|
|
4
|
+
// AWS_LAMBDA_HTTP_ENDPOINT (host:port) and stops on SIGTERM, as it does in a pod.
|
|
5
|
+
import { main } from './runtime.js';
|
|
6
|
+
const endpoint = process.env.AWS_LAMBDA_HTTP_ENDPOINT;
|
|
7
|
+
if (endpoint && !process.env.PORT)
|
|
8
|
+
process.env.PORT = endpoint.slice(endpoint.lastIndexOf(':') + 1);
|
|
9
|
+
// Lambda's own arguments are not the runtime's: this entry always serves.
|
|
10
|
+
await main([]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function handler(event: unknown): Promise<void>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The kit runtime as the cell's Event Function (ADR 0065): the same runtime.zip
|
|
2
|
+
// with the handler node_modules/@isomorph.ai/app-sdk/dist/lambda-events-entry.handler.
|
|
3
|
+
// One function per app serves both kinds of event:
|
|
4
|
+
//
|
|
5
|
+
// {"job": "<name>"} a Scheduler schedule's input: one scheduled run of
|
|
6
|
+
// jobs/<name>.ts, exactly as `--run-job <name>` runs it
|
|
7
|
+
// {"Records": [...]} SQS messages from an enqueue: the queue is worked
|
|
8
|
+
// until a pass finds nothing
|
|
9
|
+
//
|
|
10
|
+
// An invocation that did not do its work throws, so Lambda counts the error
|
|
11
|
+
// and SQS delivers the message again.
|
|
12
|
+
import { register } from 'node:module';
|
|
13
|
+
import { processOptions, Runtime } from './runtime.js';
|
|
14
|
+
register('./runtime-hooks.js', import.meta.url);
|
|
15
|
+
let runtime;
|
|
16
|
+
function eventRuntime() {
|
|
17
|
+
runtime ??= new Runtime(processOptions());
|
|
18
|
+
return runtime;
|
|
19
|
+
}
|
|
20
|
+
export async function handler(event) {
|
|
21
|
+
const { job, Records: records } = (event && typeof event === 'object' ? event : {});
|
|
22
|
+
if (typeof job === 'string' && job) {
|
|
23
|
+
if (!(await eventRuntime().runJobOnce(job)))
|
|
24
|
+
throw new Error(`jobs/${job}.ts did not run to completion; the log above names why`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(records))
|
|
28
|
+
return eventRuntime().drainQueue();
|
|
29
|
+
throw new Error('the event is neither a scheduled job ({"job": "<name>"}) nor queue messages');
|
|
30
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface RuntimeOptions {
|
|
|
87
87
|
localGateway?: Omit<LocalGatewayOptions, 'log'>;
|
|
88
88
|
/** 'sts-session' when the workload's bearer is a gateway session for the function role (ISOMORPH_GATEWAY_IDENTITY). */
|
|
89
89
|
gatewayIdentity?: 'sts-session';
|
|
90
|
+
/** The SQS queue an enqueue sends its run to (ISOMORPH_JOBS_QUEUE_URL); the queue loop is off when set. */
|
|
91
|
+
queueUrl?: string;
|
|
90
92
|
}
|
|
91
93
|
export declare class Runtime {
|
|
92
94
|
private readonly options;
|
|
@@ -104,8 +106,18 @@ export declare class Runtime {
|
|
|
104
106
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
105
107
|
constructor(options: RuntimeOptions);
|
|
106
108
|
start(): Promise<number>;
|
|
107
|
-
/** Whether this process works the queue at all: not the gate,
|
|
109
|
+
/** Whether this process works the queue at all: not the gate, no queue elsewhere, and there is a queue. */
|
|
108
110
|
private worksQueue;
|
|
111
|
+
/** A runs table, an app with jobs, a gateway to reach. */
|
|
112
|
+
private hasQueue;
|
|
113
|
+
/**
|
|
114
|
+
* The Event Function's answer to a queue message: wait for the data path,
|
|
115
|
+
* then work passes until one finds nothing. A message stands for "the queue
|
|
116
|
+
* has work", not for its run: a pass claims the oldest run, and a failed
|
|
117
|
+
* run's retry needs a pass after it. A pass that throws fails the
|
|
118
|
+
* invocation, so SQS delivers the message again.
|
|
119
|
+
*/
|
|
120
|
+
drainQueue(): Promise<void>;
|
|
109
121
|
/**
|
|
110
122
|
* Starts the queue loop unless it is already live. The first pass runs at
|
|
111
123
|
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
@@ -146,3 +158,7 @@ export declare class Runtime {
|
|
|
146
158
|
export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
|
|
147
159
|
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|
|
148
160
|
export declare function runJobFromArguments(argv?: string[]): string | undefined;
|
|
161
|
+
/** The options of a runtime process; the app's own SDK calls go to the gateway the runtime was given. */
|
|
162
|
+
export declare function processOptions(): RuntimeOptions;
|
|
163
|
+
/** The runtime as a process: this module's main, and the Web Function's (lambda-entry.ts). */
|
|
164
|
+
export declare function main(argv?: string[]): Promise<void>;
|
package/dist/runtime.js
CHANGED
|
@@ -53,6 +53,9 @@
|
|
|
53
53
|
// the workload's bearer is a gateway session the
|
|
54
54
|
// runtime obtains with its function role (a
|
|
55
55
|
// serverless cell, ADR 0065), not a token file
|
|
56
|
+
// ISOMORPH_JOBS_QUEUE_URL an enqueue sends the run to this SQS queue and
|
|
57
|
+
// the queue loop never runs here: the cell's
|
|
58
|
+
// Event Function works it (lambda-events-entry.ts)
|
|
56
59
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
57
60
|
import { createServer } from 'node:http';
|
|
58
61
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
@@ -82,8 +85,9 @@ const LEASE_MS = 5 * 60_000;
|
|
|
82
85
|
const QUEUE_POLL_MS = 2_000;
|
|
83
86
|
const TOKEN_REFRESH_MS = 60_000;
|
|
84
87
|
// A gateway session lasts an hour. An action runs at most 300 s, so a request
|
|
85
|
-
// takes a new one in the session's last 10 minutes;
|
|
86
|
-
//
|
|
88
|
+
// takes a new one in the session's last 10 minutes; an Event Function
|
|
89
|
+
// invocation (a scheduled run, a queue drain) may take up to 900 s and takes
|
|
90
|
+
// one in the last 15.
|
|
87
91
|
const SESSION_MIN_VALIDITY_MS = 10 * 60_000;
|
|
88
92
|
const JOB_SESSION_MIN_VALIDITY_MS = 15 * 60_000;
|
|
89
93
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
@@ -293,9 +297,32 @@ export class Runtime {
|
|
|
293
297
|
this.wakeQueue();
|
|
294
298
|
return port;
|
|
295
299
|
}
|
|
296
|
-
/** Whether this process works the queue at all: not the gate,
|
|
300
|
+
/** Whether this process works the queue at all: not the gate, no queue elsewhere, and there is a queue. */
|
|
297
301
|
worksQueue() {
|
|
298
|
-
return this.options.mode !== 'gate' && this.options.
|
|
302
|
+
return this.options.mode !== 'gate' && !this.options.queueUrl && this.hasQueue();
|
|
303
|
+
}
|
|
304
|
+
/** A runs table, an app with jobs, a gateway to reach. */
|
|
305
|
+
hasQueue() {
|
|
306
|
+
return this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The Event Function's answer to a queue message: wait for the data path,
|
|
310
|
+
* then work passes until one finds nothing. A message stands for "the queue
|
|
311
|
+
* has work", not for its run: a pass claims the oldest run, and a failed
|
|
312
|
+
* run's retry needs a pass after it. A pass that throws fails the
|
|
313
|
+
* invocation, so SQS delivers the message again.
|
|
314
|
+
*/
|
|
315
|
+
async drainQueue() {
|
|
316
|
+
this.inventory = await discover(this.options.root);
|
|
317
|
+
await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
|
|
318
|
+
if (!this.hasQueue()) {
|
|
319
|
+
this.log('a queue message arrived but this app has no runs to work');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!(await this.awaitDataPath()))
|
|
323
|
+
throw new Error('the data path did not answer; the message is retried');
|
|
324
|
+
while (!this.stopped && await this.work())
|
|
325
|
+
await new Promise((wake) => setTimeout(wake, this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
299
326
|
}
|
|
300
327
|
/**
|
|
301
328
|
* Starts the queue loop unless it is already live. The first pass runs at
|
|
@@ -662,7 +689,13 @@ export class Runtime {
|
|
|
662
689
|
if (!owner)
|
|
663
690
|
throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
|
|
664
691
|
const queued = await this.runs.enqueue(job, body.input, owner);
|
|
665
|
-
|
|
692
|
+
// Awaited before the enqueue answers: a Web Function may be frozen as soon as it has replied.
|
|
693
|
+
if (this.options.queueUrl) {
|
|
694
|
+
const { awsEnvironment, sendQueueMessage } = await import('./aws.js');
|
|
695
|
+
await sendQueueMessage(awsEnvironment(), this.options.queueUrl, JSON.stringify({ runId: queued.id }));
|
|
696
|
+
}
|
|
697
|
+
else
|
|
698
|
+
this.wakeQueue();
|
|
666
699
|
return queued;
|
|
667
700
|
}
|
|
668
701
|
case GET_RUN_ACTION: {
|
|
@@ -692,6 +725,7 @@ export function optionsFromEnvironment(env = process.env) {
|
|
|
692
725
|
tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
|
|
693
726
|
jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
|
|
694
727
|
...(env.ISOMORPH_GATEWAY_IDENTITY === 'sts-session' ? { gatewayIdentity: 'sts-session' } : {}),
|
|
728
|
+
...(env.ISOMORPH_JOBS_QUEUE_URL ? { queueUrl: env.ISOMORPH_JOBS_QUEUE_URL } : {}),
|
|
695
729
|
...(env.ISOMORPH_LOCAL_GATEWAY_BINARY && env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE
|
|
696
730
|
? { localGateway: { binary: env.ISOMORPH_LOCAL_GATEWAY_BINARY, configFile: env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE, url: (env.ISOMORPH_LOCAL_GATEWAY_URL || '').replace(/\/$/, '') } }
|
|
697
731
|
: {}),
|
|
@@ -708,33 +742,26 @@ export function runJobFromArguments(argv = process.argv.slice(2)) {
|
|
|
708
742
|
}
|
|
709
743
|
return undefined;
|
|
710
744
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const entryPath = process.argv[1] ? (() => { try {
|
|
718
|
-
return realpathSync(process.argv[1]);
|
|
745
|
+
/** The options of a runtime process; the app's own SDK calls go to the gateway the runtime was given. */
|
|
746
|
+
export function processOptions() {
|
|
747
|
+
const options = optionsFromEnvironment();
|
|
748
|
+
if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
|
|
749
|
+
process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
|
|
750
|
+
return options;
|
|
719
751
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
} })() : '';
|
|
723
|
-
const entry = entryPath ? pathToFileURL(entryPath).href : '';
|
|
724
|
-
if (import.meta.url === entry) {
|
|
752
|
+
/** The runtime as a process: this module's main, and the Web Function's (lambda-entry.ts). */
|
|
753
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
725
754
|
// Extensionless relative imports in the app's server code resolve to
|
|
726
755
|
// their .ts files (runtime-hooks.ts); registered before any app module
|
|
727
756
|
// is imported.
|
|
728
757
|
register('./runtime-hooks.js', import.meta.url);
|
|
729
|
-
const options =
|
|
730
|
-
if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
|
|
731
|
-
process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
|
|
758
|
+
const options = processOptions();
|
|
732
759
|
if (!(await stat(options.root).catch(() => undefined))?.isDirectory()) {
|
|
733
760
|
console.error(`isomorph runtime: ${options.root} is not a directory`);
|
|
734
761
|
process.exit(2);
|
|
735
762
|
}
|
|
736
763
|
const runtime = new Runtime(options);
|
|
737
|
-
const job = runJobFromArguments();
|
|
764
|
+
const job = runJobFromArguments(argv);
|
|
738
765
|
if (job !== undefined) {
|
|
739
766
|
if (!job) {
|
|
740
767
|
console.error('isomorph runtime: --run-job needs a job name');
|
|
@@ -747,3 +774,18 @@ if (import.meta.url === entry) {
|
|
|
747
774
|
process.on('SIGINT', shutdown);
|
|
748
775
|
await runtime.start();
|
|
749
776
|
}
|
|
777
|
+
// Node resolves this module's URL through any symlink in its path, while
|
|
778
|
+
// process.argv[1] is the path the starter typed. On macOS /tmp and /var are
|
|
779
|
+
// symlinks, so a gate or `isomorph dev` started from an app root under them
|
|
780
|
+
// handed a path whose URL never matched, and the runtime loaded as a library
|
|
781
|
+
// and exited 0 without a word (the gate reported "exited before it
|
|
782
|
+
// answered", 2026-10-06). Compare the real path, as Node did.
|
|
783
|
+
const entryPath = process.argv[1] ? (() => { try {
|
|
784
|
+
return realpathSync(process.argv[1]);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return process.argv[1];
|
|
788
|
+
} })() : '';
|
|
789
|
+
const entry = entryPath ? pathToFileURL(entryPath).href : '';
|
|
790
|
+
if (import.meta.url === entry)
|
|
791
|
+
await main();
|