agenttwin 0.1.0 → 0.2.1

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.
Files changed (121) hide show
  1. package/README.md +94 -24
  2. package/dist/api.js +712 -0
  3. package/dist/cli.js +5375 -57
  4. package/dist/dashboard/assets/brand.css +2 -0
  5. package/dist/dashboard/assets/dashboard/dashboard.css +189 -0
  6. package/dist/dashboard/assets/dashboard/dashboard.js +438 -0
  7. package/dist/dashboard/assets/dashboard/hosted.js +50 -0
  8. package/dist/dashboard/assets/dashboard/inventory.json +1 -0
  9. package/dist/dashboard/assets/dashboard/runs.json +1 -0
  10. package/dist/dashboard/assets/fonts/Syne-OFL.txt +93 -0
  11. package/dist/dashboard/assets/fonts/inter-var.woff2 +0 -0
  12. package/dist/dashboard/assets/fonts/plexmono-400.woff2 +0 -0
  13. package/dist/dashboard/assets/fonts/plexmono-500.woff2 +0 -0
  14. package/dist/dashboard/assets/fonts/syne-400.ttf +0 -0
  15. package/dist/dashboard/assets/fonts/syne-500.ttf +0 -0
  16. package/dist/dashboard/assets/fonts/syne-600.ttf +0 -0
  17. package/dist/dashboard/assets/icons/ATTRIBUTION.md +7 -0
  18. package/dist/dashboard/assets/icons/attio.ico +0 -0
  19. package/dist/dashboard/assets/icons/aws.svg +1 -0
  20. package/dist/dashboard/assets/icons/bank.svg +5 -0
  21. package/dist/dashboard/assets/icons/box.svg +1 -0
  22. package/dist/dashboard/assets/icons/chat.svg +3 -0
  23. package/dist/dashboard/assets/icons/check.png +0 -0
  24. package/dist/dashboard/assets/icons/ci.svg +5 -0
  25. package/dist/dashboard/assets/icons/clay.png +0 -0
  26. package/dist/dashboard/assets/icons/cloudflare.svg +1 -0
  27. package/dist/dashboard/assets/icons/co-abridge.png +0 -0
  28. package/dist/dashboard/assets/icons/co-clay.png +0 -0
  29. package/dist/dashboard/assets/icons/co-cognition.png +0 -0
  30. package/dist/dashboard/assets/icons/co-intercom.svg +1 -0
  31. package/dist/dashboard/assets/icons/co-paloalto.png +0 -0
  32. package/dist/dashboard/assets/icons/co-pylon.png +0 -0
  33. package/dist/dashboard/assets/icons/co-ramp.png +0 -0
  34. package/dist/dashboard/assets/icons/co-sentry.svg +1 -0
  35. package/dist/dashboard/assets/icons/codes.svg +5 -0
  36. package/dist/dashboard/assets/icons/datadog.svg +1 -0
  37. package/dist/dashboard/assets/icons/discord.svg +1 -0
  38. package/dist/dashboard/assets/icons/docker.svg +1 -0
  39. package/dist/dashboard/assets/icons/documenso.png +0 -0
  40. package/dist/dashboard/assets/icons/dropbox.svg +1 -0
  41. package/dist/dashboard/assets/icons/edr.svg +4 -0
  42. package/dist/dashboard/assets/icons/ehr.svg +5 -0
  43. package/dist/dashboard/assets/icons/git.svg +1 -0
  44. package/dist/dashboard/assets/icons/github.svg +1 -0
  45. package/dist/dashboard/assets/icons/githubactions.svg +1 -0
  46. package/dist/dashboard/assets/icons/gitlab.svg +1 -0
  47. package/dist/dashboard/assets/icons/gmail.svg +7 -0
  48. package/dist/dashboard/assets/icons/google-calendar.svg +28 -0
  49. package/dist/dashboard/assets/icons/google.svg +1 -0
  50. package/dist/dashboard/assets/icons/googlecloud.svg +1 -0
  51. package/dist/dashboard/assets/icons/googledocs.svg +1 -0
  52. package/dist/dashboard/assets/icons/googledrive.svg +1 -0
  53. package/dist/dashboard/assets/icons/googlesheets.svg +1 -0
  54. package/dist/dashboard/assets/icons/hubspot.svg +1 -0
  55. package/dist/dashboard/assets/icons/intercom.svg +1 -0
  56. package/dist/dashboard/assets/icons/jira.svg +1 -0
  57. package/dist/dashboard/assets/icons/klaviyo.png +0 -0
  58. package/dist/dashboard/assets/icons/kubernetes.svg +1 -0
  59. package/dist/dashboard/assets/icons/linear.svg +1 -0
  60. package/dist/dashboard/assets/icons/linkedin.svg +1 -0
  61. package/dist/dashboard/assets/icons/mail.svg +4 -0
  62. package/dist/dashboard/assets/icons/mixpanel.svg +1 -0
  63. package/dist/dashboard/assets/icons/mongodb.svg +1 -0
  64. package/dist/dashboard/assets/icons/netsuite.png +0 -0
  65. package/dist/dashboard/assets/icons/nodedotjs.svg +1 -0
  66. package/dist/dashboard/assets/icons/notes.svg +5 -0
  67. package/dist/dashboard/assets/icons/notion.svg +1 -0
  68. package/dist/dashboard/assets/icons/npm.svg +1 -0
  69. package/dist/dashboard/assets/icons/okta.svg +1 -0
  70. package/dist/dashboard/assets/icons/openai.svg +1 -0
  71. package/dist/dashboard/assets/icons/outlook.ico +0 -0
  72. package/dist/dashboard/assets/icons/pagerduty.svg +1 -0
  73. package/dist/dashboard/assets/icons/policy.svg +5 -0
  74. package/dist/dashboard/assets/icons/postgres.svg +1 -0
  75. package/dist/dashboard/assets/icons/python.svg +1 -0
  76. package/dist/dashboard/assets/icons/quickbooks.svg +1 -0
  77. package/dist/dashboard/assets/icons/redis.svg +1 -0
  78. package/dist/dashboard/assets/icons/resend.svg +1 -0
  79. package/dist/dashboard/assets/icons/salesforce.svg +1 -0
  80. package/dist/dashboard/assets/icons/sentry.svg +1 -0
  81. package/dist/dashboard/assets/icons/shopify.svg +1 -0
  82. package/dist/dashboard/assets/icons/siem.svg +4 -0
  83. package/dist/dashboard/assets/icons/slack.svg +6 -0
  84. package/dist/dashboard/assets/icons/snowflake.svg +1 -0
  85. package/dist/dashboard/assets/icons/sparkle.svg +4 -0
  86. package/dist/dashboard/assets/icons/stripe.svg +1 -0
  87. package/dist/dashboard/assets/icons/supabase.svg +1 -0
  88. package/dist/dashboard/assets/icons/terraform.svg +1 -0
  89. package/dist/dashboard/assets/icons/trolley.ico +0 -0
  90. package/dist/dashboard/assets/icons/twilio.svg +1 -0
  91. package/dist/dashboard/assets/icons/ui/arrow-right.svg +1 -0
  92. package/dist/dashboard/assets/icons/ui/arrows-clockwise.svg +1 -0
  93. package/dist/dashboard/assets/icons/ui/arrows-left-right.svg +1 -0
  94. package/dist/dashboard/assets/icons/ui/caret-down.svg +1 -0
  95. package/dist/dashboard/assets/icons/ui/check.svg +1 -0
  96. package/dist/dashboard/assets/icons/ui/clock.svg +1 -0
  97. package/dist/dashboard/assets/icons/ui/cube.svg +1 -0
  98. package/dist/dashboard/assets/icons/ui/git-branch.svg +1 -0
  99. package/dist/dashboard/assets/icons/ui/github-logo.svg +1 -0
  100. package/dist/dashboard/assets/icons/ui/magnifying-glass.svg +1 -0
  101. package/dist/dashboard/assets/icons/ui/plugs-connected.svg +1 -0
  102. package/dist/dashboard/assets/icons/ui/seal-check.svg +1 -0
  103. package/dist/dashboard/assets/icons/ui/shield-check.svg +1 -0
  104. package/dist/dashboard/assets/icons/ui/stack-simple.svg +1 -0
  105. package/dist/dashboard/assets/icons/ui/terminal.svg +1 -0
  106. package/dist/dashboard/assets/icons/ui/warning-circle.svg +1 -0
  107. package/dist/dashboard/assets/icons/ui/x.svg +1 -0
  108. package/dist/dashboard/assets/icons/unified.png +0 -0
  109. package/dist/dashboard/assets/icons/unified.svg +23 -0
  110. package/dist/dashboard/assets/icons/unstructured.svg +3 -0
  111. package/dist/dashboard/assets/icons/vercel.svg +1 -0
  112. package/dist/dashboard/assets/icons/waterfall.jpg +0 -0
  113. package/dist/dashboard/assets/icons/xero.svg +1 -0
  114. package/dist/dashboard/assets/logo/favicon.svg +7 -0
  115. package/dist/dashboard/assets/logo/mark-stacked-worlds.svg +4 -0
  116. package/dist/dashboard/assets/logo/mark-twin-rings.svg +7 -0
  117. package/dist/dashboard/assets/logo/mark-worlds-overlap.svg +10 -0
  118. package/dist/dashboard/index.html +63 -0
  119. package/dist/gateway.js +719 -0
  120. package/dist/worker.js +341 -0
  121. package/package.json +8 -3
