@unicity-astrid/sdk 0.1.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 +120 -0
- package/dist/approval.d.ts +23 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +29 -0
- package/dist/approval.js.map +1 -0
- package/dist/capabilities.d.ts +14 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +19 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/capsule.d.ts +39 -0
- package/dist/capsule.d.ts.map +1 -0
- package/dist/capsule.js +67 -0
- package/dist/capsule.js.map +1 -0
- package/dist/contracts.d.ts +1104 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +4 -0
- package/dist/contracts.js.map +1 -0
- package/dist/elicit.d.ts +30 -0
- package/dist/elicit.d.ts.map +1 -0
- package/dist/elicit.js +103 -0
- package/dist/elicit.js.map +1 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +27 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +108 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs.d.ts +135 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +257 -0
- package/dist/fs.js.map +1 -0
- package/dist/http.d.ts +90 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +276 -0
- package/dist/http.js.map +1 -0
- package/dist/identity.d.ts +46 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +69 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptors.d.ts +21 -0
- package/dist/interceptors.d.ts.map +1 -0
- package/dist/interceptors.js +22 -0
- package/dist/interceptors.js.map +1 -0
- package/dist/ipc.d.ts +143 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +261 -0
- package/dist/ipc.js.map +1 -0
- package/dist/kv.d.ts +45 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +91 -0
- package/dist/kv.js.map +1 -0
- package/dist/log.d.ts +17 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +40 -0
- package/dist/log.js.map +1 -0
- package/dist/net.d.ts +154 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +421 -0
- package/dist/net.js.map +1 -0
- package/dist/process.d.ts +77 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +128 -0
- package/dist/process.js.map +1 -0
- package/dist/runtime/bridge.d.ts +34 -0
- package/dist/runtime/bridge.d.ts.map +1 -0
- package/dist/runtime/bridge.js +326 -0
- package/dist/runtime/bridge.js.map +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/registry.d.ts +58 -0
- package/dist/runtime/registry.d.ts.map +1 -0
- package/dist/runtime/registry.js +129 -0
- package/dist/runtime/registry.js.map +1 -0
- package/dist/runtime.d.ts +36 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +50 -0
- package/dist/runtime.js.map +1 -0
- package/dist/time.d.ts +29 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +43 -0
- package/dist/time.js.map +1 -0
- package/dist/tool.d.ts +48 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +86 -0
- package/dist/tool.js.map +1 -0
- package/dist/uplink.d.ts +27 -0
- package/dist/uplink.d.ts.map +1 -0
- package/dist/uplink.js +36 -0
- package/dist/uplink.js.map +1 -0
- package/package.json +38 -0
- package/src/approval.ts +38 -0
- package/src/capabilities.ts +22 -0
- package/src/capsule.ts +90 -0
- package/src/contracts.ts +1189 -0
- package/src/elicit.ts +136 -0
- package/src/env.ts +31 -0
- package/src/errors.ts +122 -0
- package/src/fs.ts +357 -0
- package/src/http.ts +345 -0
- package/src/identity.ts +101 -0
- package/src/index.ts +83 -0
- package/src/interceptors.ts +25 -0
- package/src/ipc.ts +354 -0
- package/src/kv.ts +123 -0
- package/src/log.ts +43 -0
- package/src/net.ts +545 -0
- package/src/process.ts +205 -0
- package/src/runtime/bridge.ts +374 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +178 -0
- package/src/runtime.ts +70 -0
- package/src/time.ts +48 -0
- package/src/tool.ts +125 -0
- package/src/uplink.ts +49 -0
- package/src/wit-imports.d.ts +689 -0
- package/wit-contracts/astrid-contracts.wit +1266 -0
package/src/process.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host process spawning. Mirrors `astrid_sdk::process`. Capsules need the
|
|
3
|
+
* `host_process` capability for the specific command being invoked; the
|
|
4
|
+
* kernel runs the process under platform sandboxing (sandbox-exec on macOS,
|
|
5
|
+
* bwrap on Linux).
|
|
6
|
+
*
|
|
7
|
+
* Per-capsule cap: 8 concurrent background processes. `ProcessHandle` is a
|
|
8
|
+
* Component Model resource — drop releases the slot and reaps the child.
|
|
9
|
+
*
|
|
10
|
+
* **Desktop-kernel only.** Unikernel targets (hermit-rs, etc.) do not implement
|
|
11
|
+
* this package; capsules importing `astrid:process` will fail to load there.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
spawn as hostSpawn,
|
|
16
|
+
spawnBackground as hostSpawnBackground,
|
|
17
|
+
type ProcessHandle as WitProcessHandle,
|
|
18
|
+
type ProcessSignal,
|
|
19
|
+
type SpawnRequest,
|
|
20
|
+
type ExitInfo,
|
|
21
|
+
} from "astrid:process/host@1.0.0";
|
|
22
|
+
import { SysError, callHost } from "./errors.js";
|
|
23
|
+
|
|
24
|
+
export type { ProcessSignal, EnvVar } from "astrid:process/host@1.0.0";
|
|
25
|
+
|
|
26
|
+
export interface ProcessResult {
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
/** Exit code if exited normally; `undefined` if killed by signal. */
|
|
30
|
+
exitCode: number | undefined;
|
|
31
|
+
/** Unix signal number if killed by signal; `undefined` otherwise. */
|
|
32
|
+
signal: number | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProcessLogs {
|
|
36
|
+
stdout: string;
|
|
37
|
+
stderr: string;
|
|
38
|
+
running: boolean;
|
|
39
|
+
exitCode: number | undefined;
|
|
40
|
+
signal: number | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KillResult {
|
|
44
|
+
killed: boolean;
|
|
45
|
+
exitCode: number | undefined;
|
|
46
|
+
signal: number | undefined;
|
|
47
|
+
stdout: string;
|
|
48
|
+
stderr: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SpawnOptions {
|
|
52
|
+
/** Working directory relative to the workspace. */
|
|
53
|
+
cwd?: string;
|
|
54
|
+
/** Environment variables to pass to the child. */
|
|
55
|
+
env?: Record<string, string>;
|
|
56
|
+
/** Stdin bytes piped to the child on spawn. */
|
|
57
|
+
stdin?: Uint8Array;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSpawnRequest(
|
|
61
|
+
cmd: string,
|
|
62
|
+
args: string[],
|
|
63
|
+
options: SpawnOptions | undefined,
|
|
64
|
+
): SpawnRequest {
|
|
65
|
+
return {
|
|
66
|
+
cmd,
|
|
67
|
+
args,
|
|
68
|
+
stdin: options?.stdin,
|
|
69
|
+
env: options?.env
|
|
70
|
+
? Object.entries(options.env).map(([key, value]) => ({ key, value }))
|
|
71
|
+
: [],
|
|
72
|
+
cwd: options?.cwd,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function unpackExit(exit: ExitInfo): { exitCode: number | undefined; signal: number | undefined } {
|
|
77
|
+
return { exitCode: exit.exitCode, signal: exit.signal };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Spawn a process and block until it exits. */
|
|
81
|
+
export function spawn(
|
|
82
|
+
cmd: string,
|
|
83
|
+
args: string[] = [],
|
|
84
|
+
options?: SpawnOptions,
|
|
85
|
+
): ProcessResult {
|
|
86
|
+
const request = buildSpawnRequest(cmd, args, options);
|
|
87
|
+
const result = callHost(`process.spawn(${JSON.stringify(cmd)})`, () =>
|
|
88
|
+
hostSpawn(request),
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
stdout: result.stdout,
|
|
92
|
+
stderr: result.stderr,
|
|
93
|
+
...unpackExit(result.exit),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Spawn a background process. Returns a resource handle. */
|
|
98
|
+
export function spawnBackground(
|
|
99
|
+
cmd: string,
|
|
100
|
+
args: string[] = [],
|
|
101
|
+
options?: SpawnOptions,
|
|
102
|
+
): BackgroundProcessHandle {
|
|
103
|
+
const request = buildSpawnRequest(cmd, args, options);
|
|
104
|
+
const inner = callHost(`process.spawnBackground(${JSON.stringify(cmd)})`, () =>
|
|
105
|
+
hostSpawnBackground(request),
|
|
106
|
+
);
|
|
107
|
+
return new BackgroundProcessHandle(inner);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class BackgroundProcessHandle {
|
|
111
|
+
#inner: WitProcessHandle | undefined;
|
|
112
|
+
|
|
113
|
+
constructor(inner: WitProcessHandle) {
|
|
114
|
+
this.#inner = inner;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Drain buffered stdout/stderr since the last read. */
|
|
118
|
+
readLogs(): ProcessLogs {
|
|
119
|
+
const result = callHost("process.readLogs", () => this.#requireInner().readLogs());
|
|
120
|
+
return {
|
|
121
|
+
stdout: result.stdout,
|
|
122
|
+
stderr: result.stderr,
|
|
123
|
+
running: result.running,
|
|
124
|
+
exitCode: result.exit?.exitCode,
|
|
125
|
+
signal: result.exit?.signal,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Write to stdin. Returns bytes actually written. */
|
|
130
|
+
writeStdin(data: Uint8Array): number {
|
|
131
|
+
return callHost("process.writeStdin", () => this.#requireInner().writeStdin(data));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Close the stdin pipe (child observes EOF). */
|
|
135
|
+
closeStdin(): void {
|
|
136
|
+
callHost("process.closeStdin", () => this.#requireInner().closeStdin());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Send a signal (fire-and-forget). Use {@link kill} for SIGKILL + log drainage. */
|
|
140
|
+
signal(sig: ProcessSignal): void {
|
|
141
|
+
callHost(`process.signal(${sig})`, () => this.#requireInner().signal(sig));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Send SIGKILL and drain remaining output. */
|
|
145
|
+
kill(): KillResult {
|
|
146
|
+
const result = callHost("process.kill", () => this.#requireInner().kill());
|
|
147
|
+
return {
|
|
148
|
+
killed: result.killed,
|
|
149
|
+
stdout: result.stdout,
|
|
150
|
+
stderr: result.stderr,
|
|
151
|
+
exitCode: result.exit?.exitCode,
|
|
152
|
+
signal: result.exit?.signal,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wait for the process to exit. `timeoutMs = undefined` waits indefinitely;
|
|
158
|
+
* a bounded value throws `wait-timeout` if the timeout elapses first.
|
|
159
|
+
*/
|
|
160
|
+
wait(timeoutMs?: number): { exitCode: number | undefined; signal: number | undefined } {
|
|
161
|
+
const ms = timeoutMs === undefined ? undefined : BigInt(Math.max(0, Math.floor(timeoutMs)));
|
|
162
|
+
const exit = callHost(`process.wait(${timeoutMs ?? "∞"})`, () =>
|
|
163
|
+
this.#requireInner().wait(ms),
|
|
164
|
+
);
|
|
165
|
+
return unpackExit(exit);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Wait for the process AND drain remaining stdout/stderr atomically. */
|
|
169
|
+
waitWithOutput(timeoutMs?: number): ProcessResult {
|
|
170
|
+
const ms = timeoutMs === undefined ? undefined : BigInt(Math.max(0, Math.floor(timeoutMs)));
|
|
171
|
+
const result = callHost(`process.waitWithOutput(${timeoutMs ?? "∞"})`, () =>
|
|
172
|
+
this.#requireInner().waitWithOutput(ms),
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
stdout: result.stdout,
|
|
176
|
+
stderr: result.stderr,
|
|
177
|
+
...unpackExit(result.exit),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** OS-level PID. Throws if the process has already been reaped. */
|
|
182
|
+
osPid(): number {
|
|
183
|
+
return callHost("process.osPid", () => this.#requireInner().osPid());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
close(): void {
|
|
187
|
+
if (this.#inner === undefined) return;
|
|
188
|
+
const inner = this.#inner;
|
|
189
|
+
this.#inner = undefined;
|
|
190
|
+
try {
|
|
191
|
+
inner[Symbol.dispose]();
|
|
192
|
+
} catch {
|
|
193
|
+
// already released
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
[Symbol.dispose](): void {
|
|
198
|
+
this.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#requireInner(): WitProcessHandle {
|
|
202
|
+
if (this.#inner === undefined) throw SysError.api("ProcessHandle is closed");
|
|
203
|
+
return this.#inner;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge runtime — implements the four WIT guest exports
|
|
3
|
+
* (`astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`) on top
|
|
4
|
+
* of the registry the decorators populated.
|
|
5
|
+
*
|
|
6
|
+
* The Rust SDK's `#[capsule]` macro generates this same logic at compile
|
|
7
|
+
* time. We do it at runtime: walk the registry, dispatch the action,
|
|
8
|
+
* load/save state for mutable handlers, publish results to IPC, and
|
|
9
|
+
* produce the `capsule-result` the kernel expects.
|
|
10
|
+
*
|
|
11
|
+
* Dispatch table mirrors `sdk-rust/astrid-sdk-macros/src/lib.rs:600–660`:
|
|
12
|
+
* - `tool_describe`: aggregate schemas + descriptions, return as `data`.
|
|
13
|
+
* Built lazily on first invocation, cached thereafter.
|
|
14
|
+
* - `tool_execute_<name>`: parse `ToolExecuteRequest`, optionally load
|
|
15
|
+
* state, run handler, publish result to `tool.v1.execute.<name>.result`,
|
|
16
|
+
* optionally save state, return `{ action: "continue", data: undefined }`.
|
|
17
|
+
* - any other action: check the interceptors map THEN the commands map.
|
|
18
|
+
* Their results flow back via `capsule-result.data` directly (no IPC
|
|
19
|
+
* publish), matching the Rust macro's behaviour for these paths.
|
|
20
|
+
*
|
|
21
|
+
* State key matches Rust: `__state`, JSON-encoded.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
getRegistration,
|
|
26
|
+
type CapsuleRegistration,
|
|
27
|
+
type ToolEntry,
|
|
28
|
+
type InterceptorEntry,
|
|
29
|
+
type CommandEntry,
|
|
30
|
+
} from "./registry.js";
|
|
31
|
+
import * as kv from "../kv.js";
|
|
32
|
+
import * as ipc from "../ipc.js";
|
|
33
|
+
import * as log from "../log.js";
|
|
34
|
+
import { getConfig } from "astrid:sys/host@1.0.0";
|
|
35
|
+
|
|
36
|
+
const STATE_KEY = "__state";
|
|
37
|
+
|
|
38
|
+
export interface CapsuleResult {
|
|
39
|
+
action: string;
|
|
40
|
+
data: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Bridge {
|
|
44
|
+
astridHookTrigger(action: string, payload: Uint8Array): CapsuleResult;
|
|
45
|
+
run(): void;
|
|
46
|
+
astridInstall(): void;
|
|
47
|
+
astridUpgrade(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ToolExecuteRequest {
|
|
51
|
+
call_id: string;
|
|
52
|
+
tool_name: string;
|
|
53
|
+
arguments: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
|
|
58
|
+
function denied(reason: string): CapsuleResult {
|
|
59
|
+
return { action: "deny", data: reason };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cont(data?: string): CapsuleResult {
|
|
63
|
+
return { action: "continue", data };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createBridge(): Bridge {
|
|
67
|
+
let toolDescribeCache: string | undefined;
|
|
68
|
+
|
|
69
|
+
function reg(): CapsuleRegistration {
|
|
70
|
+
const r = getRegistration();
|
|
71
|
+
if (r === undefined) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"No @capsule class registered. The build pipeline emits the entry " +
|
|
74
|
+
"module after the user's source so decorators have already fired — " +
|
|
75
|
+
"this means the user code never imported the SDK or never declared " +
|
|
76
|
+
"a @capsule class.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return r;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildToolDescribe(): string {
|
|
83
|
+
const r = reg();
|
|
84
|
+
const tools = Array.from(r.tools.values()).map(toolToDescribeEntry);
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
tools,
|
|
87
|
+
description: r.description ?? "",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadInstance(r: CapsuleRegistration): object {
|
|
92
|
+
const instance = new r.ctor();
|
|
93
|
+
const persisted = kv.get<Record<string, unknown>>(STATE_KEY);
|
|
94
|
+
if (persisted !== undefined && typeof persisted === "object" && persisted !== null) {
|
|
95
|
+
Object.assign(instance, persisted);
|
|
96
|
+
}
|
|
97
|
+
return instance;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persistInstance(instance: object): void {
|
|
101
|
+
const snapshot: Record<string, unknown> = {};
|
|
102
|
+
for (const [key, value] of Object.entries(instance)) {
|
|
103
|
+
snapshot[key] = value;
|
|
104
|
+
}
|
|
105
|
+
kv.set(STATE_KEY, snapshot);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getInstance(entry: { mutable: boolean }): { instance: object; persist: boolean } {
|
|
109
|
+
const r = reg();
|
|
110
|
+
if (entry.mutable) {
|
|
111
|
+
return { instance: loadInstance(r), persist: true };
|
|
112
|
+
}
|
|
113
|
+
return { instance: new r.ctor(), persist: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function executeTool(entry: ToolEntry, payload: Uint8Array): CapsuleResult {
|
|
117
|
+
let req: ToolExecuteRequest;
|
|
118
|
+
try {
|
|
119
|
+
req = JSON.parse(decoder.decode(payload)) as ToolExecuteRequest;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return denied(`failed to parse tool execute payload: ${(e as Error).message}`);
|
|
122
|
+
}
|
|
123
|
+
const callId = req.call_id ?? "";
|
|
124
|
+
|
|
125
|
+
let instance: object;
|
|
126
|
+
let persist: boolean;
|
|
127
|
+
try {
|
|
128
|
+
({ instance, persist } = getInstance(entry));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
publishToolError(entry.name, callId, `failed to load state: ${(e as Error).message}`);
|
|
131
|
+
return cont();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let resultPayload: { content: string; isError: boolean };
|
|
135
|
+
try {
|
|
136
|
+
const raw = invoke(instance, entry.methodName, req.arguments);
|
|
137
|
+
resultPayload = { content: stringifyResult(raw), isError: false };
|
|
138
|
+
} catch (e) {
|
|
139
|
+
resultPayload = { content: (e as Error).message ?? String(e), isError: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (persist && !resultPayload.isError) {
|
|
143
|
+
try {
|
|
144
|
+
persistInstance(instance);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
publishToolError(entry.name, callId, `failed to save state: ${(e as Error).message}`);
|
|
147
|
+
return cont();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
publishToolResult(entry.name, callId, resultPayload.content, resultPayload.isError);
|
|
152
|
+
return cont();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Dispatch interceptor / command actions. Both kinds share the same
|
|
157
|
+
* return path: the handler's JSON-serialized result becomes
|
|
158
|
+
* `capsule-result.data`, the kernel reads it from the hook-trigger
|
|
159
|
+
* return value. `null` results yield `{ action: "continue", data: undefined }`
|
|
160
|
+
* so the interceptor chain keeps the original payload (mirrors Rust).
|
|
161
|
+
*/
|
|
162
|
+
function executeHookHandler(
|
|
163
|
+
entry: InterceptorEntry | CommandEntry,
|
|
164
|
+
payload: Uint8Array,
|
|
165
|
+
): CapsuleResult {
|
|
166
|
+
let parsed: unknown = undefined;
|
|
167
|
+
if (payload.length > 0) {
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(decoder.decode(payload));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return denied(`failed to parse payload: ${(e as Error).message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let instance: object;
|
|
176
|
+
let persist: boolean;
|
|
177
|
+
try {
|
|
178
|
+
({ instance, persist } = getInstance(entry));
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return denied(`failed to load state: ${(e as Error).message}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let resultJson: string;
|
|
184
|
+
try {
|
|
185
|
+
const raw = invoke(instance, entry.methodName, parsed);
|
|
186
|
+
resultJson = stringifyResult(raw);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return denied((e as Error).message ?? String(e));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (persist) {
|
|
192
|
+
try {
|
|
193
|
+
persistInstance(instance);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return denied(`failed to save state: ${(e as Error).message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (resultJson === "null") return cont();
|
|
200
|
+
return cont(resultJson);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
astridHookTrigger(action: string, payload: Uint8Array): CapsuleResult {
|
|
205
|
+
try {
|
|
206
|
+
if (action === "tool_describe") {
|
|
207
|
+
toolDescribeCache ??= buildToolDescribe();
|
|
208
|
+
return cont(toolDescribeCache);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (action.startsWith("tool_execute_")) {
|
|
212
|
+
const name = action.slice("tool_execute_".length);
|
|
213
|
+
const r = reg();
|
|
214
|
+
const entry = r.tools.get(name);
|
|
215
|
+
if (entry === undefined) return denied(`unknown tool: ${name}`);
|
|
216
|
+
return executeTool(entry, payload);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const r = reg();
|
|
220
|
+
const interceptor = r.interceptors.get(action);
|
|
221
|
+
if (interceptor !== undefined) {
|
|
222
|
+
return executeHookHandler(interceptor, payload);
|
|
223
|
+
}
|
|
224
|
+
const command = r.commands.get(action);
|
|
225
|
+
if (command !== undefined) {
|
|
226
|
+
return executeHookHandler(command, payload);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return denied(`unknown hook action: ${action}`);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return denied(`bridge panic in astridHookTrigger: ${(e as Error).message ?? String(e)}`);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
run(): void {
|
|
236
|
+
try {
|
|
237
|
+
const r = reg();
|
|
238
|
+
if (r.runMethod === undefined) {
|
|
239
|
+
// Non-runnable capsule: WIT requires the export, but it must
|
|
240
|
+
// return immediately so the kernel doesn't think we're a daemon.
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Runnable capsule. Per Rust macro semantics, run() loads state but
|
|
244
|
+
// does NOT auto-persist (loops are infinite; explicit kv.set is
|
|
245
|
+
// the user's responsibility for runnable state).
|
|
246
|
+
const instance = loadInstance(r);
|
|
247
|
+
const raw = invoke(instance, r.runMethod, undefined);
|
|
248
|
+
// For a daemon-style loop, the handler should never resolve. If it
|
|
249
|
+
// does (or throws), surface via log and return.
|
|
250
|
+
if (raw instanceof Promise) {
|
|
251
|
+
syncWait(raw);
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
log.error(`run loop exited with error: ${(e as Error).message ?? String(e)}`);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
astridInstall(): void {
|
|
259
|
+
try {
|
|
260
|
+
const r = reg();
|
|
261
|
+
if (r.installMethod === undefined) return;
|
|
262
|
+
const instance = new r.ctor();
|
|
263
|
+
invoke(instance, r.installMethod, undefined);
|
|
264
|
+
persistInstance(instance);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
log.error(`install hook failed: ${(e as Error).message ?? String(e)}`);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
astridUpgrade(): void {
|
|
271
|
+
try {
|
|
272
|
+
const r = reg();
|
|
273
|
+
if (r.upgradeMethod === undefined) return;
|
|
274
|
+
const prevVersion = safeGetConfig("prev_version");
|
|
275
|
+
const instance = loadInstance(r);
|
|
276
|
+
invoke(instance, r.upgradeMethod, prevVersion);
|
|
277
|
+
persistInstance(instance);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
log.error(`upgrade hook failed: ${(e as Error).message ?? String(e)}`);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Call a registered method on an instance. The bridge always passes
|
|
287
|
+
* either zero or one argument; method signatures with more parameters
|
|
288
|
+
* are rejected at decorator time.
|
|
289
|
+
*/
|
|
290
|
+
function invoke(instance: object, methodName: string, arg: unknown): unknown {
|
|
291
|
+
const method = (instance as Record<string, unknown>)[methodName];
|
|
292
|
+
if (typeof method !== "function") {
|
|
293
|
+
throw new Error(`method ${methodName} not found on capsule instance`);
|
|
294
|
+
}
|
|
295
|
+
const raw = arg === undefined
|
|
296
|
+
? (method as Function).call(instance)
|
|
297
|
+
: (method as Function).call(instance, arg);
|
|
298
|
+
return raw instanceof Promise ? syncWait(raw) : raw;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function toolToDescribeEntry(entry: ToolEntry): Record<string, unknown> {
|
|
302
|
+
const schema =
|
|
303
|
+
entry.inputSchema ?? ({ type: "object", properties: {} } as Record<string, unknown>);
|
|
304
|
+
// Mirror schemars: `mutable` is a schema extension, not a top-level field.
|
|
305
|
+
const inputSchema: Record<string, unknown> = { ...schema, mutable: entry.mutable };
|
|
306
|
+
return {
|
|
307
|
+
name: entry.name,
|
|
308
|
+
description: entry.description ?? "",
|
|
309
|
+
input_schema: inputSchema,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function publishToolResult(name: string, callId: string, content: string, isError: boolean): void {
|
|
314
|
+
const topic = `tool.v1.execute.${name}.result`;
|
|
315
|
+
ipc.publishJson(topic, {
|
|
316
|
+
type: "tool_execute_result",
|
|
317
|
+
call_id: callId,
|
|
318
|
+
result: { call_id: callId, content, is_error: isError },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function publishToolError(name: string, callId: string, message: string): void {
|
|
323
|
+
publishToolResult(name, callId, message, true);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function stringifyResult(value: unknown): string {
|
|
327
|
+
if (typeof value === "string") return value;
|
|
328
|
+
if (value === undefined) return "null";
|
|
329
|
+
try {
|
|
330
|
+
return JSON.stringify(value);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
throw new Error(`failed to serialize tool result: ${(e as Error).message}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function safeGetConfig(key: string): string {
|
|
337
|
+
try {
|
|
338
|
+
return getConfig(key) ?? "";
|
|
339
|
+
} catch {
|
|
340
|
+
return "";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* StarlingMonkey runs the JS event loop to settle promises returned from
|
|
346
|
+
* exported functions before returning to the host. Sync handlers
|
|
347
|
+
* pass-through; async handlers whose promise hasn't resolved by the
|
|
348
|
+
* engine's microtask drain are surfaced as a clear error.
|
|
349
|
+
*/
|
|
350
|
+
function syncWait<T>(promise: Promise<T>): T {
|
|
351
|
+
let settled = false;
|
|
352
|
+
let value: T | undefined;
|
|
353
|
+
let error: unknown;
|
|
354
|
+
promise.then(
|
|
355
|
+
(v) => {
|
|
356
|
+
settled = true;
|
|
357
|
+
value = v;
|
|
358
|
+
},
|
|
359
|
+
(e) => {
|
|
360
|
+
settled = true;
|
|
361
|
+
error = e;
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
if (!settled) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
"Handler returned a Promise that did not settle synchronously. " +
|
|
367
|
+
"ComponentizeJS syncifies awaits backed by host imports it knows " +
|
|
368
|
+
"how to drive — pure setTimeout/setInterval will hang. Use only " +
|
|
369
|
+
"Astrid SDK calls inside handlers, or make the handler sync.",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (error !== undefined) throw error;
|
|
373
|
+
return value as T;
|
|
374
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createBridge, type Bridge, type CapsuleResult } from "./bridge.js";
|
|
2
|
+
export {
|
|
3
|
+
registerCapsule,
|
|
4
|
+
recordTool,
|
|
5
|
+
recordInstall,
|
|
6
|
+
recordUpgrade,
|
|
7
|
+
getRegistration,
|
|
8
|
+
__resetRegistry,
|
|
9
|
+
type CapsuleRegistration,
|
|
10
|
+
type ToolEntry,
|
|
11
|
+
} from "./registry.js";
|