package/dist/worker.js ADDED
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/runtime/worker.ts
4
+ import { readFile } from "fs/promises";
5
+ import { promisify } from "util";
6
+ import { execFile } from "child_process";
7
+ import { spawn } from "child_process";
8
+
9
+ // src/runtime/http-world.ts
10
+ import { createServer } from "http";
11
+ import { randomBytes } from "crypto";
12
+ var HttpWorld = class {
13
+ constructor(spec) {
14
+ this.spec = spec;
15
+ }
16
+ spec;
17
+ events = [];
18
+ state = /* @__PURE__ */ Object.create(null);
19
+ credentials = /* @__PURE__ */ Object.create(null);
20
+ unsupported = [];
21
+ servers = [];
22
+ timers = /* @__PURE__ */ new Set();
23
+ fired = false;
24
+ record(service, operation, path, detail) {
25
+ if (this.events.length >= 5e4) return;
26
+ this.events.push({ sequence: this.events.length + 1, service, operation, path, detail });
27
+ }
28
+ async start() {
29
+ for (const service of this.spec.services) {
30
+ const token = `at_world_${randomBytes(24).toString("hex")}`;
31
+ const data = /* @__PURE__ */ Object.create(null);
32
+ this.state[service.name] = data;
33
+ for (const c of service.collections) data[c.path] = structuredClone(c.seed);
34
+ const keys = /* @__PURE__ */ new Map();
35
+ const server = createServer(async (req, res) => {
36
+ const send = (code, body) => {
37
+ if (!res.destroyed) {
38
+ res.writeHead(code, { "content-type": "application/json" });
39
+ res.end(JSON.stringify(body));
40
+ }
41
+ };
42
+ try {
43
+ if (req.headers.authorization !== `Bearer ${token}`) {
44
+ this.record(service.name, "auth-denied", req.url ?? "", "invalid test credential");
45
+ send(401, { error: "invalid_test_credential" });
46
+ return;
47
+ }
48
+ if (this.events.length >= 5e4 || this.unsupported.length >= 1e3) {
49
+ send(429, { error: "world_capacity" });
50
+ return;
51
+ }
52
+ const url = new URL(req.url ?? "/", "http://localhost");
53
+ let path = url.pathname;
54
+ const slack = service.adapter === "slack";
55
+ const slackMethod = path.replace(/^\/api\//, "");
56
+ let payload;
57
+ if (req.method === "POST") {
58
+ const chunks = [];
59
+ let bytes = 0;
60
+ for await (const chunk of req) {
61
+ bytes += chunk.length;
62
+ if (bytes > 1024 * 1024) {
63
+ send(413, { error: "body_too_large" });
64
+ return;
65
+ }
66
+ chunks.push(Buffer.from(chunk));
67
+ }
68
+ const text = Buffer.concat(chunks).toString("utf8");
69
+ let parsed;
70
+ try {
71
+ parsed = slack && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
72
+ } catch {
73
+ send(400, { error: "expected_json_object" });
74
+ return;
75
+ }
76
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
77
+ send(400, { error: "expected_json_object" });
78
+ return;
79
+ }
80
+ payload = parsed;
81
+ }
82
+ if (slack) {
83
+ const params = payload ?? Object.fromEntries(url.searchParams);
84
+ const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
85
+ if (Object.keys(params).some((k) => !allowed.includes(k))) {
86
+ this.unsupported.push(`${service.name}: unsupported parameters for ${slackMethod}`);
87
+ send(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
88
+ return;
89
+ }
90
+ if (slackMethod === "chat.postMessage" && req.method === "POST") path = "/messages";
91
+ else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path = "/messages";
92
+ else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path = "/channels";
93
+ else {
94
+ this.unsupported.push(`${service.name}: ${req.method} ${url.pathname}`);
95
+ send(501, { ok: false, error: "unsupported_operation" });
96
+ return;
97
+ }
98
+ }
99
+ if (!Object.hasOwn(data, path) || !["GET", "POST"].includes(req.method ?? "")) {
100
+ this.unsupported.push(`${service.name}: ${req.method} ${path}`);
101
+ send(501, { error: "unsupported_operation", forwardedToProduction: false });
102
+ return;
103
+ }
104
+ if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
105
+ this.record(service.name, "read", path, `${data[path].length} records`);
106
+ if (slack && path === "/channels") send(200, { ok: true, channels: data[path], response_metadata: { next_cursor: "" } });
107
+ else if (slack) {
108
+ const channel = payload?.channel ?? url.searchParams.get("channel");
109
+ if (!data["/channels"].some((c) => c.id === channel)) send(200, { ok: false, error: "channel_not_found" });
110
+ else send(200, { ok: true, messages: data[path].filter((m) => m.channel === channel), has_more: false });
111
+ } else send(200, { data: data[path] });
112
+ return;
113
+ }
114
+ const body = payload;
115
+ if (slack) {
116
+ const message = body;
117
+ if (!data["/channels"].some((c) => c.id === message.channel)) {
118
+ send(200, { ok: false, error: "channel_not_found" });
119
+ return;
120
+ }
121
+ if (typeof message.text !== "string" || !message.text) {
122
+ send(200, { ok: false, error: "no_text" });
123
+ return;
124
+ }
125
+ }
126
+ if (data[path].length >= 1e4 || this.events.length >= 5e4) {
127
+ send(429, { error: "world_capacity" });
128
+ return;
129
+ }
130
+ const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
131
+ const key = typeof keyHeader === "string" ? `${path}:${keyHeader}` : void 0;
132
+ const signature = JSON.stringify(body);
133
+ const previous = key ? keys.get(key) : void 0;
134
+ if (previous) {
135
+ if (previous.body !== signature) {
136
+ send(409, { error: "idempotency_conflict" });
137
+ return;
138
+ }
139
+ this.record(service.name, "replay", path, "same-key response replay");
140
+ send(200, previous.record);
141
+ return;
142
+ }
143
+ let index = data[path].length + 1;
144
+ while (data[path].some((r) => r.id === `${service.name}_${index}`)) index++;
145
+ const row = { ...body, id: `${service.name}_${index}` };
146
+ if (slack) row.ts = `${index}.000001`;
147
+ const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
148
+ data[path].push(row);
149
+ if (key) keys.set(key, { body: signature, record: row });
150
+ this.record(service.name, "write", path, String(row.id));
151
+ const fault = this.spec.fault;
152
+ if (!this.fired && fault?.service === service.name && fault.path === path) {
153
+ this.fired = true;
154
+ this.record(service.name, "fault", path, `write committed; response delayed ${fault.delayMs}ms`);
155
+ const timer = setTimeout(() => {
156
+ this.timers.delete(timer);
157
+ send(slack ? 200 : 201, reply);
158
+ }, fault.delayMs);
159
+ this.timers.add(timer);
160
+ } else send(slack ? 200 : 201, reply);
161
+ } catch {
162
+ send(500, { error: "world_request_error" });
163
+ }
164
+ });
165
+ server.requestTimeout = 1e4;
166
+ await new Promise((resolve, reject) => {
167
+ server.once("error", reject);
168
+ server.listen(0, "127.0.0.1", resolve);
169
+ });
170
+ this.servers.push(server);
171
+ this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
172
+ }
173
+ return this.credentials;
174
+ }
175
+ assertions() {
176
+ return this.spec.assertions.map((a) => {
177
+ const rows = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) => JSON.stringify(row[k]) === JSON.stringify(v)));
178
+ return { ...a, actual: rows.length, passed: rows.length === a.count };
179
+ });
180
+ }
181
+ get faultTriggered() {
182
+ return this.fired;
183
+ }
184
+ async close() {
185
+ for (const timer of this.timers) clearTimeout(timer);
186
+ this.timers.clear();
187
+ await Promise.all(this.servers.map((server) => new Promise((resolve) => {
188
+ server.close(() => resolve());
189
+ server.closeAllConnections();
190
+ })));
191
+ }
192
+ };
193
+
194
+ // src/runtime/spec.ts
195
+ var object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
196
+ var namePattern = /^[a-z][a-z0-9_]{0,39}$/;
197
+ function parseWorld(value) {
198
+ const invalid = (message) => {
199
+ throw new Error(`Invalid world: ${message}`);
200
+ };
201
+ if (!object(value) || value.schema !== "agenttwin/world-v1") invalid("schema must be agenttwin/world-v1");
202
+ const v = value;
203
+ if (typeof v.name !== "string" || !namePattern.test(v.name)) invalid("name must be lowercase letters, digits or underscores");
204
+ if (!Array.isArray(v.files) || !v.files.length || v.files.length > 100 || !v.files.every((f) => typeof f === "string" && f.length > 0)) invalid("files must explicitly list 1\u2013100 application files");
205
+ if (new Set(v.files).size !== v.files.length) invalid("files must be unique");
206
+ if (!Array.isArray(v.command) || !v.command.length || !v.command.every((c) => typeof c === "string" && c.length > 0) || v.command[0] !== "node") invalid("command must be an argv array starting with node; supply a bundled Node application");
207
+ if (!Number.isInteger(v.timeoutSeconds) || Number(v.timeoutSeconds) < 1 || Number(v.timeoutSeconds) > 300) invalid("timeoutSeconds must be 1\u2013300");
208
+ if (v.workflowId !== void 0 && (typeof v.workflowId !== "string" || !v.workflowId)) invalid("workflowId must be a nonempty string");
209
+ if (!Array.isArray(v.services) || !v.services.length || v.services.length > 10) invalid("services must contain 1\u201310 HTTP services");
210
+ const services = /* @__PURE__ */ new Map();
211
+ for (const service of v.services) {
212
+ if (!object(service) || typeof service.name !== "string" || !namePattern.test(service.name) || services.has(service.name)) invalid("service names must be unique lowercase identifiers");
213
+ const item = service;
214
+ if (!Array.isArray(item.collections) || !item.collections.length || item.collections.length > 20) invalid("each service needs 1\u201320 collections");
215
+ if (item.adapter !== void 0 && !["json-collection", "slack"].includes(String(item.adapter))) invalid("unsupported service adapter");
216
+ const paths = /* @__PURE__ */ new Set();
217
+ for (const entry of item.collections) {
218
+ if (!object(entry) || typeof entry.path !== "string" || !/^\/[a-zA-Z0-9_/-]+$/.test(entry.path) || entry.path.includes("//") || paths.has(entry.path)) invalid("collection paths must be unique absolute HTTP paths");
219
+ const collection = entry;
220
+ if (!Array.isArray(collection.seed) || collection.seed.length > 1e3 || !collection.seed.every(object)) invalid("seed must be an array of up to 1000 JSON records");
221
+ paths.add(collection.path);
222
+ }
223
+ if (item.adapter === "slack" && (!paths.has("/messages") || !paths.has("/channels") || paths.size !== 2)) invalid("slack adapter requires /messages and /channels collections");
224
+ services.set(item.name, paths);
225
+ }
226
+ const known = (entry) => typeof entry.service === "string" && typeof entry.path === "string" && services.get(entry.service)?.has(entry.path);
227
+ if (!Array.isArray(v.assertions) || !v.assertions.length || v.assertions.length > 100) invalid("at least one final-state assertion is required (maximum 100)");
228
+ for (const assertion of v.assertions) {
229
+ if (!object(assertion) || !known(assertion) || !Number.isSafeInteger(assertion.count) || Number(assertion.count) < 0 || assertion.where !== void 0 && !object(assertion.where)) invalid("assertions need an existing service/path, nonnegative count, and optional where object");
230
+ }
231
+ if (v.postgres !== void 0) {
232
+ if (!object(v.postgres) || v.postgres.seedFile !== void 0 && !v.files.includes(String(v.postgres.seedFile)) || !Array.isArray(v.postgres.assertions) || !v.postgres.assertions.length || v.postgres.assertions.length > 100) invalid("postgres requires assertions and seedFile must be listed in files");
233
+ for (const a of v.postgres.assertions) {
234
+ if (!object(a) || typeof a.query !== "string" || !/^select\s/i.test(a.query) || a.query.includes(";") || !Array.isArray(a.equals) || !a.equals.every(object)) invalid("postgres assertions require a SELECT query without semicolons and an equals array");
235
+ }
236
+ }
237
+ if (v.redis !== void 0) {
238
+ if (!object(v.redis) || !Array.isArray(v.redis.assertions) || !v.redis.assertions.length || v.redis.assertions.length > 100) invalid("redis requires assertions");
239
+ for (const a of v.redis.assertions) {
240
+ if (!object(a) || typeof a.key !== "string" || !a.key || a.equals !== null && typeof a.equals !== "string") invalid("redis assertions require key and string/null equals");
241
+ }
242
+ }
243
+ if (v.fault !== void 0 && (!object(v.fault) || !known(v.fault) || v.fault.type !== "timeout-after-write" || !Number.isInteger(v.fault.delayMs) || Number(v.fault.delayMs) < 1 || Number(v.fault.delayMs) > 1e4)) invalid("fault requires an existing service/path and delayMs 1\u201310000");
244
+ const keys = ["schema", "name", "workflowId", "files", "command", "timeoutSeconds", "services", "assertions", "postgres", "redis", "fault"];
245
+ if (Object.keys(v).some((k) => !keys.includes(k))) invalid("unknown world field");
246
+ return v;
247
+ }
248
+
249
+ // src/runtime/worker.ts
250
+ var secrets = [];
251
+ async function main() {
252
+ const spec = parseWorld(JSON.parse(await readFile("/input/world.json", "utf8")));
253
+ const world = new HttpWorld(spec);
254
+ const started = Date.now();
255
+ try {
256
+ const credentials = await world.start();
257
+ secrets.push(...Object.values(credentials).map((c) => c.token));
258
+ const runtimeEnv = JSON.parse(await readFile("/input/runtime.json", "utf8"));
259
+ secrets.push(...Object.values(runtimeEnv));
260
+ const env = { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: "/tmp", TMPDIR: "/tmp", NODE_ENV: "test", ...runtimeEnv };
261
+ for (const [name, credential] of Object.entries(credentials)) {
262
+ env[`AGENTTWIN_${name.toUpperCase()}_URL`] = credential.endpoint;
263
+ env[`AGENTTWIN_${name.toUpperCase()}_TOKEN`] = credential.token;
264
+ }
265
+ let stdout = "", stderr = "", timedOut = false;
266
+ const child = spawn(spec.command[0], spec.command.slice(1), { cwd: "/input/app", env, stdio: ["ignore", "pipe", "pipe"], detached: true, uid: 1e3, gid: 1e3 });
267
+ child.stdout.on("data", (b) => {
268
+ stdout = (stdout + b.toString()).slice(-65536);
269
+ });
270
+ child.stderr.on("data", (b) => {
271
+ stderr = (stderr + b.toString()).slice(-65536);
272
+ });
273
+ const kill = () => {
274
+ if (child.pid) {
275
+ try {
276
+ process.kill(-child.pid, "SIGKILL");
277
+ } catch {
278
+ }
279
+ }
280
+ };
281
+ const timer = setTimeout(() => {
282
+ timedOut = true;
283
+ kill();
284
+ }, spec.timeoutSeconds * 1e3);
285
+ const exitCode = await new Promise((resolve, reject) => {
286
+ child.once("error", reject);
287
+ child.once("exit", resolve);
288
+ }).finally(() => {
289
+ clearTimeout(timer);
290
+ kill();
291
+ });
292
+ await world.close();
293
+ const assertions = world.assertions();
294
+ const infrastructureAssertions = [];
295
+ for (const assertion of spec.postgres?.assertions ?? []) {
296
+ const { stdout: output } = await promisify(execFile)("psql", [runtimeEnv.DATABASE_URL, "-qAt", "-v", "ON_ERROR_STOP=1", "-c", "BEGIN READ ONLY", "-c", `SELECT COALESCE(json_agg(t), '[]'::json) FROM (${assertion.query}) t`, "-c", "COMMIT"], { env: { ...env, PGOPTIONS: "-c statement_timeout=5000" }, timeout: 7e3 });
297
+ const actual = JSON.parse(output.trim());
298
+ infrastructureAssertions.push({ service: "postgres", query: assertion.query, expected: assertion.equals, actual, passed: JSON.stringify(actual) === JSON.stringify(assertion.equals) });
299
+ }
300
+ for (const assertion of spec.redis?.assertions ?? []) {
301
+ const { stdout: exists } = await promisify(execFile)("redis-cli", ["--raw", "EXISTS", assertion.key], { env, timeout: 5e3 });
302
+ const { stdout: output } = await promisify(execFile)("redis-cli", ["--raw", "GET", assertion.key], { env, timeout: 5e3 });
303
+ const actual = exists.trim() === "0" ? null : output.replace(/\n$/, "");
304
+ infrastructureAssertions.push({ service: "redis", key: assertion.key, expected: assertion.equals, actual, passed: actual === assertion.equals });
305
+ }
306
+ const status = world.unsupported.length || spec.fault && !world.faultTriggered ? "incomplete" : timedOut || exitCode !== 0 ? "error" : [...assertions, ...infrastructureAssertions].every((a) => a.passed) ? "passed" : "failed";
307
+ for (const c of Object.values(credentials)) {
308
+ stdout = stdout.split(c.token).join("[test-token]");
309
+ stderr = stderr.split(c.token).join("[test-token]");
310
+ }
311
+ for (const secret of Object.values(runtimeEnv)) {
312
+ stdout = stdout.split(secret).join("[test-credential]");
313
+ stderr = stderr.split(secret).join("[test-credential]");
314
+ }
315
+ process.stdout.write(JSON.stringify({
316
+ schema: "agenttwin/run-v1",
317
+ name: spec.name,
318
+ workflowId: spec.workflowId,
319
+ status,
320
+ application: { command: spec.command, exitCode, timedOut, stdout, stderr },
321
+ assertions,
322
+ infrastructureAssertions,
323
+ fault: spec.fault ? { ...spec.fault, triggered: world.faultTriggered } : null,
324
+ initialState: Object.fromEntries(spec.services.map((s) => [s.name, Object.fromEntries(s.collections.map((c) => [c.path, c.seed]))])),
325
+ finalState: world.state,
326
+ events: world.events,
327
+ unsupported: world.unsupported,
328
+ durationMs: Date.now() - started,
329
+ coverage: "JSON collections: GET/list, POST/create, idempotency replay. Slack subset (JSON/form): chat.postMessage, conversations.list/history (no OAuth, pagination or provider parity claim). Optional real PostgreSQL 15 / Redis with explicit final-state checks."
330
+ }) + "\n");
331
+ } finally {
332
+ await world.close();
333
+ }
334
+ }
335
+ main().catch((error) => {
336
+ let message = String(error);
337
+ for (const secret of secrets) message = message.split(secret).join("[test-credential]");
338
+ process.stderr.write(`World runtime failed: ${message}
339
+ `);
340
+ process.exitCode = 1;
341
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenttwin",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Give your agent a test world before it touches the real one.",
5
5
  "keywords": [
6
6
  "agent",
@@ -21,7 +21,7 @@
21
21
  "LICENSE"
22
22
  ],
23
23
  "engines": {
24
- "node": ">=18"
24
+ "node": ">=22"
25
25
  },
26
26
  "license": "MIT",
27
27
  "scripts": {
@@ -29,11 +29,16 @@
29
29
  "dev": "tsx src/cli.ts",
30
30
  "typecheck": "tsc --noEmit",
31
31
  "test": "vitest run",
32
- "prepublishOnly": "npm run build && npm test"
32
+ "prepublishOnly": "npm run build && npm test",
33
+ "start:api": "node dist/gateway.js"
33
34
  },
34
35
  "publishConfig": {
35
36
  "access": "public"
36
37
  },
38
+ "dependencies": {
39
+ "e2b": "^2.52.1",
40
+ "yaml": "^2.9.1"
41
+ },
37
42
  "devDependencies": {
38
43
  "@types/node": "^26.6.4",
39
44
  "tsup": "^8.5.1",