agenttwin 0.3.6 → 0.3.8
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 +17 -6
- package/dist/api.js +3363 -313
- package/dist/cli.js +5851 -798
- package/dist/dashboard/assets/dashboard/agent-home.css +130 -0
- package/dist/dashboard/assets/dashboard/golden-gate-portrait.png +0 -0
- package/dist/dashboard/assets/next/static/2g265p4WMG_nO2mZLqMm6/_buildManifest.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/{4bd1b696-c023c6e3521b1417.js → 08ffc717-8762425cc415d63e.js} +1 -1
- package/dist/dashboard/assets/next/static/chunks/{772-85561381eedc95da.js → 432-c62d9b48a7d6ccab.js} +2 -2
- package/dist/dashboard/assets/next/static/chunks/49-1030db7fbea7040f.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/569-40122b1284f67d6a.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/763-591b8154b93c62c9.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/_not-found/page-b98766fc366ca9f1.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/account/password/page-233a9ebc76252642.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-61b8b3be0e794aac.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-00f7cd91ede8c855.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/demo/page-8aa153465b6db0ff.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/docs/[[...slug]]/page-4fa9f4e9dd4f6dad.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/enterprise/page-d7050946aac756a5.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/layout-6e927a2292a84bf6.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/login/page-e6294a5f57d54f5b.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/onboarding/page-5ec79ea6cfd7cd2d.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/page-ca3b8f68fccf45b1.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/pricing/page-586a8fca800d528e.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/signup/page-e6294a5f57d54f5b.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/sitemap.xml/route-a1876335f52de3bc.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/layout-dd08f4b1d4f23f97.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/dashboard/page-5afc46f2b00b2680.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/layout-c87340f4ff137c46.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/login/page-6907e7e63a858618.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/onboarding/page-12bc7303ea36169c.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/page-0e2e02a3fde43e92.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/app/v2/signup/page-6907e7e63a858618.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/framework-9306c712dbc33cd6.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/main-54d795e329dc7419.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/main-app-1b876317815c7f42.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/pages/_app-d27c16deeb41f43e.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/pages/_error-e7f3ba1f3701e7a7.js +1 -0
- package/dist/dashboard/assets/next/static/chunks/webpack-a79d1eff6c08383c.js +1 -0
- package/dist/dashboard/assets/next/static/css/138b575a572b2958.css +1 -0
- package/dist/dashboard/assets/next/static/css/1f0bd0855bbd1a14.css +1 -0
- package/dist/dashboard/assets/next/static/css/311920319a51fc90.css +1 -0
- package/dist/dashboard/assets/next/static/css/32d869ddee6d2dc1.css +1 -0
- package/dist/dashboard/assets/next/static/css/3f003235629d65a0.css +1 -0
- package/dist/dashboard/assets/next/static/css/4af3c8ca0da0f48b.css +1 -0
- package/dist/dashboard/assets/next/static/css/4fa51ab33fbc7e1b.css +1 -0
- package/dist/dashboard/assets/next/static/css/556c40e9d291ee02.css +1 -0
- package/dist/dashboard/assets/next/static/css/807e07644631d8e2.css +1 -0
- package/dist/dashboard/assets/next/static/css/97d686ac80c4018f.css +1 -0
- package/dist/dashboard/assets/next/static/css/c1117da59daa6ba9.css +1 -0
- package/dist/dashboard/assets/next/static/css/d99eecb7633d1200.css +1 -0
- package/dist/dashboard/assets/next/static/css/ec9722ff890f136c.css +1 -0
- package/dist/dashboard/assets/next/static/css/f9c2780fff15fd22.css +1 -0
- package/dist/dashboard/assets/next/static/media/golden-gate-portrait.7941528f.png +0 -0
- package/dist/dashboard/index.html +16 -1
- package/dist/gateway.js +3405 -306
- package/dist/worker.js +1701 -53
- package/dist/world-host.js +2442 -0
- package/package.json +6 -1
- package/dist/dashboard/assets/next/static/HZwGKKvvx_oO6caYggjFh/_buildManifest.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/255-09fb0d129bb12f51.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/_not-found/page-5dc6a60918e8652d.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/layout-74603c2c8eaf0760.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/dashboard/page-b79ed135f6bac5f7.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/demo/page-760a47a5e4dee43f.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/docs/[[...slug]]/page-80a0c2c82d5b4efd.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/layout-1a40c59e535513d4.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/page-de4735052adab906.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/pricing/page-7703fc31d6dff0f7.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/app/sitemap.xml/route-ef5a216a578eb7a8.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/framework-292291387d6b2e39.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/main-aea6e71289384293.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/main-app-99295a8cc37e8a47.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/pages/_app-7d307437aca18ad4.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/pages/_error-cb2a52f75f2162e2.js +0 -1
- package/dist/dashboard/assets/next/static/chunks/webpack-f3b6dd27998e84ce.js +0 -1
- package/dist/dashboard/assets/next/static/css/1323d7da89b3fa5e.css +0 -1
- package/dist/dashboard/assets/next/static/css/9282330a2fd1ab73.css +0 -1
- package/dist/dashboard/assets/next/static/css/96e4f356a1159071.css +0 -1
- /package/dist/dashboard/assets/next/static/{HZwGKKvvx_oO6caYggjFh → 2g265p4WMG_nO2mZLqMm6}/_ssgManifest.js +0 -0
package/dist/worker.js
CHANGED
|
@@ -8,45 +8,1144 @@ import { spawn } from "child_process";
|
|
|
8
8
|
|
|
9
9
|
// src/runtime/http-world.ts
|
|
10
10
|
import { createServer } from "http";
|
|
11
|
-
import {
|
|
11
|
+
import { isDeepStrictEqual } from "util";
|
|
12
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
13
|
+
|
|
14
|
+
// src/runtime/request-source.ts
|
|
15
|
+
import { mkdtemp, writeFile, rm, realpath } from "fs/promises";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { join, resolve } from "path";
|
|
18
|
+
function parseRequestSource(raw, spec) {
|
|
19
|
+
if (typeof raw !== "string" || raw.length > 1024) return;
|
|
20
|
+
try {
|
|
21
|
+
const source = JSON.parse(raw);
|
|
22
|
+
if (!source || typeof source.file !== "string" || !spec.files.includes(source.file) || !Number.isSafeInteger(source.line) || source.line < 1 || source.line > 1e6 || !Number.isSafeInteger(source.column) || source.column < 1 || source.column > 1e6) return;
|
|
23
|
+
return { file: source.file, line: source.line, column: source.column, provenance: "application-reported" };
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/runtime/stripe-adapter.ts
|
|
30
|
+
function stripeOperation(method, path, params, data, sequence) {
|
|
31
|
+
const error = (status, code, message, param) => ({ status, body: { error: { type: "invalid_request_error", code, message, ...param ? { param } : {} } } });
|
|
32
|
+
const match = /^\/v1\/(customers|payment_intents)(?:\/([^/]+))?(?:\/(confirm))?$/.exec(path);
|
|
33
|
+
if (!match || !["GET", "POST"].includes(method)) return void 0;
|
|
34
|
+
const [, resource, id, action] = match, collection = `/v1/${resource}`, rows2 = data[collection];
|
|
35
|
+
const allowed = method === "GET" ? id ? [] : ["limit", "starting_after"] : resource === "customers" && !id ? ["email", "name", "description"] : resource === "payment_intents" && !id ? ["amount", "currency", "customer", "payment_method", "confirm"] : action === "confirm" ? ["payment_method"] : void 0;
|
|
36
|
+
if (!allowed || resource === "customers" && action || resource === "payment_intents" && method === "GET" && !id) return void 0;
|
|
37
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
38
|
+
if (method === "GET" && !id) {
|
|
39
|
+
const limit = params.limit === void 0 ? 10 : Number(params.limit);
|
|
40
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) return { ...error(400, "parameter_invalid_integer", "limit must be 1\u2013100", "limit"), collection };
|
|
41
|
+
const ordered = [...rows2].reverse();
|
|
42
|
+
const index = params.starting_after === void 0 ? -1 : ordered.findIndex((r) => r.id === params.starting_after);
|
|
43
|
+
if (params.starting_after !== void 0 && index === -1) return { ...error(400, "resource_missing", "No such starting_after customer", "starting_after"), collection };
|
|
44
|
+
const page = ordered.slice(index + 1, index + 1 + limit);
|
|
45
|
+
return { status: 200, body: { object: "list", data: page, has_more: ordered.length > index + 1 + limit, url: collection }, collection, operation: "read" };
|
|
46
|
+
}
|
|
47
|
+
const row = id ? rows2.find((r) => r.id === id) : void 0;
|
|
48
|
+
if (id && !row) return { ...error(404, "resource_missing", `No such ${resource}: '${id}'`), collection };
|
|
49
|
+
if (method === "GET") return { status: 200, body: row, collection, operation: "read" };
|
|
50
|
+
if (action === "confirm") {
|
|
51
|
+
if (row.status === "succeeded") return { status: 200, body: row, collection, operation: "replay" };
|
|
52
|
+
const payment = params.payment_method ?? row.payment_method;
|
|
53
|
+
if (payment !== "pm_card_visa" && payment !== "pm_card_chargeDeclined") return { ...error(400, "parameter_invalid", "Only pm_card_visa and pm_card_chargeDeclined are supported", "payment_method"), collection };
|
|
54
|
+
row.payment_method = payment;
|
|
55
|
+
row.status = payment === "pm_card_visa" ? "succeeded" : "requires_payment_method";
|
|
56
|
+
row.last_payment_error = payment === "pm_card_chargeDeclined" ? { code: "card_declined", decline_code: "generic_decline", message: "Your card was declined." } : null;
|
|
57
|
+
return { status: payment === "pm_card_chargeDeclined" ? 402 : 200, body: payment === "pm_card_chargeDeclined" ? { error: { type: "card_error", code: "card_declined", decline_code: "generic_decline", message: "Your card was declined.", payment_intent: structuredClone(row) } } : row, collection, record: structuredClone(row), operation: "write" };
|
|
58
|
+
}
|
|
59
|
+
const prefix = resource === "customers" ? "cus" : "pi";
|
|
60
|
+
let suffix = sequence;
|
|
61
|
+
while (rows2.some((r) => r.id === `${prefix}_at_${suffix}`)) suffix++;
|
|
62
|
+
const created2 = { id: `${prefix}_at_${suffix}`, object: resource === "customers" ? "customer" : "payment_intent", created: 17e8, livemode: false };
|
|
63
|
+
if (resource === "customers") {
|
|
64
|
+
if (params.email !== void 0 && typeof params.email !== "string") return { ...error(400, "parameter_invalid", "email must be a string", "email"), collection };
|
|
65
|
+
Object.assign(created2, { email: null, name: null, description: null, ...params });
|
|
66
|
+
} else {
|
|
67
|
+
const amount = Number(params.amount);
|
|
68
|
+
if (!Number.isSafeInteger(amount) || amount < 1) return { ...error(400, "parameter_invalid_integer", "amount must be a positive integer", "amount"), collection };
|
|
69
|
+
if (typeof params.currency !== "string" || !/^[a-z]{3}$/.test(params.currency)) return { ...error(400, "parameter_invalid", "currency must be a lowercase three-letter code", "currency"), collection };
|
|
70
|
+
if (params.customer && !data["/v1/customers"].some((r) => r.id === params.customer)) return { ...error(400, "resource_missing", "No such customer", "customer"), collection };
|
|
71
|
+
if (params.payment_method !== void 0 && !["pm_card_visa", "pm_card_chargeDeclined"].includes(String(params.payment_method))) return { ...error(400, "parameter_invalid", "Unsupported test payment method", "payment_method"), collection };
|
|
72
|
+
if (params.confirm !== void 0 && !["true", "false", true, false].includes(params.confirm)) return { ...error(400, "parameter_invalid", "confirm must be boolean", "confirm"), collection };
|
|
73
|
+
const confirm = params.confirm === "true" || params.confirm === true;
|
|
74
|
+
if (confirm && !params.payment_method) return { ...error(400, "parameter_missing", "Missing payment_method", "payment_method"), collection };
|
|
75
|
+
Object.assign(created2, { amount, currency: params.currency, customer: params.customer ?? null, payment_method: params.payment_method ?? null, status: confirm && params.payment_method === "pm_card_visa" ? "succeeded" : !confirm && params.payment_method ? "requires_confirmation" : "requires_payment_method", last_payment_error: confirm && params.payment_method === "pm_card_chargeDeclined" ? { code: "card_declined", message: "Your card was declined." } : null });
|
|
76
|
+
}
|
|
77
|
+
if (rows2.length >= 1e4) return { ...error(429, "world_capacity", "World record capacity reached"), collection };
|
|
78
|
+
rows2.push(created2);
|
|
79
|
+
const declined = resource === "payment_intents" && (params.confirm === "true" || params.confirm === true) && params.payment_method === "pm_card_chargeDeclined";
|
|
80
|
+
return { status: declined ? 402 : 200, body: declined ? { error: { type: "card_error", code: "card_declined", decline_code: "generic_decline", message: "Your card was declined.", payment_intent: structuredClone(created2) } } : created2, collection, record: structuredClone(created2), operation: "write" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/runtime/rest-adapters.ts
|
|
84
|
+
import { createHash } from "crypto";
|
|
85
|
+
var EPOCH = 17e8;
|
|
86
|
+
var iso = (epoch = EPOCH) => new Date(epoch * 1e3).toISOString();
|
|
87
|
+
var sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
88
|
+
var detUuid = (seed) => {
|
|
89
|
+
const hex = sha(seed).slice(0, 32).split("");
|
|
90
|
+
hex[12] = "4";
|
|
91
|
+
hex[16] = "8";
|
|
92
|
+
const s = hex.join("");
|
|
93
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
|
|
94
|
+
};
|
|
95
|
+
var rows = (data, collection) => data[collection];
|
|
96
|
+
var notFound = (collection, body) => ({ status: 404, body, collection });
|
|
97
|
+
var badRequest = (collection, body) => ({ status: 400, body, collection });
|
|
98
|
+
var ok = (collection, body, operation = "read") => ({ status: 200, body, collection, operation });
|
|
99
|
+
var created = (collection, body, record) => ({ status: 201, body, collection, record: structuredClone(record), operation: "write" });
|
|
100
|
+
var write = (status, collection, body, record) => ({ status, body, collection, record: structuredClone(record), operation: "write" });
|
|
101
|
+
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
102
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
103
|
+
var pagination = (params, limitDefault, limitMax) => {
|
|
104
|
+
if (params.limit !== void 0 && (!Number.isInteger(Number(params.limit)) || Number(params.limit) < 1 || Number(params.limit) > limitMax)) return void 0;
|
|
105
|
+
const limit = params.limit === void 0 ? limitDefault : Number(params.limit);
|
|
106
|
+
return { limit };
|
|
107
|
+
};
|
|
108
|
+
var uniqueId = (existing, base, key = "id") => {
|
|
109
|
+
let candidate = base;
|
|
110
|
+
while (existing.some((r) => r[key] === String(candidate))) candidate++;
|
|
111
|
+
return String(candidate);
|
|
112
|
+
};
|
|
113
|
+
var boundedInt = (value, min, max) => {
|
|
114
|
+
if (value === void 0) return void 0;
|
|
115
|
+
const n = typeof value === "number" ? value : typeof value === "string" && /^-?\d+$/.test(value) ? Number(value) : NaN;
|
|
116
|
+
return Number.isInteger(n) && n >= min && n <= max ? n : null;
|
|
117
|
+
};
|
|
118
|
+
function githubOperation(method, path, params, _headers, data, sequence) {
|
|
119
|
+
const errors = (status, message, extra) => ({ status, body: { message, documentation_url: "https://docs.github.com/rest", ...extra ?? {} }, collection: "/repos" });
|
|
120
|
+
if (path === "/repos") return method === "GET" ? ok("/repos", rows(data, "/repos").map((row) => structuredClone(row))) : void 0;
|
|
121
|
+
const match = /^\/repos\/([^/]+)\/([^/]+)(?:\/(issues|pulls|check-runs)(?:\/(\d+))?(?:\/(comments|merge))?)?$/.exec(path);
|
|
122
|
+
if (!match) return void 0;
|
|
123
|
+
const [, owner, name, resource, id, sub] = match;
|
|
124
|
+
const repo = `${owner}/${name}`;
|
|
125
|
+
if (!rows(data, "/repos").some((r) => r.name === repo || r.full_name === repo)) return notFound("/repos", { message: "Not Found", documentation_url: "https://docs.github.com/rest" });
|
|
126
|
+
if (!resource) {
|
|
127
|
+
if (method !== "GET") return void 0;
|
|
128
|
+
const repository = rows(data, "/repos").find((r) => r.name === repo || r.full_name === repo);
|
|
129
|
+
return ok("/repos", structuredClone(repository));
|
|
130
|
+
}
|
|
131
|
+
if (resource === "check-runs") {
|
|
132
|
+
if (method !== "POST" || id) return void 0;
|
|
133
|
+
if (typeof params.name !== "string" || !params.name || typeof params.head_sha !== "string" || !params.head_sha) return errors(422, "Validation Failed", { errors: [{ resource: "CheckRun", field: "name", code: "missing_field" }] });
|
|
134
|
+
const allowed = ["name", "head_sha", "status", "conclusion"];
|
|
135
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
136
|
+
if (params.status !== void 0 && !["queued", "in_progress", "completed"].includes(String(params.status))) return errors(422, "Validation Failed", { errors: [{ resource: "CheckRun", field: "status", code: "invalid" }] });
|
|
137
|
+
if (params.conclusion !== void 0 && !["success", "failure", "neutral", "cancelled", "skipped", "timed_out"].includes(String(params.conclusion))) return errors(422, "Validation Failed", { errors: [{ resource: "CheckRun", field: "conclusion", code: "invalid" }] });
|
|
138
|
+
const run = { id: `github_at_${sequence}`, name: params.name, head_sha: params.head_sha, status: str(params.status) ?? (params.conclusion !== void 0 ? "completed" : "queued"), conclusion: params.conclusion === void 0 ? null : params.conclusion, repo, html_url: null, started_at: iso(), completed_at: params.conclusion === void 0 ? null : iso() };
|
|
139
|
+
rows(data, "/check-runs").push(run);
|
|
140
|
+
return created("/check-runs", structuredClone(run), run);
|
|
141
|
+
}
|
|
142
|
+
if (resource === "issues") {
|
|
143
|
+
const inRepo = rows(data, "/issues").filter((r) => r.repo === repo);
|
|
144
|
+
if (method === "GET" && !id && !sub) {
|
|
145
|
+
if (Object.keys(params).some((k) => !["state", "page", "per_page"].includes(k))) return void 0;
|
|
146
|
+
const page = pagination(params, 30, 100);
|
|
147
|
+
if (!page) return errors(422, "Validation Failed", { errors: [{ resource: "Issue", field: "per_page", code: "invalid" }] });
|
|
148
|
+
const state = str(params.state) ?? "open";
|
|
149
|
+
if (!["open", "closed", "all"].includes(state)) return errors(422, "Validation Failed", { errors: [{ resource: "Issue", field: "state", code: "invalid" }] });
|
|
150
|
+
const filtered = inRepo.filter((r) => state === "all" || r.state === state);
|
|
151
|
+
const pageNumber = params.page === void 0 ? 1 : Number(params.page);
|
|
152
|
+
if (!Number.isInteger(pageNumber) || pageNumber < 1) return errors(422, "Validation Failed", { errors: [{ resource: "Issue", field: "page", code: "invalid" }] });
|
|
153
|
+
return ok("/issues", filtered.slice((pageNumber - 1) * page.limit, pageNumber * page.limit).map((row) => structuredClone(row)));
|
|
154
|
+
}
|
|
155
|
+
if (method === "POST" && !id && !sub) {
|
|
156
|
+
if (Object.keys(params).some((k) => !["title", "body", "labels"].includes(k))) return void 0;
|
|
157
|
+
if (typeof params.title !== "string" || !params.title) return errors(422, "Validation Failed", { errors: [{ resource: "Issue", field: "title", code: "missing_field" }] });
|
|
158
|
+
if (params.labels !== void 0 && (!Array.isArray(params.labels) || !params.labels.every((l) => typeof l === "string"))) return errors(422, "Validation Failed", { errors: [{ resource: "Issue", field: "labels", code: "invalid" }] });
|
|
159
|
+
const issue2 = { id: `github_at_${sequence}`, number: inRepo.reduce((max, r) => Math.max(max, Number(r.number) || 0), 0) + 1, repo, title: params.title, body: str(params.body) ?? null, state: "open", user: { login: "agenttwin-test" }, labels: Array.isArray(params.labels) ? structuredClone(params.labels) : [], comments: 0, created_at: iso(), updated_at: iso(), closed_at: null };
|
|
160
|
+
rows(data, "/issues").push(issue2);
|
|
161
|
+
return created("/issues", structuredClone(issue2), issue2);
|
|
162
|
+
}
|
|
163
|
+
const issue = id ? inRepo.find((r) => String(r.number) === id) : void 0;
|
|
164
|
+
if (!issue) return notFound("/issues", { message: "Not Found", documentation_url: "https://docs.github.com/rest" });
|
|
165
|
+
if (method === "GET" && !sub) return ok("/issues", structuredClone(issue));
|
|
166
|
+
if (method === "PATCH" && !sub) {
|
|
167
|
+
const allowed = ["state", "title", "body"];
|
|
168
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
169
|
+
if (params.state !== void 0 && !["open", "closed"].includes(String(params.state))) return errors(422, "Validation Failed", { errors: [{ resource: "Issue", field: "state", code: "invalid" }] });
|
|
170
|
+
if (params.title !== void 0 && typeof params.title !== "string") return void 0;
|
|
171
|
+
if (params.body !== void 0 && typeof params.body !== "string") return void 0;
|
|
172
|
+
if (params.title !== void 0) issue.title = params.title;
|
|
173
|
+
if (params.body !== void 0) issue.body = params.body;
|
|
174
|
+
if (params.state !== void 0) {
|
|
175
|
+
issue.state = params.state;
|
|
176
|
+
issue.closed_at = params.state === "closed" ? iso() : null;
|
|
177
|
+
}
|
|
178
|
+
issue.updated_at = iso();
|
|
179
|
+
return write(200, "/issues", structuredClone(issue), issue);
|
|
180
|
+
}
|
|
181
|
+
if (method === "POST" && sub === "comments") {
|
|
182
|
+
if (typeof params.body !== "string" || !params.body) return errors(422, "Validation Failed", { errors: [{ resource: "IssueComment", field: "body", code: "missing_field" }] });
|
|
183
|
+
if (Object.keys(params).some((k) => k !== "body")) return void 0;
|
|
184
|
+
const comment = { id: `github_at_${sequence}`, repo, issue_number: issue.number, body: params.body, user: { login: "agenttwin-test" }, created_at: iso(), updated_at: iso() };
|
|
185
|
+
rows(data, "/comments").push(comment);
|
|
186
|
+
return created("/comments", structuredClone(comment), comment);
|
|
187
|
+
}
|
|
188
|
+
if (method === "GET" && sub === "comments") return ok("/comments", rows(data, "/comments").filter((c) => c.repo === repo && c.issue_number === issue.number).map((row) => structuredClone(row)));
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
if (resource === "pulls") {
|
|
192
|
+
const inRepo = rows(data, "/pulls").filter((r) => r.repo === repo);
|
|
193
|
+
const pull = id ? inRepo.find((r) => String(r.number) === id) : void 0;
|
|
194
|
+
if (method === "GET" && !id && !sub) {
|
|
195
|
+
if (Object.keys(params).some((k) => k !== "state")) return void 0;
|
|
196
|
+
const state = str(params.state) ?? "open";
|
|
197
|
+
if (!["open", "closed", "all"].includes(state)) return errors(422, "Validation Failed", { errors: [{ resource: "PullRequest", field: "state", code: "invalid" }] });
|
|
198
|
+
return ok("/pulls", inRepo.filter((r) => state === "all" || r.state === state).map((row) => structuredClone(row)));
|
|
199
|
+
}
|
|
200
|
+
if (!pull) return id ? notFound("/pulls", { message: "Not Found", documentation_url: "https://docs.github.com/rest" }) : void 0;
|
|
201
|
+
if (method === "GET" && id && !sub) return ok("/pulls", structuredClone(pull));
|
|
202
|
+
if (method === "PUT" && sub === "merge") {
|
|
203
|
+
if (Object.keys(params).some((k) => k !== "merge_method")) return void 0;
|
|
204
|
+
if (params.merge_method !== void 0 && !["merge", "squash", "rebase"].includes(String(params.merge_method))) return errors(422, "Validation Failed", { errors: [{ resource: "PullRequest", field: "merge_method", code: "invalid" }] });
|
|
205
|
+
if (pull.merged) return errors(405, "Pull Request was already merged");
|
|
206
|
+
if (pull.state !== "open") return errors(405, "Pull Request is not mergeable");
|
|
207
|
+
pull.merged = true;
|
|
208
|
+
pull.merged_at = iso();
|
|
209
|
+
pull.state = "closed";
|
|
210
|
+
pull.merge_commit_sha = sha(`${repo}:${pull.number}:${sequence}`).slice(0, 40);
|
|
211
|
+
return write(200, "/pulls", { sha: pull.merge_commit_sha, merged: true, message: "Pull Request successfully merged" }, pull);
|
|
212
|
+
}
|
|
213
|
+
return void 0;
|
|
214
|
+
}
|
|
215
|
+
return void 0;
|
|
216
|
+
}
|
|
217
|
+
function linearOperation(method, path, params, _headers, data, sequence) {
|
|
218
|
+
const notFoundNode = (name) => ({ status: 200, body: { data: null, errors: [{ message: `Entity not found: ${name}` }] }, collection: "/issues", operation: "read" });
|
|
219
|
+
if (method !== "POST" || path !== "/graphql") return void 0;
|
|
220
|
+
const query = str(params.query);
|
|
221
|
+
if (!query) return void 0;
|
|
222
|
+
if (!/^\s*(query|mutation)\b/m.test(query)) return void 0;
|
|
223
|
+
const isMutation = /\bmutation\b/.test(query.slice(0, query.indexOf("{") + 1));
|
|
224
|
+
const root = new RegExp(isMutation ? "\\bmutation\\b[^{]*\\{\\s*(\\w+)" : "\\bquery\\b[^{]*\\{\\s*(\\w+)").exec(query);
|
|
225
|
+
if (!root) return void 0;
|
|
226
|
+
const variables = isObject(params.variables) ? params.variables : {};
|
|
227
|
+
const arg = (name) => variables[name] ?? (new RegExp(`\\b${name}\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(query)?.[1] !== void 0 ? JSON.parse(`"${new RegExp(`\\b${name}\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(query)[1]}"`) : void 0);
|
|
228
|
+
const input = variables.input ?? void 0;
|
|
229
|
+
if (!isMutation) {
|
|
230
|
+
if (root[1] === "viewer") {
|
|
231
|
+
if (Object.keys(variables).length) return void 0;
|
|
232
|
+
return ok("/users", { data: { viewer: rows(data, "/users")[0] ?? { id: "linear_at_viewer", name: "Test User", email: "user@example.test", displayName: "Test User" } } });
|
|
233
|
+
}
|
|
234
|
+
if (root[1] === "issues") {
|
|
235
|
+
if (Object.keys(variables).length) return void 0;
|
|
236
|
+
return ok("/issues", { data: { issues: { nodes: rows(data, "/issues").map((row) => structuredClone(row)) } } });
|
|
237
|
+
}
|
|
238
|
+
if (root[1] === "projects") {
|
|
239
|
+
if (Object.keys(variables).length) return void 0;
|
|
240
|
+
return ok("/projects", { data: { projects: { nodes: rows(data, "/projects").map((row) => structuredClone(row)) } } });
|
|
241
|
+
}
|
|
242
|
+
if (root[1] === "issue") {
|
|
243
|
+
const issue = rows(data, "/issues").find((r) => r.id === arg("id"));
|
|
244
|
+
if (Object.keys(variables).some((k) => k !== "id") || arg("id") === void 0) return void 0;
|
|
245
|
+
return issue ? ok("/issues", { data: { issue: structuredClone(issue) } }) : notFoundNode("Issue");
|
|
246
|
+
}
|
|
247
|
+
if (root[1] === "project") {
|
|
248
|
+
const project = rows(data, "/projects").find((r) => r.id === arg("id"));
|
|
249
|
+
if (Object.keys(variables).some((k) => k !== "id") || arg("id") === void 0) return void 0;
|
|
250
|
+
return project ? ok("/projects", { data: { project: structuredClone(project) } }) : notFoundNode("Project");
|
|
251
|
+
}
|
|
252
|
+
return void 0;
|
|
253
|
+
}
|
|
254
|
+
if (root[1] === "issueCreate") {
|
|
255
|
+
if (!isObject(input) || Object.keys(variables).some((k) => k !== "input")) return void 0;
|
|
256
|
+
if (Object.keys(input).some((k) => !["title", "description", "state", "assigneeId", "projectId", "priority"].includes(k))) return void 0;
|
|
257
|
+
if (typeof input.title !== "string" || !input.title) return { status: 200, body: { data: null, errors: [{ message: "Issue title required" }] }, collection: "/issues" };
|
|
258
|
+
const issue = { id: `linear_at_${sequence}`, title: input.title, description: str(input.description) ?? null, state: str(input.state) ?? "todo", assigneeId: str(input.assigneeId) ?? null, projectId: str(input.projectId) ?? null, priority: input.priority === void 0 ? 0 : input.priority, createdAt: iso(), updatedAt: iso() };
|
|
259
|
+
rows(data, "/issues").push(issue);
|
|
260
|
+
return write(200, "/issues", { data: { issueCreate: { success: true, issue: structuredClone(issue) } } }, issue);
|
|
261
|
+
}
|
|
262
|
+
if (root[1] === "issueUpdate") {
|
|
263
|
+
if (!isObject(input) || arg("id") === void 0 || Object.keys(variables).some((k) => !["id", "input"].includes(k))) return void 0;
|
|
264
|
+
const issue = rows(data, "/issues").find((r) => r.id === arg("id"));
|
|
265
|
+
if (!issue) return notFoundNode("Issue");
|
|
266
|
+
if (Object.keys(input).some((k) => !["title", "description", "state", "assigneeId", "projectId", "priority"].includes(k))) return void 0;
|
|
267
|
+
for (const [k, v] of Object.entries(input)) if (["title", "description", "state", "assigneeId", "projectId", "priority"].includes(k)) issue[k] = v;
|
|
268
|
+
issue.updatedAt = iso();
|
|
269
|
+
return write(200, "/issues", { data: { issueUpdate: { success: true, issue: structuredClone(issue) } } }, issue);
|
|
270
|
+
}
|
|
271
|
+
if (root[1] === "commentCreate") {
|
|
272
|
+
if (!isObject(input) || Object.keys(variables).some((k) => k !== "input")) return void 0;
|
|
273
|
+
if (Object.keys(input).some((k) => !["issueId", "body"].includes(k))) return void 0;
|
|
274
|
+
const issue = rows(data, "/issues").find((r) => r.id === input.issueId);
|
|
275
|
+
if (!issue) return notFoundNode("Issue");
|
|
276
|
+
if (typeof input.body !== "string" || !input.body) return { status: 200, body: { data: null, errors: [{ message: "Comment body required" }] }, collection: "/comments" };
|
|
277
|
+
const comment = { id: `linear_at_${sequence}`, issueId: input.issueId, body: input.body, userId: rows(data, "/users")[0]?.id ?? "linear_at_viewer", createdAt: iso() };
|
|
278
|
+
rows(data, "/comments").push(comment);
|
|
279
|
+
return write(200, "/comments", { data: { commentCreate: { success: true, comment: structuredClone(comment) } } }, comment);
|
|
280
|
+
}
|
|
281
|
+
return void 0;
|
|
282
|
+
}
|
|
283
|
+
var notionPropValue = (property) => {
|
|
284
|
+
if (!isObject(property)) return property;
|
|
285
|
+
if (property.title !== void 0) return Array.isArray(property.title) ? property.title.map((t) => t?.plain_text ?? t?.text?.content ?? "").join("") : property.title;
|
|
286
|
+
if (property.rich_text !== void 0) return Array.isArray(property.rich_text) ? property.rich_text.map((t) => t?.plain_text ?? t?.text?.content ?? "").join("") : property.rich_text;
|
|
287
|
+
if (property.select !== void 0) return isObject(property.select) ? property.select.name : property.select;
|
|
288
|
+
if (property.status !== void 0) return isObject(property.status) ? property.status.name : property.status;
|
|
289
|
+
return property.number ?? property.checkbox ?? property.url ?? property.email ?? property.phone_number ?? null;
|
|
290
|
+
};
|
|
291
|
+
var notionPage = (id, parent, properties, archived = false) => ({ object: "page", id, created_time: iso(), last_edited_time: iso(), created_by: null, last_edited_by: null, cover: null, icon: null, parent, archived, properties, url: null });
|
|
292
|
+
var notionError = (collection, status, code, message) => ({ status, body: { object: "error", status, code, message }, collection });
|
|
293
|
+
function notionOperation(method, path, params, headers, data, sequence) {
|
|
294
|
+
if (!/^\/v1\/(pages|databases|blocks)/.test(path)) return void 0;
|
|
295
|
+
const version = headers["notion-version"];
|
|
296
|
+
if (typeof version !== "string" || !version) return notionError("/pages", 400, "invalid_request", "Notion-Version header failed validation: Notion-Version header should be defined, instead was `undefined`.");
|
|
297
|
+
if (path === "/v1/pages") {
|
|
298
|
+
if (method !== "POST") return void 0;
|
|
299
|
+
if (Object.keys(params).some((k) => !["parent", "properties"].includes(k))) return void 0;
|
|
300
|
+
const parent = isObject(params.parent) ? params.parent : void 0;
|
|
301
|
+
if (!parent || str(parent.database_id) === void 0 || Object.keys(parent).some((k) => k !== "database_id")) return notionError("/pages", 400, "validation_error", "Bounded Notion twin supports database parents only: provide parent.database_id.");
|
|
302
|
+
const database = rows(data, "/databases").find((d) => d.id === parent.database_id);
|
|
303
|
+
if (!database) return notionError("/pages", 404, "object_not_found", "Could not find database with ID.");
|
|
304
|
+
if (!isObject(params.properties)) return notionError("/pages", 400, "validation_error", "properties should be defined.");
|
|
305
|
+
const page = notionPage(detUuid(`page:${sequence}`), { type: "database_id", database_id: database.id }, structuredClone(params.properties));
|
|
306
|
+
rows(data, "/pages").push(page);
|
|
307
|
+
return { status: 200, body: structuredClone(page), collection: "/pages", record: structuredClone(page), operation: "write" };
|
|
308
|
+
}
|
|
309
|
+
const pageMatch = /^\/v1\/pages\/([^/]+)$/.exec(path);
|
|
310
|
+
if (pageMatch) {
|
|
311
|
+
const page = rows(data, "/pages").find((p) => p.id === pageMatch[1]);
|
|
312
|
+
if (!page) return notionError("/pages", 404, "object_not_found", "Could not find page with ID.");
|
|
313
|
+
if (method === "GET") return ok("/pages", structuredClone(page));
|
|
314
|
+
if (method === "PATCH") {
|
|
315
|
+
if (Object.keys(params).some((k) => !["properties", "archived", "in_trash"].includes(k))) return void 0;
|
|
316
|
+
if (params.properties !== void 0) {
|
|
317
|
+
if (!isObject(params.properties)) return notionError("/pages", 400, "validation_error", "properties should be an object.");
|
|
318
|
+
Object.assign(page.properties, structuredClone(params.properties));
|
|
319
|
+
}
|
|
320
|
+
if (params.archived !== void 0) {
|
|
321
|
+
if (typeof params.archived !== "boolean") return notionError("/pages", 400, "validation_error", "archived should be a boolean.");
|
|
322
|
+
page.archived = params.archived;
|
|
323
|
+
}
|
|
324
|
+
page.last_edited_time = iso();
|
|
325
|
+
return write(200, "/pages", structuredClone(page), page);
|
|
326
|
+
}
|
|
327
|
+
return void 0;
|
|
328
|
+
}
|
|
329
|
+
const databaseMatch = /^\/v1\/databases\/([^/]+)(?:\/(query))?$/.exec(path);
|
|
330
|
+
if (databaseMatch) {
|
|
331
|
+
const database = rows(data, "/databases").find((d) => d.id === databaseMatch[1]);
|
|
332
|
+
if (!database) return notionError("/databases", 404, "object_not_found", "Could not find database with ID.");
|
|
333
|
+
if (!databaseMatch[2]) {
|
|
334
|
+
if (method !== "GET") return void 0;
|
|
335
|
+
return ok("/databases", structuredClone(database));
|
|
336
|
+
}
|
|
337
|
+
if (method !== "POST") return void 0;
|
|
338
|
+
if (Object.keys(params).some((k) => !["filter", "page_size", "start_cursor", "sorts"].includes(k))) return void 0;
|
|
339
|
+
if (params.sorts !== void 0 || params.start_cursor !== void 0) return void 0;
|
|
340
|
+
let limit = 100;
|
|
341
|
+
if (params.page_size !== void 0) {
|
|
342
|
+
if (!Number.isInteger(params.page_size) || Number(params.page_size) < 1 || Number(params.page_size) > 100) return notionError("/databases", 400, "validation_error", "page_size must be an integer between 1 and 100.");
|
|
343
|
+
limit = Number(params.page_size);
|
|
344
|
+
}
|
|
345
|
+
const pages = rows(data, "/pages").filter((p) => p.parent && p.parent.database_id === database.id);
|
|
346
|
+
if (params.filter !== void 0) {
|
|
347
|
+
const filter = params.filter;
|
|
348
|
+
if (!isObject(filter) || Object.keys(filter).length !== 2 || typeof filter.property !== "string") return void 0;
|
|
349
|
+
const [condition] = Object.keys(filter).filter((k) => k !== "property");
|
|
350
|
+
if (!["title", "rich_text", "number", "select", "status", "checkbox"].includes(condition)) return void 0;
|
|
351
|
+
const expected = isObject(filter[condition]) ? filter[condition].equals : void 0;
|
|
352
|
+
if (expected === void 0 || Object.keys(filter[condition]).length !== 1) return void 0;
|
|
353
|
+
const property = filter.property;
|
|
354
|
+
const results = pages.filter((p) => notionPropValue(p.properties[property]) === expected);
|
|
355
|
+
return ok("/pages", { object: "list", results: results.slice(0, limit).map((row) => structuredClone(row)), has_more: false, next_cursor: null, type: "page_or_database", page_or_database: {} });
|
|
356
|
+
}
|
|
357
|
+
return ok("/pages", { object: "list", results: pages.slice(0, limit).map((row) => structuredClone(row)), has_more: false, next_cursor: null, type: "page_or_database", page_or_database: {} });
|
|
358
|
+
}
|
|
359
|
+
const blockMatch = /^\/v1\/blocks\/([^/]+)\/(children)$/.exec(path);
|
|
360
|
+
if (blockMatch) {
|
|
361
|
+
if (method !== "PATCH" || blockMatch[2] !== "children") return void 0;
|
|
362
|
+
if (Object.keys(params).some((k) => k !== "children")) return void 0;
|
|
363
|
+
if (!Array.isArray(params.children) || !params.children.length || params.children.length > 100) return notionError("/blocks", 400, "validation_error", "children should be an array of 1\u2013100 block objects.");
|
|
364
|
+
if (!params.children.every(isObject)) return notionError("/blocks", 400, "validation_error", "children should be block objects.");
|
|
365
|
+
if (params.children.some((c) => typeof c.type !== "string" || !c.type)) return notionError("/blocks", 400, "validation_error", "Each child block requires a type.");
|
|
366
|
+
const page = rows(data, "/pages").find((p) => p.id === blockMatch[1]);
|
|
367
|
+
const block = rows(data, "/blocks").find((b) => b.id === blockMatch[1]);
|
|
368
|
+
if (!page && !block) return notionError("/blocks", 404, "object_not_found", "Could not find block with ID.");
|
|
369
|
+
const parent = page ? { type: "page_id", page_id: page.id } : { type: "block_id", block_id: block.id };
|
|
370
|
+
const createdBlocks = params.children.map((child, index) => {
|
|
371
|
+
const record = { object: "block", id: detUuid(`block:${sequence}:${index}`), parent, type: child.type, created_time: iso(), last_edited_time: iso(), has_children: false, archived: false, block: structuredClone(child) };
|
|
372
|
+
rows(data, "/blocks").push(record);
|
|
373
|
+
return record;
|
|
374
|
+
});
|
|
375
|
+
return write(200, "/blocks", { object: "list", results: structuredClone(createdBlocks), has_more: false, next_cursor: null, type: "block", block: {} }, createdBlocks[createdBlocks.length - 1]);
|
|
376
|
+
}
|
|
377
|
+
return void 0;
|
|
378
|
+
}
|
|
379
|
+
var hubspotEnvelope = (record) => ({ id: record.id, properties: structuredClone(record.properties), createdAt: record.createdAt, updatedAt: record.updatedAt, archived: false });
|
|
380
|
+
function hubspotOperation(method, path, params, _headers, data, sequence) {
|
|
381
|
+
const match = /^\/crm\/v3\/objects\/(contacts|companies|deals|tickets)(?:\/(search)|\/([^/]+))?$/.exec(path);
|
|
382
|
+
if (!match) return void 0;
|
|
383
|
+
const [, object2, search, id] = match;
|
|
384
|
+
const collection = `/crm/v3/objects/${object2}`;
|
|
385
|
+
const properties = isObject(params.properties) ? params.properties : void 0;
|
|
386
|
+
const validProperties = (value) => Object.values(value).every((v) => ["string", "number", "boolean", "object"].includes(v === null ? "object" : typeof v));
|
|
387
|
+
if (search) {
|
|
388
|
+
if (method !== "POST" || id) return void 0;
|
|
389
|
+
if (Object.keys(params).some((k) => !["query", "filterGroups", "limit", "after", "properties", "sorts"].includes(k))) return void 0;
|
|
390
|
+
if (params.properties !== void 0 || params.sorts !== void 0) return void 0;
|
|
391
|
+
const searchLimit = boundedInt(params.limit, 1, 100);
|
|
392
|
+
if (searchLimit === null) return badRequest(collection, { status: "error", message: "Unable to process: limit must be between 1 and 100", category: "VALIDATION_ERROR" });
|
|
393
|
+
const searchAfter = boundedInt(params.after, 0, Number.MAX_SAFE_INTEGER);
|
|
394
|
+
if (searchAfter === null) return badRequest(collection, { status: "error", message: "Unable to process: after must be a nonnegative integer offset", category: "VALIDATION_ERROR" });
|
|
395
|
+
const limit = searchLimit ?? 10, after = searchAfter ?? 0;
|
|
396
|
+
let filtered = rows(data, collection);
|
|
397
|
+
if (params.query !== void 0) {
|
|
398
|
+
if (typeof params.query !== "string") return void 0;
|
|
399
|
+
const needle = params.query.toLowerCase();
|
|
400
|
+
filtered = filtered.filter((r) => Object.values(r.properties).some((v) => typeof v === "string" && v.toLowerCase().includes(needle)));
|
|
401
|
+
}
|
|
402
|
+
if (params.filterGroups !== void 0) {
|
|
403
|
+
if (!Array.isArray(params.filterGroups) || params.filterGroups.length !== 1) return void 0;
|
|
404
|
+
const group = params.filterGroups[0];
|
|
405
|
+
if (!isObject(group) || Object.keys(group).some((k) => k !== "filters") || !Array.isArray(group.filters) || !group.filters.length || group.filters.length > 3) return void 0;
|
|
406
|
+
for (const filter of group.filters) {
|
|
407
|
+
if (!isObject(filter) || Object.keys(filter).some((k) => !["propertyName", "operator", "value"].includes(k)) || typeof filter.propertyName !== "string" || filter.operator !== "EQ" || filter.value === void 0) return void 0;
|
|
408
|
+
const propertyName = filter.propertyName;
|
|
409
|
+
filtered = filtered.filter((r) => r.properties[propertyName] === filter.value);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const page = filtered.slice(after, after + limit);
|
|
413
|
+
return ok(collection, { total: filtered.length, results: page.map((r) => hubspotEnvelope(r)) });
|
|
414
|
+
}
|
|
415
|
+
if (!id) {
|
|
416
|
+
if (method === "GET") {
|
|
417
|
+
if (Object.keys(params).some((k) => !["limit", "after"].includes(k))) return void 0;
|
|
418
|
+
const listLimit = boundedInt(params.limit, 1, 100);
|
|
419
|
+
if (listLimit === null) return badRequest(collection, { status: "error", message: "Unable to process: limit must be between 1 and 100", category: "VALIDATION_ERROR" });
|
|
420
|
+
const listAfter = boundedInt(params.after, 0, Number.MAX_SAFE_INTEGER);
|
|
421
|
+
if (listAfter === null) return badRequest(collection, { status: "error", message: "Unable to process: after must be a nonnegative integer offset", category: "VALIDATION_ERROR" });
|
|
422
|
+
const page = rows(data, collection).slice(listAfter ?? 0, (listAfter ?? 0) + (listLimit ?? 10));
|
|
423
|
+
return ok(collection, { total: rows(data, collection).length, results: page.map((r) => hubspotEnvelope(r)) });
|
|
424
|
+
}
|
|
425
|
+
if (method !== "POST") return void 0;
|
|
426
|
+
if (Object.keys(params).some((k) => k !== "properties") || !properties || !validProperties(properties)) return badRequest(collection, { status: "error", message: `Invalid input JSON on line 1: Unable to process JSON: properties required for ${object2}`, category: "VALIDATION_ERROR" });
|
|
427
|
+
const record2 = { id: uniqueId(rows(data, collection), 501 + sequence), properties: structuredClone(properties), createdAt: iso(), updatedAt: iso() };
|
|
428
|
+
rows(data, collection).push(record2);
|
|
429
|
+
return created(collection, hubspotEnvelope(record2), record2);
|
|
430
|
+
}
|
|
431
|
+
const record = rows(data, collection).find((r) => r.id === id);
|
|
432
|
+
if (!record) return { status: 404, body: { status: "error", message: `Object not found: ${object2} with id ${id}`, category: "OBJECT_NOT_FOUND" }, collection };
|
|
433
|
+
if (method === "GET") return ok(collection, hubspotEnvelope(record));
|
|
434
|
+
if (method === "PATCH") {
|
|
435
|
+
if (Object.keys(params).some((k) => k !== "properties") || !properties || !validProperties(properties)) return badRequest(collection, { status: "error", message: "Unable to process JSON: properties object required", category: "VALIDATION_ERROR" });
|
|
436
|
+
Object.assign(record.properties, structuredClone(properties));
|
|
437
|
+
record.updatedAt = iso();
|
|
438
|
+
return write(200, collection, hubspotEnvelope(record), record);
|
|
439
|
+
}
|
|
440
|
+
return void 0;
|
|
441
|
+
}
|
|
442
|
+
var SHOPIFY_VERSION = "2024-01";
|
|
443
|
+
var shopifySlug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
444
|
+
function shopifyOperation(method, path, params, _headers, data, sequence) {
|
|
445
|
+
const match = new RegExp(`^/admin/api/${SHOPIFY_VERSION}/(products|orders|customers)(?:/(\\d+))?\\.json$`).exec(path);
|
|
446
|
+
if (!match) return void 0;
|
|
447
|
+
const [, resource, id] = match;
|
|
448
|
+
const collection = `/admin/${resource}`;
|
|
449
|
+
const wrap = resource === "products" ? "product" : resource === "orders" ? "order" : "customer";
|
|
450
|
+
const rowsIn2 = rows(data, collection);
|
|
451
|
+
const notFoundShopify = () => notFound(collection, { errors: "Not Found" });
|
|
452
|
+
const enriched = (record2) => ({ ...structuredClone(record2), created_at: record2.created_at ?? iso(), updated_at: iso() });
|
|
453
|
+
if (!id) {
|
|
454
|
+
if (method === "GET") {
|
|
455
|
+
if (Object.keys(params).some((k) => !["limit", "since_id"].includes(k))) return void 0;
|
|
456
|
+
const page = pagination(params, 50, 250);
|
|
457
|
+
if (!page) return badRequest(collection, { errors: "limit must be between 1 and 250" });
|
|
458
|
+
let filtered = rowsIn2;
|
|
459
|
+
if (params.since_id !== void 0) {
|
|
460
|
+
if (!Number.isInteger(Number(params.since_id)) || Number(params.since_id) < 0) return badRequest(collection, { errors: "since_id must be a nonnegative integer" });
|
|
461
|
+
filtered = filtered.filter((r) => Number(r.id) > Number(params.since_id));
|
|
462
|
+
}
|
|
463
|
+
return ok(collection, { [`${resource}`]: filtered.slice(0, page.limit).map(enriched) });
|
|
464
|
+
}
|
|
465
|
+
if (method !== "POST") return void 0;
|
|
466
|
+
if (Object.keys(params).some((k) => k !== wrap) || !isObject(params[wrap])) return badRequest(collection, { errors: { [wrap]: ["expected a root object"] } });
|
|
467
|
+
const body = params[wrap];
|
|
468
|
+
if (resource === "products" && (typeof body.title !== "string" || !body.title)) return { status: 422, body: { errors: { title: ["can't be blank"] } }, collection };
|
|
469
|
+
if (resource === "orders") {
|
|
470
|
+
if (!Array.isArray(body.line_items) || !body.line_items.length || !body.line_items.every(isObject)) return { status: 422, body: { errors: { line_items: ["can't be blank"] } }, collection };
|
|
471
|
+
if (body.line_items.some((item) => Object.keys(item).some((k) => !["title", "quantity", "price", "sku"].includes(k)))) return void 0;
|
|
472
|
+
}
|
|
473
|
+
const allowed = { products: ["title", "body_html", "vendor", "product_type", "status", "tags", "variants", "options"], orders: ["email", "phone", "line_items", "financial_status", "fulfillment_status", "note", "total_price", "currency"], customers: ["first_name", "last_name", "email", "phone", "note", "tags"] }[resource];
|
|
474
|
+
if (Object.keys(body).some((k) => !allowed.includes(k))) return void 0;
|
|
475
|
+
const record2 = { id: uniqueId(rowsIn2, { products: 632910392, orders: 450789469, customers: 207119551 }[resource] + sequence), created_at: iso(), updated_at: iso() };
|
|
476
|
+
if (resource === "products") Object.assign(record2, { title: body.title, body_html: body.body_html ?? null, vendor: body.vendor ?? null, product_type: body.product_type ?? null, handle: shopifySlug(String(body.title)), status: str(body.status) ?? "active", tags: body.tags ?? "", variants: Array.isArray(body.variants) ? structuredClone(body.variants) : [], options: Array.isArray(body.options) ? structuredClone(body.options) : [], image: null, images: [] });
|
|
477
|
+
if (resource === "orders") Object.assign(record2, { email: body.email ?? null, phone: body.phone ?? null, note: body.note ?? null, line_items: structuredClone(body.line_items), total_price: typeof body.total_price === "string" ? body.total_price : Number(Array.isArray(body.line_items) ? body.line_items.reduce((sum, item) => sum + Number(item.price ?? 0) * Number(item.quantity ?? 1), 0) : 0).toFixed(2), currency: str(body.currency) ?? "USD", financial_status: str(body.financial_status) ?? "pending", fulfillment_status: body.fulfillment_status ?? null });
|
|
478
|
+
if (resource === "customers") Object.assign(record2, { first_name: body.first_name ?? null, last_name: body.last_name ?? null, email: body.email ?? null, phone: body.phone ?? null, note: body.note ?? null, tags: body.tags ?? "", state: "enabled", verified_email: true, accepts_marketing: false });
|
|
479
|
+
rowsIn2.push(record2);
|
|
480
|
+
return created(collection, { [wrap]: enriched(record2) }, record2);
|
|
481
|
+
}
|
|
482
|
+
const record = rowsIn2.find((r) => String(r.id) === id);
|
|
483
|
+
if (!record) return notFoundShopify();
|
|
484
|
+
if (method === "GET") return ok(collection, { [wrap]: enriched(record) });
|
|
485
|
+
if (method === "PUT") {
|
|
486
|
+
if (Object.keys(params).some((k) => k !== wrap) || !isObject(params[wrap])) return badRequest(collection, { errors: { [wrap]: ["expected a root object"] } });
|
|
487
|
+
const body = params[wrap];
|
|
488
|
+
const allowed = { products: ["title", "body_html", "vendor", "product_type", "status", "tags"], orders: ["email", "phone", "note", "financial_status", "fulfillment_status"], customers: ["first_name", "last_name", "email", "phone", "note", "tags"] }[resource];
|
|
489
|
+
if (Object.keys(body).some((k) => !allowed.includes(k))) return void 0;
|
|
490
|
+
for (const [k, v] of Object.entries(body)) record[k] = v;
|
|
491
|
+
if (resource === "products" && body.title !== void 0) record.handle = shopifySlug(String(body.title));
|
|
492
|
+
record.updated_at = iso();
|
|
493
|
+
return write(200, collection, { [wrap]: enriched(record) }, record);
|
|
494
|
+
}
|
|
495
|
+
return void 0;
|
|
496
|
+
}
|
|
497
|
+
function datadogOperation(method, path, params, _headers, data, sequence) {
|
|
498
|
+
const monitorMatch = /^\/api\/v1\/monitors?\/?(\d+)?(?:\/(mute))?$/.exec(path);
|
|
499
|
+
if (monitorMatch) {
|
|
500
|
+
const [, id, mute] = monitorMatch;
|
|
501
|
+
if (!id) {
|
|
502
|
+
if (method === "GET") {
|
|
503
|
+
if (Object.keys(params).some((k) => k !== "name")) return void 0;
|
|
504
|
+
const filtered = params.name === void 0 ? rows(data, "/monitors") : rows(data, "/monitors").filter((m) => typeof params.name === "string" && String(m.name).includes(params.name));
|
|
505
|
+
return ok("/monitors", filtered.map((row) => structuredClone(row)));
|
|
506
|
+
}
|
|
507
|
+
if (method !== "POST") return void 0;
|
|
508
|
+
if (Object.keys(params).some((k) => !["name", "type", "query", "message", "tags", "options", "priority"].includes(k))) return void 0;
|
|
509
|
+
if (typeof params.name !== "string" || !params.name || typeof params.type !== "string" || !params.type || typeof params.query !== "string" || !params.query) return badRequest("/monitors", { errors: ["The value provided for parameter 'name', 'type' or 'query' is invalid"] });
|
|
510
|
+
const monitor2 = { id: sequence, name: params.name, type: params.type, query: params.query, message: str(params.message) ?? "", tags: Array.isArray(params.tags) ? structuredClone(params.tags) : [], options: isObject(params.options) ? structuredClone(params.options) : {}, priority: params.priority === void 0 ? null : params.priority, overall_state: "OK", creator: { name: "agenttwin@example.test", handle: "agenttwin@example.test" }, created: iso(), modified: iso() };
|
|
511
|
+
rows(data, "/monitors").push(monitor2);
|
|
512
|
+
return { status: 200, body: structuredClone(monitor2), collection: "/monitors", record: structuredClone(monitor2), operation: "write" };
|
|
513
|
+
}
|
|
514
|
+
const monitor = rows(data, "/monitors").find((m) => String(m.id) === id);
|
|
515
|
+
if (!monitor) return notFound("/monitors", { errors: ["Monitor not found"] });
|
|
516
|
+
if (method === "GET" && !mute) return ok("/monitors", structuredClone(monitor));
|
|
517
|
+
if (method === "POST" && mute === "mute") {
|
|
518
|
+
if (Object.keys(params).some((k) => !["scope", "end"].includes(k))) return void 0;
|
|
519
|
+
const options = isObject(monitor.options) ? monitor.options : {};
|
|
520
|
+
monitor.options = { ...options, muted: true };
|
|
521
|
+
monitor.modified = iso();
|
|
522
|
+
return write(200, "/monitors", structuredClone(monitor), monitor);
|
|
523
|
+
}
|
|
524
|
+
return void 0;
|
|
525
|
+
}
|
|
526
|
+
if (path === "/api/v1/events") {
|
|
527
|
+
if (method === "POST") {
|
|
528
|
+
if (Object.keys(params).some((k) => !["title", "text", "tags", "priority", "alert_type", "source"].includes(k))) return void 0;
|
|
529
|
+
if (typeof params.title !== "string" || !params.title || typeof params.text !== "string" || !params.text) return badRequest("/events", { errors: ["The value provided for parameter 'title' or 'text' is invalid"] });
|
|
530
|
+
const event = { id: sequence, title: params.title, text: params.text, tags: Array.isArray(params.tags) ? structuredClone(params.tags) : [], priority: params.priority === void 0 ? "normal" : params.priority, alert_type: str(params.alert_type) ?? "info", source: str(params.source) ?? "agenttwin", date_happened: EPOCH, device_name: null, host: null, resource: `/api/v1/events/${sequence}`, url: null, status: "ok" };
|
|
531
|
+
rows(data, "/events").push(event);
|
|
532
|
+
return { status: 200, body: { status: "ok", event: structuredClone(event) }, collection: "/events", record: structuredClone(event), operation: "write" };
|
|
533
|
+
}
|
|
534
|
+
if (method === "GET") {
|
|
535
|
+
if (Object.keys(params).some((k) => !["start", "end", "priority", "tags", "sources"].includes(k))) return void 0;
|
|
536
|
+
if (params.start === void 0 || params.end === void 0) return badRequest("/events", { errors: ["start and end parameters are required"] });
|
|
537
|
+
if (!Number.isInteger(Number(params.start)) || !Number.isInteger(Number(params.end))) return badRequest("/events", { errors: ["start and end parameters must be integers"] });
|
|
538
|
+
let filtered = rows(data, "/events").filter((e) => Number(e.date_happened) >= Number(params.start) && Number(e.date_happened) <= Number(params.end));
|
|
539
|
+
if (params.priority !== void 0) filtered = filtered.filter((e) => e.priority === params.priority);
|
|
540
|
+
if (params.tags !== void 0) {
|
|
541
|
+
if (typeof params.tags !== "string") return void 0;
|
|
542
|
+
const tags = params.tags.split(",").filter(Boolean);
|
|
543
|
+
filtered = filtered.filter((e) => tags.every((tag) => Array.isArray(e.tags) && e.tags.includes(tag)));
|
|
544
|
+
}
|
|
545
|
+
return ok("/events", { events: filtered.slice(0, 100).map((row) => structuredClone(row)) });
|
|
546
|
+
}
|
|
547
|
+
return void 0;
|
|
548
|
+
}
|
|
549
|
+
if (path === "/api/v1/query") {
|
|
550
|
+
if (method !== "GET") return void 0;
|
|
551
|
+
if (Object.keys(params).some((k) => !["from", "to", "query"].includes(k))) return void 0;
|
|
552
|
+
if (params.from === void 0 || params.to === void 0 || typeof params.query !== "string" || !params.query) return badRequest("/api/v1/query", { errors: ["from, to and query parameters are required"] });
|
|
553
|
+
if (!Number.isInteger(Number(params.from)) || !Number.isInteger(Number(params.to))) return badRequest("/api/v1/query", { errors: ["from and to must be epoch seconds"] });
|
|
554
|
+
const from = Number(params.from), to = Number(params.to);
|
|
555
|
+
if (to <= from) return badRequest("/api/v1/query", { errors: ["to must be after from"] });
|
|
556
|
+
const parsed = /^(?:avg|min|max|sum):([a-zA-Z0-9_.]+)\{[^}]*\}$/.exec(params.query);
|
|
557
|
+
if (!parsed) return badRequest("/api/v1/query", { errors: ["query must look like avg:metric.name{scope}"] });
|
|
558
|
+
const metric = rows(data, "/metrics").find((m) => m.metric === parsed[1]);
|
|
559
|
+
const pointlist = metric ? metric.points.filter(([ts]) => ts >= from && ts <= to) : [];
|
|
560
|
+
return ok("/metrics", { status: "ok", from_date: from * 1e3, to_date: to * 1e3, query: params.query, series: [{ metric: parsed[1], display_name: parsed[1].split(".").pop(), unit: null, pointlist: pointlist.map(([ts, value]) => [ts * 1e3, value]), scope: null, expression: params.query }] });
|
|
561
|
+
}
|
|
562
|
+
return void 0;
|
|
563
|
+
}
|
|
564
|
+
var googleError = (collection, status, message) => ({ status, body: { error: { code: status, message, status: status === 404 ? "NOT_FOUND" : "INVALID_ARGUMENT" } }, collection });
|
|
565
|
+
var rowsIn = (data, collection) => data[collection];
|
|
566
|
+
function gmailOperation(method, path, params, _headers, data, sequence) {
|
|
567
|
+
const MESSAGES = "/gmail/v1/users/me/messages", LABELS = "/gmail/v1/users/me/labels";
|
|
568
|
+
const fail = (status, message) => googleError(MESSAGES, status, message);
|
|
569
|
+
if (path === `${MESSAGES}/send`) {
|
|
570
|
+
if (method !== "POST") return void 0;
|
|
571
|
+
if (Object.keys(params).some((k) => !["raw", "to", "subject", "text", "threadId"].includes(k))) return void 0;
|
|
572
|
+
const text = str(params.text), raw = str(params.raw);
|
|
573
|
+
if (!text && !raw) return fail(400, "Message body is required: provide `text` or a `raw` MIME string.");
|
|
574
|
+
if (params.text !== void 0 && text === "") return fail(400, "Message text must not be empty.");
|
|
575
|
+
if (params.to !== void 0 && str(params.to) === void 0) return fail(400, "to must be a string.");
|
|
576
|
+
if (params.subject !== void 0 && str(params.subject) === void 0) return fail(400, "subject must be a string.");
|
|
577
|
+
if (params.threadId !== void 0 && str(params.threadId) === void 0) return fail(400, "threadId must be a string.");
|
|
578
|
+
const body = text ?? raw ?? "";
|
|
579
|
+
const message = { id: `gmail_at_${sequence}`, threadId: str(params.threadId) ?? `gmail_at_${sequence}`, labelIds: ["SENT"], snippet: body.slice(0, 100), payload: { mimeType: "text/plain", headers: [{ name: "To", value: str(params.to) ?? "" }, { name: "Subject", value: str(params.subject) ?? "" }], body: { data: body } }, sizeEstimate: body.length, internalDate: "1700000000000", historyId: String(17e8 + sequence) };
|
|
580
|
+
rowsIn(data, MESSAGES).push(message);
|
|
581
|
+
return write(200, MESSAGES, structuredClone(message), message);
|
|
582
|
+
}
|
|
583
|
+
if (path === MESSAGES) {
|
|
584
|
+
if (method !== "GET") return void 0;
|
|
585
|
+
if (Object.keys(params).some((k) => k !== "q")) return void 0;
|
|
586
|
+
let filtered = rowsIn(data, MESSAGES);
|
|
587
|
+
if (params.q !== void 0) {
|
|
588
|
+
if (typeof params.q !== "string") return fail(400, "q must be a string.");
|
|
589
|
+
filtered = filtered.filter((m) => typeof m.snippet === "string" && m.snippet.includes(params.q));
|
|
590
|
+
}
|
|
591
|
+
return ok(MESSAGES, { messages: filtered.map((m) => ({ id: m.id, threadId: m.threadId })), resultSizeEstimate: filtered.length });
|
|
592
|
+
}
|
|
593
|
+
const messageMatch = /^\/gmail\/v1\/users\/me\/messages\/([^/]+)$/.exec(path);
|
|
594
|
+
if (messageMatch) {
|
|
595
|
+
if (method !== "GET") return void 0;
|
|
596
|
+
const message = rowsIn(data, MESSAGES).find((m) => m.id === messageMatch[1]);
|
|
597
|
+
if (!message) return fail(404, `Message not found: ${messageMatch[1]}`);
|
|
598
|
+
return ok(MESSAGES, structuredClone(message));
|
|
599
|
+
}
|
|
600
|
+
if (path === LABELS) {
|
|
601
|
+
if (method !== "GET") return void 0;
|
|
602
|
+
if (Object.keys(params).length) return void 0;
|
|
603
|
+
return ok(LABELS, { labels: rowsIn(data, LABELS).map((row) => structuredClone(row)) });
|
|
604
|
+
}
|
|
605
|
+
return void 0;
|
|
606
|
+
}
|
|
607
|
+
function googleCalendarOperation(method, path, params, _headers, data, sequence) {
|
|
608
|
+
const CALENDAR_LIST = "/calendar/v3/users/me/calendarList", EVENTS = "/calendar/v3/calendars/primary/events";
|
|
609
|
+
const fail = (status, message) => googleError(EVENTS, status, message);
|
|
610
|
+
if (path === CALENDAR_LIST) {
|
|
611
|
+
if (method !== "GET") return void 0;
|
|
612
|
+
if (Object.keys(params).length) return void 0;
|
|
613
|
+
return ok(CALENDAR_LIST, { items: rowsIn(data, CALENDAR_LIST).map((row) => structuredClone(row)) });
|
|
614
|
+
}
|
|
615
|
+
const match = /^\/calendar\/v3\/calendars\/([^/]+)\/events(?:\/([^/]+))?$/.exec(path);
|
|
616
|
+
if (!match) return void 0;
|
|
617
|
+
const [, calendar, eventId] = match;
|
|
618
|
+
if (calendar !== "primary") return googleError(EVENTS, 404, `Not Found: no twin state for calendar ${calendar}`);
|
|
619
|
+
const scalar = (value) => ["summary", "description", "location"].filter((k) => params[k] !== void 0 && str(params[k]) === void 0);
|
|
620
|
+
const meetingField = (value) => value !== void 0 && !Array.isArray(value) && !isObject(value);
|
|
621
|
+
if (!eventId) {
|
|
622
|
+
if (method === "GET") {
|
|
623
|
+
const allowed = ["timeMin", "timeMax", "maxResults", "pageToken", "singleEvents", "orderBy", "conferenceDataVersion"];
|
|
624
|
+
if (Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
625
|
+
if (params.singleEvents !== void 0 && !["true", "false", true, false].includes(params.singleEvents)) return fail(400, "Invalid singleEvents.");
|
|
626
|
+
if (params.orderBy !== void 0 && params.orderBy !== "startTime") return void 0;
|
|
627
|
+
if (params.orderBy === "startTime" && String(params.singleEvents) !== "true") return fail(400, "startTime ordering requires singleEvents=true.");
|
|
628
|
+
if (params.conferenceDataVersion !== void 0 && String(params.conferenceDataVersion) !== "1") return void 0;
|
|
629
|
+
const timestamp = (v) => {
|
|
630
|
+
if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v)) return NaN;
|
|
631
|
+
return Math.floor(Date.parse(v) / 1e3) * 1e3;
|
|
632
|
+
};
|
|
633
|
+
const lower = params.timeMin === void 0 ? -Infinity : timestamp(params.timeMin);
|
|
634
|
+
const upper = params.timeMax === void 0 ? Infinity : timestamp(params.timeMax);
|
|
635
|
+
if (Number.isNaN(lower) || Number.isNaN(upper) || lower >= upper) return fail(400, "Invalid time range.");
|
|
636
|
+
const limit = params.maxResults === void 0 ? 250 : boundedInt(params.maxResults, 1, 2500);
|
|
637
|
+
if (limit === null) return fail(400, "Invalid maxResults.");
|
|
638
|
+
let events = rowsIn(data, EVENTS).filter((row) => row.status !== "cancelled");
|
|
639
|
+
if (events.some((row) => row.recurrence || row.recurringEventId || row.conferenceData)) return void 0;
|
|
640
|
+
const timed = params.timeMin !== void 0 || params.timeMax !== void 0 || params.orderBy !== void 0;
|
|
641
|
+
if (timed && events.some((row) => !isObject(row.start) || !isObject(row.end) || !Number.isFinite(timestamp(row.start.dateTime)) || !Number.isFinite(timestamp(row.end.dateTime)))) return void 0;
|
|
642
|
+
if (timed) events = events.filter((row) => timestamp(row.end.dateTime) > lower && timestamp(row.start.dateTime) < upper);
|
|
643
|
+
if (params.orderBy === "startTime") events = [...events].sort((a, b) => timestamp(a.start.dateTime) - timestamp(b.start.dateTime));
|
|
644
|
+
const query = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "pageToken").sort(([a], [b]) => a.localeCompare(b)));
|
|
645
|
+
const fingerprint = sha(JSON.stringify({ query, events })).slice(0, 20);
|
|
646
|
+
let offset = 0;
|
|
647
|
+
if (params.pageToken !== void 0) {
|
|
648
|
+
if (typeof params.pageToken !== "string") return fail(400, "Invalid page token.");
|
|
649
|
+
const token = /^at_([a-f0-9]{20})_(\d+)$/.exec(params.pageToken);
|
|
650
|
+
if (!token || token[1] !== fingerprint || !Number.isSafeInteger(Number(token[2])) || Number(token[2]) < 1 || Number(token[2]) >= events.length) return fail(400, "Invalid or stale page token.");
|
|
651
|
+
offset = Number(token[2]);
|
|
652
|
+
}
|
|
653
|
+
const next = offset + (limit ?? 250);
|
|
654
|
+
return ok(EVENTS, { items: structuredClone(events.slice(offset, next)), ...next < events.length ? { nextPageToken: `at_${fingerprint}_${next}` } : {} });
|
|
655
|
+
}
|
|
656
|
+
if (method !== "POST") return void 0;
|
|
657
|
+
if (Object.keys(params).some((k) => !["summary", "description", "location", "start", "end", "attendees", "status"].includes(k))) return void 0;
|
|
658
|
+
if (typeof params.summary !== "string" || !params.summary) return fail(400, "Event summary is required.");
|
|
659
|
+
if (["start", "end", "attendees"].some((k) => meetingField(params[k]))) return fail(400, "start, end and attendees must be objects.");
|
|
660
|
+
const scalarBad = scalar(params);
|
|
661
|
+
if (scalarBad.length) return fail(400, `${scalarBad.join(", ")} must be strings.`);
|
|
662
|
+
const event2 = { id: detUuid(`gcal:${sequence}`), summary: params.summary, description: str(params.description) ?? null, location: str(params.location) ?? null, start: isObject(params.start) ? structuredClone(params.start) : { dateTime: iso() }, end: isObject(params.end) ? structuredClone(params.end) : { dateTime: iso() }, attendees: Array.isArray(params.attendees) ? structuredClone(params.attendees) : [], status: str(params.status) ?? "confirmed", htmlLink: null, creator: { email: "agenttwin@example.test", self: true }, created: iso(), updated: iso() };
|
|
663
|
+
rowsIn(data, EVENTS).push(event2);
|
|
664
|
+
return write(200, EVENTS, structuredClone(event2), event2);
|
|
665
|
+
}
|
|
666
|
+
const event = rowsIn(data, EVENTS).find((e) => e.id === eventId);
|
|
667
|
+
if (!event) return fail(404, `Not Found: event ${eventId}`);
|
|
668
|
+
if (method === "GET") return ok(EVENTS, structuredClone(event));
|
|
669
|
+
if (method === "PATCH") {
|
|
670
|
+
if (Object.keys(params).some((k) => !["summary", "description", "location", "start", "end", "attendees", "status"].includes(k))) return void 0;
|
|
671
|
+
if (["start", "end", "attendees"].some((k) => meetingField(params[k]))) return fail(400, "start, end and attendees must be objects.");
|
|
672
|
+
const scalarBad = scalar(params);
|
|
673
|
+
if (scalarBad.length) return fail(400, `${scalarBad.join(", ")} must be strings.`);
|
|
674
|
+
for (const [k, v] of Object.entries(params)) if (["summary", "description", "location", "start", "end", "attendees", "status"].includes(k)) event[k] = structuredClone(v);
|
|
675
|
+
event.updated = iso();
|
|
676
|
+
return write(200, EVENTS, structuredClone(event), event);
|
|
677
|
+
}
|
|
678
|
+
if (method === "DELETE") {
|
|
679
|
+
rowsIn(data, EVENTS).splice(rowsIn(data, EVENTS).findIndex((e) => e.id === eventId), 1);
|
|
680
|
+
return write(200, EVENTS, {}, structuredClone(event));
|
|
681
|
+
}
|
|
682
|
+
return void 0;
|
|
683
|
+
}
|
|
684
|
+
function gitlabOperation(method, path, params, _headers, data, sequence) {
|
|
685
|
+
const match = /^\/api\/v4\/(projects|issues|merge_requests)(?:\/(\d+))?(?:\/(notes)(?:\/(\d+))?)?$/.exec(path);
|
|
686
|
+
if (!match) return void 0;
|
|
687
|
+
const [, resource, id, sub] = match;
|
|
688
|
+
const collection = sub === "notes" ? "/api/v4/merge_requests/notes" : `/api/v4/${resource}`;
|
|
689
|
+
const records = rowsIn(data, collection);
|
|
690
|
+
const author = { id: 42, username: "agenttwin-test", name: "AgentTwin Test" };
|
|
691
|
+
if (sub === "notes") {
|
|
692
|
+
if (!id || !resource) return void 0;
|
|
693
|
+
const mr = rowsIn(data, "/api/v4/merge_requests").find((r) => String(r.iid) === id || String(r.id) === id);
|
|
694
|
+
if (!mr) return notFound(collection, { message: "404 Merge Request Not Found" });
|
|
695
|
+
if (method === "GET") return ok(collection, records.filter((n) => n.noteable_iid === mr.iid).map((row) => structuredClone(row)));
|
|
696
|
+
if (method !== "POST") return void 0;
|
|
697
|
+
if (Object.keys(params).some((k) => k !== "body")) return void 0;
|
|
698
|
+
if (typeof params.body !== "string" || !params.body) return badRequest(collection, { message: "body is required" });
|
|
699
|
+
const note = { id: uniqueId(records, 101 + sequence), body: params.body, author, created_at: iso(), updated_at: iso(), noteable_id: mr.id, noteable_iid: mr.iid, noteable_type: "MergeRequest" };
|
|
700
|
+
records.push(note);
|
|
701
|
+
return created(collection, structuredClone(note), note);
|
|
702
|
+
}
|
|
703
|
+
if (method === "GET" && !id) {
|
|
704
|
+
if (Object.keys(params).length) return void 0;
|
|
705
|
+
return ok(collection, records.map((row) => structuredClone(row)));
|
|
706
|
+
}
|
|
707
|
+
if (method === "POST" && !id) {
|
|
708
|
+
if (resource === "projects") {
|
|
709
|
+
if (Object.keys(params).some((k) => !["name", "description", "visibility"].includes(k))) return void 0;
|
|
710
|
+
if (typeof params.name !== "string" || !params.name) return badRequest(collection, { message: "name is missing" });
|
|
711
|
+
if (params.description !== void 0 && str(params.description) === void 0) return void 0;
|
|
712
|
+
const project = { id: uniqueId(records, 1 + sequence), name: params.name, description: str(params.description) ?? "", visibility: str(params.visibility) ?? "private", creator_id: 42, created_at: iso() };
|
|
713
|
+
records.push(project);
|
|
714
|
+
return created(collection, structuredClone(project), project);
|
|
715
|
+
}
|
|
716
|
+
if (resource === "issues") {
|
|
717
|
+
if (Object.keys(params).some((k) => !["title", "description", "project_id", "labels", "assignee_ids"].includes(k))) return void 0;
|
|
718
|
+
const projectId2 = Number(params.project_id);
|
|
719
|
+
if (typeof params.title !== "string" || !params.title || !Number.isInteger(projectId2)) return badRequest(collection, { message: "title and project_id are required" });
|
|
720
|
+
if (!rowsIn(data, "/api/v4/projects").some((p) => String(p.id) === String(projectId2))) return notFound(collection, { message: "404 Project Not Found" });
|
|
721
|
+
if (params.labels !== void 0 && (!Array.isArray(params.labels) || !params.labels.every((l) => typeof l === "string"))) return badRequest(collection, { message: "labels must be an array of strings" });
|
|
722
|
+
const inProject2 = records.filter((r) => r.project_id === projectId2);
|
|
723
|
+
const issue = { id: uniqueId(records, 101 + sequence), iid: inProject2.reduce((max, r) => Math.max(max, Number(r.iid) || 0), 0) + 1, project_id: projectId2, title: params.title, description: str(params.description) ?? null, state: "opened", labels: Array.isArray(params.labels) ? structuredClone(params.labels) : [], author, web_url: null, created_at: iso(), updated_at: iso() };
|
|
724
|
+
records.push(issue);
|
|
725
|
+
return created(collection, structuredClone(issue), issue);
|
|
726
|
+
}
|
|
727
|
+
if (Object.keys(params).some((k) => !["project_id", "source_branch", "target_branch", "title", "description"].includes(k))) return void 0;
|
|
728
|
+
const projectId = Number(params.project_id);
|
|
729
|
+
if (typeof params.title !== "string" || !params.title || !Number.isInteger(projectId) || typeof params.source_branch !== "string" || !params.source_branch || typeof params.target_branch !== "string" || !params.target_branch) return badRequest(collection, { message: "title, project_id, source_branch and target_branch are required" });
|
|
730
|
+
if (!rowsIn(data, "/api/v4/projects").some((p) => String(p.id) === String(projectId))) return notFound(collection, { message: "404 Project Not Found" });
|
|
731
|
+
const inProject = records.filter((r) => r.project_id === projectId);
|
|
732
|
+
const mergeRequest = { id: uniqueId(records, 201 + sequence), iid: inProject.reduce((max, r) => Math.max(max, Number(r.iid) || 0), 0) + 1, project_id: projectId, source_branch: params.source_branch, target_branch: params.target_branch, title: params.title, description: str(params.description) ?? null, state: "opened", author, web_url: null, created_at: iso(), updated_at: iso() };
|
|
733
|
+
records.push(mergeRequest);
|
|
734
|
+
return created(collection, structuredClone(mergeRequest), mergeRequest);
|
|
735
|
+
}
|
|
736
|
+
const primary = id ? rowsIn(data, `/api/v4/${resource}`).find((r) => String(r.id) === id || String(r.iid) === id) : void 0;
|
|
737
|
+
if (!primary) return id ? notFound(collection, { message: `404 ${resource === "merge_requests" ? "Merge Request" : resource.slice(0, -1)} Not Found` }) : void 0;
|
|
738
|
+
if (method === "GET") return ok(collection, structuredClone(primary));
|
|
739
|
+
return void 0;
|
|
740
|
+
}
|
|
741
|
+
var JIRA_TRANSITIONS = { "11": "To Do", "21": "In Progress", "31": "Done" };
|
|
742
|
+
function jiraOperation(method, path, params, _headers, data, sequence) {
|
|
743
|
+
const ISSUE = "/rest/api/3/issue", PROJECT = "/rest/api/3/project";
|
|
744
|
+
const jiraFail = (collection, errorMessages, errors) => ({ status: 400, body: { errorMessages, errors }, collection });
|
|
745
|
+
if (path === "/rest/api/3/search") {
|
|
746
|
+
if (method !== "POST") return void 0;
|
|
747
|
+
if (Object.keys(params).some((k) => !["jql", "maxResults", "fields", "startAt"].includes(k))) return void 0;
|
|
748
|
+
const jql = str(params.jql);
|
|
749
|
+
if (!jql) return jiraFail("/rest/api/3/search", ["The 'jql' parameter is required."], {});
|
|
750
|
+
const parsed = /^\s*project\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*$/.exec(jql);
|
|
751
|
+
if (params.fields !== void 0 && (!Array.isArray(params.fields) || !params.fields.every((f) => typeof f === "string"))) return void 0;
|
|
752
|
+
if (!parsed) return void 0;
|
|
753
|
+
const project = parsed[1] ?? parsed[2] ?? parsed[3];
|
|
754
|
+
const maxResults = boundedInt(params.maxResults, 1, 100);
|
|
755
|
+
if (params.maxResults !== void 0 && maxResults === null) return jiraFail("/rest/api/3/search", ["'maxResults' must be an integer between 1 and 100."], {});
|
|
756
|
+
const issueRows = rowsIn(data, ISSUE).filter((r) => r.projectKey === project);
|
|
757
|
+
return ok(ISSUE, { expand: [], startAt: 0, maxResults: maxResults ?? 50, total: issueRows.length, issues: issueRows.slice(0, maxResults ?? 50).map((row) => structuredClone(row)) });
|
|
758
|
+
}
|
|
759
|
+
if (path === ISSUE) {
|
|
760
|
+
if (method !== "POST") return void 0;
|
|
761
|
+
if (Object.keys(params).some((k) => k !== "fields") || !isObject(params.fields)) return jiraFail(ISSUE, [], { fields: "fields is required" });
|
|
762
|
+
const fields = params.fields;
|
|
763
|
+
if (Object.keys(fields).some((k) => !["project", "issuetype", "summary", "description", "priority", "labels", "assignee"].includes(k))) return void 0;
|
|
764
|
+
const projectKey = isObject(fields.project) ? str(fields.project.key) : void 0;
|
|
765
|
+
const issuetype = isObject(fields.issuetype) ? str(fields.issuetype.name) : void 0;
|
|
766
|
+
if (!projectKey || !issuetype || typeof fields.summary !== "string" || !fields.summary) return jiraFail(ISSUE, [], { summary: projectKey && issuetype ? "Summary is required." : `${!projectKey ? "project key" : "issuetype name"} and summary are required.` });
|
|
767
|
+
if (!rowsIn(data, PROJECT).some((p) => p.key === projectKey)) return jiraFail(ISSUE, [`Project '${projectKey}' does not exist.`], {});
|
|
768
|
+
const issueRows = rowsIn(data, ISSUE);
|
|
769
|
+
const number = issueRows.filter((r) => r.projectKey === projectKey).reduce((max, r) => Math.max(max, Number(r.number) || 0), 0) + 1;
|
|
770
|
+
const issue = { id: uniqueId(issueRows, 1e4 + sequence), key: `${projectKey}-${number}`, projectKey, number, fields: { project: structuredClone(fields.project), issuetype: structuredClone(fields.issuetype), summary: fields.summary, description: fields.description ?? null, labels: Array.isArray(fields.labels) ? structuredClone(fields.labels) : [], status: { name: "Open" }, assignee: null, created: iso() } };
|
|
771
|
+
issueRows.push(issue);
|
|
772
|
+
return created(ISSUE, { id: issue.id, key: issue.key, self: null }, issue);
|
|
773
|
+
}
|
|
774
|
+
const issueMatch = /^\/rest\/api\/3\/issue\/([^/]+)(?:\/(comment|transitions))?$/.exec(path);
|
|
775
|
+
if (issueMatch) {
|
|
776
|
+
const issue = rowsIn(data, ISSUE).find((r) => r.key === issueMatch[1] || String(r.id) === issueMatch[1]);
|
|
777
|
+
if (!issue) return notFound(ISSUE, { errorMessages: ["Issue does not exist or you do not have permission to see it."], errors: {} });
|
|
778
|
+
if (method === "GET" && !issueMatch[2]) return ok(ISSUE, structuredClone(issue));
|
|
779
|
+
if (method === "POST" && issueMatch[2] === "comment") {
|
|
780
|
+
if (Object.keys(params).some((k) => k !== "body")) return void 0;
|
|
781
|
+
if (typeof params.body !== "string" && !isObject(params.body)) return jiraFail(ISSUE, [], { body: "body is required" });
|
|
782
|
+
if (isObject(params.body) && params.body.type !== "doc") return jiraFail(ISSUE, [], { body: "Comment bodies must be Atlassian Document Format objects with type 'doc', or plain strings." });
|
|
783
|
+
const commentRows = rowsIn(data, "/rest/api/3/issue/comments");
|
|
784
|
+
const comment = { id: uniqueId(commentRows, 101 + sequence), issueKey: issue.key, author: { accountId: "agenttwin-test-account", displayName: "AgentTwin Test" }, created: iso(), updated: iso(), body: structuredClone(params.body) };
|
|
785
|
+
commentRows.push(comment);
|
|
786
|
+
return created("/rest/api/3/issue/comments", structuredClone(comment), comment);
|
|
787
|
+
}
|
|
788
|
+
if (method === "POST" && issueMatch[2] === "transitions") {
|
|
789
|
+
if (Object.keys(params).some((k) => !["transition", "fields"].includes(k)) || !isObject(params.transition) || Object.keys(params.transition).some((k) => k !== "id")) return void 0;
|
|
790
|
+
const transitionId = str(params.transition.id);
|
|
791
|
+
if (!transitionId) return void 0;
|
|
792
|
+
if (!JIRA_TRANSITIONS[transitionId]) return jiraFail(ISSUE, [], { transition: "Transition id must be one of 11, 21, 31" });
|
|
793
|
+
issue.fields.status = { name: JIRA_TRANSITIONS[transitionId] };
|
|
794
|
+
return write(200, ISSUE, {}, structuredClone(issue));
|
|
795
|
+
}
|
|
796
|
+
return void 0;
|
|
797
|
+
}
|
|
798
|
+
if (path === PROJECT) {
|
|
799
|
+
if (method !== "GET") return void 0;
|
|
800
|
+
if (Object.keys(params).length) return void 0;
|
|
801
|
+
return ok(PROJECT, rowsIn(data, PROJECT).map((row) => structuredClone(row)));
|
|
802
|
+
}
|
|
803
|
+
const projectMatch = /^\/rest\/api\/3\/project\/([^/]+)$/.exec(path);
|
|
804
|
+
if (projectMatch) {
|
|
805
|
+
if (method !== "GET") return void 0;
|
|
806
|
+
const project = rowsIn(data, PROJECT).find((p) => p.key === projectMatch[1] || String(p.id) === projectMatch[1]);
|
|
807
|
+
if (!project) return notFound(PROJECT, { errorMessages: [`No project could be found with key '${projectMatch[1]}'`], errors: {} });
|
|
808
|
+
return ok(PROJECT, structuredClone(project));
|
|
809
|
+
}
|
|
810
|
+
return void 0;
|
|
811
|
+
}
|
|
812
|
+
var discordError = (collection, code, message) => ({ status: code === 10003 || code === 10004 ? 404 : 400, body: { message, code }, collection });
|
|
813
|
+
function discordOperation(method, path, params, _headers, data, sequence) {
|
|
814
|
+
const CHANNELS = "/api/channels", MESSAGES = "/api/channels/messages", GUILDS = "/api/guilds", MEMBERS = "/api/guilds/members";
|
|
815
|
+
const channelMatch = /^\/api\/channels\/([^/]+)(?:\/(messages)(?:\/([^/]+))?)?$/.exec(path);
|
|
816
|
+
if (channelMatch) {
|
|
817
|
+
const [, channelId, messages, messageId] = channelMatch;
|
|
818
|
+
const channel = rowsIn(data, CHANNELS).find((c) => c.id === channelId);
|
|
819
|
+
if (!channel) return discordError(MESSAGES, 10003, "Unknown Channel");
|
|
820
|
+
if (!messages) {
|
|
821
|
+
if (method !== "GET") return void 0;
|
|
822
|
+
return ok(CHANNELS, structuredClone(channel));
|
|
823
|
+
}
|
|
824
|
+
if (messageId || method !== "GET" && method !== "POST") return void 0;
|
|
825
|
+
if (method === "GET") {
|
|
826
|
+
if (Object.keys(params).length) return void 0;
|
|
827
|
+
return ok(MESSAGES, rowsIn(data, MESSAGES).filter((m) => m.channel_id === channelId).map((row) => structuredClone(row)).reverse());
|
|
828
|
+
}
|
|
829
|
+
if (Object.keys(params).some((k) => !["content", "tts"].includes(k))) return void 0;
|
|
830
|
+
if (typeof params.content !== "string" || !params.content) return discordError(MESSAGES, 50035, "Invalid Form Body: content cannot be empty.");
|
|
831
|
+
if (params.tts !== void 0 && typeof params.tts !== "boolean") return void 0;
|
|
832
|
+
const records = rowsIn(data, MESSAGES);
|
|
833
|
+
const message = { id: String(uniqueId(records, 11e14 + sequence)), channel_id: channelId, content: params.content, author: { id: "discord_at_agenttwin", username: "agenttwin-test", avatar: null }, timestamp: iso(), tts: params.tts === true, embeds: [], pinned: false, type: 0 };
|
|
834
|
+
records.push(message);
|
|
835
|
+
return created(MESSAGES, structuredClone(message), message);
|
|
836
|
+
}
|
|
837
|
+
const guildMatch = /^\/api\/guilds\/([^/]+)(?:\/(members))?$/.exec(path);
|
|
838
|
+
if (guildMatch) {
|
|
839
|
+
const [, guildId, members] = guildMatch;
|
|
840
|
+
const guild = rowsIn(data, GUILDS).find((g) => g.id === guildId);
|
|
841
|
+
if (!guild) return discordError(MEMBERS, 10004, "Unknown Guild");
|
|
842
|
+
if (!members) {
|
|
843
|
+
if (method !== "GET") return void 0;
|
|
844
|
+
return ok(GUILDS, structuredClone(guild));
|
|
845
|
+
}
|
|
846
|
+
if (method !== "GET") return void 0;
|
|
847
|
+
if (Object.keys(params).some((k) => k !== "limit")) return void 0;
|
|
848
|
+
const limit = boundedInt(params.limit, 1, 1e3);
|
|
849
|
+
if (params.limit !== void 0 && limit === null) return discordError(MEMBERS, 50035, "Invalid Form Body: limit must be between 1 and 1000.");
|
|
850
|
+
return ok(MEMBERS, rowsIn(data, MEMBERS).filter((m) => m.guild_id === guildId).slice(0, limit ?? 1e3).map((row) => structuredClone(row)));
|
|
851
|
+
}
|
|
852
|
+
return void 0;
|
|
853
|
+
}
|
|
854
|
+
function twilioOperation(method, path, params, _headers, data, sequence) {
|
|
855
|
+
const match = /^\/2010-04-01\/Accounts\/([^/]+)\/(Messages|Calls)(?:\.json)?$/.exec(path);
|
|
856
|
+
if (!match) return void 0;
|
|
857
|
+
const [, accountSid, resource] = match;
|
|
858
|
+
const collection = `/2010-04-01/${resource}`;
|
|
859
|
+
const records = rowsIn(data, collection);
|
|
860
|
+
const fail = (status, code, message) => ({ status, body: { status, code, message, more_info: "" }, collection });
|
|
861
|
+
const stamp = () => {
|
|
862
|
+
const [date, time] = iso().split("T");
|
|
863
|
+
return `${date} ${time.slice(0, 8)}`;
|
|
864
|
+
};
|
|
865
|
+
if (method === "POST") {
|
|
866
|
+
const allowed = resource === "Messages" ? ["To", "From", "Body"] : ["To", "From", "Url"];
|
|
867
|
+
if (!accountSid || Object.keys(params).some((k) => !allowed.includes(k))) return void 0;
|
|
868
|
+
const missing = allowed.filter((k) => typeof params[k] !== "string" || !params[k]);
|
|
869
|
+
if (missing.length) return fail(400, missing.length === allowed.length ? "21211" : "21606", `No parameter provided for ${allowed.filter((k) => missing.includes(k)).join(", ")}`);
|
|
870
|
+
const prefix = resource === "Messages" ? "SM" : "CA";
|
|
871
|
+
const sid = `${prefix}${sha(`${prefix.toLowerCase()}:${sequence}:${accountSid}`).slice(0, 32)}`;
|
|
872
|
+
const record = resource === "Messages" ? { account_sid: accountSid, api_version: "2010-04-01", sid, messaging_service_sid: null, status: "queued", to: params.To, from: params.From, body: params.Body, num_segments: "1", num_media: "0", direction: "outbound-api", error_code: null, error_message: null, date_created: stamp(), date_updated: stamp(), date_sent: null, price: null, price_unit: null, uri: `/2010-04-01/Accounts/${accountSid}/Messages/${sid}.json` } : { account_sid: accountSid, api_version: "2010-04-01", sid, status: "queued", to: params.To, from: params.From, url: params.Url, direction: "outbound-api", error_code: null, error_message: null, date_created: stamp(), date_updated: stamp(), start_time: null, duration: null, price: null, price_unit: null, uri: `/2010-04-01/Accounts/${accountSid}/Calls/${sid}.json` };
|
|
873
|
+
records.push(record);
|
|
874
|
+
return created(collection, structuredClone(record), record);
|
|
875
|
+
}
|
|
876
|
+
if (method === "GET") {
|
|
877
|
+
if (Object.keys(params).some((k) => !["To", "From"].includes(k))) return void 0;
|
|
878
|
+
let filtered = records;
|
|
879
|
+
if (params.To !== void 0) {
|
|
880
|
+
if (typeof params.To !== "string") return fail(400, "20001", "To must be a string");
|
|
881
|
+
filtered = filtered.filter((r) => r.to === params.To);
|
|
882
|
+
}
|
|
883
|
+
if (params.From !== void 0) {
|
|
884
|
+
if (typeof params.From !== "string") return fail(400, "20001", "From must be a string");
|
|
885
|
+
filtered = filtered.filter((r) => r.from === params.From);
|
|
886
|
+
}
|
|
887
|
+
return ok(collection, { page: 0, page_size: filtered.length, start: 0, end: Math.max(filtered.length - 1, 0), next_page_uri: null, [resource.toLowerCase()]: filtered.map((row) => structuredClone(row)) });
|
|
888
|
+
}
|
|
889
|
+
return void 0;
|
|
890
|
+
}
|
|
891
|
+
var ZENDESK_STATUSES = ["new", "open", "pending", "solved", "closed"];
|
|
892
|
+
function zendeskOperation(method, path, params, _headers, data, sequence) {
|
|
893
|
+
const match = /^\/api\/v2\/(tickets|users|organizations)(?:\/(\d+))?\.json$/.exec(path);
|
|
894
|
+
if (!match) return void 0;
|
|
895
|
+
const [, resource, id] = match;
|
|
896
|
+
const collection = `/api/v2/${resource}`;
|
|
897
|
+
const records = rowsIn(data, collection);
|
|
898
|
+
const envelope = (items) => ({ [resource]: items, next_page: null, previous_page: null, count: items.length });
|
|
899
|
+
if (method === "POST" && !id) {
|
|
900
|
+
if (resource !== "tickets") return void 0;
|
|
901
|
+
if (Object.keys(params).some((k) => k !== "ticket") || !isObject(params.ticket)) return badRequest(collection, { error: "RecordInvalid", description: "Ticket is not valid: ticket is required" });
|
|
902
|
+
const ticketParams = params.ticket;
|
|
903
|
+
if (Object.keys(ticketParams).some((k) => !["subject", "comment", "requester", "status", "priority", "tags", "description", "type"].includes(k))) return void 0;
|
|
904
|
+
if (typeof ticketParams.subject !== "string" || !ticketParams.subject) return badRequest(collection, { error: "RecordInvalid", description: "Ticket is not valid: Subject can't be blank" });
|
|
905
|
+
if (!isObject(ticketParams.comment) || Object.keys(ticketParams.comment).some((k) => k !== "body") || typeof ticketParams.comment.body !== "string" || !ticketParams.comment.body) return badRequest(collection, { error: "RecordInvalid", description: "Ticket is not valid: comment body is required" });
|
|
906
|
+
if (ticketParams.status !== void 0 && !ZENDESK_STATUSES.includes(String(ticketParams.status))) return badRequest(collection, { error: "InvalidStatus", description: "Status must be new, open, pending, solved or closed" });
|
|
907
|
+
const ticket = { id: uniqueId(records, 1 + sequence), subject: ticketParams.subject, description: str(ticketParams.description) ?? ticketParams.comment.body, status: str(ticketParams.status) ?? "new", priority: str(ticketParams.priority) ?? "normal", tags: Array.isArray(ticketParams.tags) ? structuredClone(ticketParams.tags) : [], requester: isObject(ticketParams.requester) ? structuredClone(ticketParams.requester) : null, created_at: iso(), updated_at: iso() };
|
|
908
|
+
records.push(ticket);
|
|
909
|
+
return created(collection, { ticket: structuredClone(ticket), audit: null }, ticket);
|
|
910
|
+
}
|
|
911
|
+
if (method === "GET" && !id) {
|
|
912
|
+
if (Object.keys(params).length) return void 0;
|
|
913
|
+
return ok(collection, envelope(records));
|
|
914
|
+
}
|
|
915
|
+
if (method === "GET" && id && resource === "tickets") {
|
|
916
|
+
const ticket = records.find((r) => String(r.id) === id);
|
|
917
|
+
if (!ticket) return notFound(collection, { error: "RecordNotFound", description: `Not found: ${id}` });
|
|
918
|
+
return ok(collection, { ticket: structuredClone(ticket) });
|
|
919
|
+
}
|
|
920
|
+
if (method === "PUT" && id && resource === "tickets") {
|
|
921
|
+
const ticket = typeof id === "string" ? records.find((r) => String(r.id) === id) : void 0;
|
|
922
|
+
if (!ticket) return notFound(collection, { error: "RecordNotFound", description: `Not found: ${id}` });
|
|
923
|
+
if (Object.keys(params).some((k) => k !== "ticket") || !isObject(params.ticket)) return badRequest(collection, { error: "RecordInvalid", description: "Ticket is not valid: ticket is required" });
|
|
924
|
+
const ticketParams = params.ticket;
|
|
925
|
+
if (Object.keys(ticketParams).some((k) => !["subject", "status", "priority", "tags"].includes(k))) return void 0;
|
|
926
|
+
if (ticketParams.status !== void 0 && !ZENDESK_STATUSES.includes(String(ticketParams.status))) return badRequest(collection, { error: "InvalidStatus", description: "Status must be new, open, pending, solved or closed" });
|
|
927
|
+
if (ticketParams.subject !== void 0 && (typeof ticketParams.subject !== "string" || !ticketParams.subject)) return badRequest(collection, { error: "RecordInvalid", description: "Ticket is not valid: Subject can't be blank" });
|
|
928
|
+
for (const [k, v] of Object.entries(ticketParams)) if (["subject", "status", "priority", "tags"].includes(k)) ticket[k] = structuredClone(v);
|
|
929
|
+
ticket.updated_at = iso();
|
|
930
|
+
return write(200, collection, { ticket: structuredClone(ticket), audit: null }, ticket);
|
|
931
|
+
}
|
|
932
|
+
return void 0;
|
|
933
|
+
}
|
|
934
|
+
var UNSUPPORTED_BODIES = {
|
|
935
|
+
github: { message: "Outside the verified AgentTwin GitHub twin subset", documentation_url: "https://docs.github.com/rest", forwardedToProduction: false },
|
|
936
|
+
linear: { errors: [{ message: "Outside the verified AgentTwin Linear twin subset" }], data: null, forwardedToProduction: false },
|
|
937
|
+
notion: { object: "error", status: 501, code: "unsupported_operation", message: "Outside the verified AgentTwin Notion twin subset", forwardedToProduction: false },
|
|
938
|
+
hubspot: { status: "error", category: "UNSUPPORTED_OPERATION", message: "Outside the verified AgentTwin HubSpot twin subset", forwardedToProduction: false },
|
|
939
|
+
shopify: { errors: "Outside the verified AgentTwin Shopify twin subset", forwardedToProduction: false },
|
|
940
|
+
datadog: { errors: ["Outside the verified AgentTwin Datadog twin subset"], forwardedToProduction: false },
|
|
941
|
+
gmail: { error: { code: 501, status: "UNIMPLEMENTED", message: "Outside the verified AgentTwin Gmail twin subset" }, forwardedToProduction: false },
|
|
942
|
+
"google-calendar": { error: { code: 501, status: "UNIMPLEMENTED", message: "Outside the verified AgentTwin Google Calendar twin subset" }, forwardedToProduction: false },
|
|
943
|
+
gitlab: { message: "Outside the verified AgentTwin GitLab twin subset", forwardedToProduction: false },
|
|
944
|
+
jira: { errorMessages: ["Outside the verified AgentTwin Jira twin subset"], errors: {}, forwardedToProduction: false },
|
|
945
|
+
discord: { message: "Outside the verified AgentTwin Discord twin subset", code: 50101, forwardedToProduction: false },
|
|
946
|
+
twilio: { status: 501, code: "50101", message: "Outside the verified AgentTwin Twilio twin subset", more_info: "", forwardedToProduction: false },
|
|
947
|
+
zendesk: { error: "UnsupportedOperation", description: "Outside the verified AgentTwin Zendesk twin subset", forwardedToProduction: false }
|
|
948
|
+
};
|
|
949
|
+
var REST_ADAPTERS = {
|
|
950
|
+
github: githubOperation,
|
|
951
|
+
linear: linearOperation,
|
|
952
|
+
notion: notionOperation,
|
|
953
|
+
hubspot: hubspotOperation,
|
|
954
|
+
shopify: shopifyOperation,
|
|
955
|
+
datadog: datadogOperation,
|
|
956
|
+
gmail: gmailOperation,
|
|
957
|
+
"google-calendar": googleCalendarOperation,
|
|
958
|
+
gitlab: gitlabOperation,
|
|
959
|
+
jira: jiraOperation,
|
|
960
|
+
discord: discordOperation,
|
|
961
|
+
twilio: twilioOperation,
|
|
962
|
+
zendesk: zendeskOperation
|
|
963
|
+
};
|
|
964
|
+
function restUnsupportedBody(adapter) {
|
|
965
|
+
return UNSUPPORTED_BODIES[adapter];
|
|
966
|
+
}
|
|
967
|
+
var REST_ADAPTER_NAMES = Object.keys(REST_ADAPTERS);
|
|
968
|
+
|
|
969
|
+
// src/runtime/seed.ts
|
|
970
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
971
|
+
var SEED_PATTERN = /^[a-f0-9]{32}$/;
|
|
972
|
+
var normalizeHex = (value) => value.trim().toLowerCase();
|
|
973
|
+
function normalizeSeed(seed) {
|
|
974
|
+
if (seed === void 0 || seed.trim() === "") return randomBytes(16).toString("hex");
|
|
975
|
+
const text = normalizeHex(seed);
|
|
976
|
+
if (!/^[a-f0-9]+$/.test(text) || text.length > 256) throw new Error(`Seed must be up to 256 hex characters or empty to generate; got ${seed.slice(0, 40)}`);
|
|
977
|
+
if (text.length === 32) return text;
|
|
978
|
+
return createHash2("sha256").update(text).digest("hex").slice(0, 32);
|
|
979
|
+
}
|
|
980
|
+
var SeedStream = class {
|
|
981
|
+
constructor(seed, purpose) {
|
|
982
|
+
this.seed = seed;
|
|
983
|
+
this.purpose = purpose;
|
|
984
|
+
if (!SEED_PATTERN.test(seed)) throw new Error(`Invalid seed for stream: ${seed}`);
|
|
985
|
+
if (!purpose) throw new Error("Seed stream purpose must be nonempty");
|
|
986
|
+
}
|
|
987
|
+
seed;
|
|
988
|
+
purpose;
|
|
989
|
+
counter = 0;
|
|
990
|
+
draw() {
|
|
991
|
+
const value = createHash2("sha256").update(`agenttwin-stream|${this.seed}|${this.purpose}|${this.counter}`).digest("hex");
|
|
992
|
+
this.counter++;
|
|
993
|
+
return value;
|
|
994
|
+
}
|
|
995
|
+
/** Uniform float in [0, 1). */
|
|
996
|
+
next() {
|
|
997
|
+
return parseInt(this.draw().slice(0, 13), 16) / 2 ** 52;
|
|
998
|
+
}
|
|
999
|
+
/** Uniform integer in [0, max). */
|
|
1000
|
+
nextInt(max) {
|
|
1001
|
+
if (!Number.isInteger(max) || max < 1) throw new Error("nextInt needs a positive integer max");
|
|
1002
|
+
const limit = Math.floor(2 ** 32 / max) * max;
|
|
1003
|
+
let next;
|
|
1004
|
+
do {
|
|
1005
|
+
next = parseInt(this.draw().slice(0, 8), 16);
|
|
1006
|
+
} while (next >= limit);
|
|
1007
|
+
return next % max;
|
|
1008
|
+
}
|
|
1009
|
+
nextHex(bytes) {
|
|
1010
|
+
if (!Number.isInteger(bytes) || bytes < 1 || bytes > 64) throw new Error("nextHex needs 1\u201364 bytes");
|
|
1011
|
+
return this.draw().slice(0, bytes * 2);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
var FAULT_SCHEDULE_WINDOW = 32;
|
|
1015
|
+
function faultOrdinals(selection, seed) {
|
|
1016
|
+
const count = selection.type === "timeout-after-write" ? 1 : selection.count ?? 1;
|
|
1017
|
+
const stream = new SeedStream(seed, `fault:${selection.type}:${selection.service}:${selection.path}`);
|
|
1018
|
+
const all = Array.from({ length: FAULT_SCHEDULE_WINDOW }, (_, i) => i);
|
|
1019
|
+
for (let i = all.length - 1; i > 0; i--) {
|
|
1020
|
+
const j = stream.nextInt(i + 1);
|
|
1021
|
+
[all[i], all[j]] = [all[j], all[i]];
|
|
1022
|
+
}
|
|
1023
|
+
return all.slice(0, count).sort((a, b) => a - b);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/runtime/http-world.ts
|
|
12
1027
|
var HttpWorld = class {
|
|
13
|
-
constructor(spec) {
|
|
1028
|
+
constructor(spec, seed) {
|
|
14
1029
|
this.spec = spec;
|
|
1030
|
+
this.seed = normalizeSeed(seed);
|
|
15
1031
|
}
|
|
16
1032
|
spec;
|
|
17
1033
|
events = [];
|
|
18
1034
|
state = /* @__PURE__ */ Object.create(null);
|
|
19
1035
|
credentials = /* @__PURE__ */ Object.create(null);
|
|
20
1036
|
unsupported = [];
|
|
1037
|
+
/** The run seed that governed every seeded twin-side decision in this world instance. */
|
|
1038
|
+
seed;
|
|
21
1039
|
servers = [];
|
|
22
1040
|
timers = /* @__PURE__ */ new Set();
|
|
23
1041
|
fired = false;
|
|
24
|
-
|
|
1042
|
+
faultActions = 0;
|
|
1043
|
+
candidateCounts = /* @__PURE__ */ new Map();
|
|
1044
|
+
epoch = 0;
|
|
1045
|
+
replayKeys = /* @__PURE__ */ new Map();
|
|
1046
|
+
sourceContext = new AsyncLocalStorage();
|
|
1047
|
+
record(service, operation, path, detail, record) {
|
|
25
1048
|
if (this.events.length >= 5e4) return;
|
|
26
|
-
|
|
1049
|
+
const source = this.sourceContext.getStore();
|
|
1050
|
+
this.events.push({ sequence: this.events.length + 1, service, operation, path, detail, ...record ? { record: structuredClone(record) } : {}, ...source ? { source } : {} });
|
|
1051
|
+
}
|
|
1052
|
+
rejectUnsupported(service, method, path, detail) {
|
|
1053
|
+
this.unsupported.push(`${service}: ${method} ${path}: ${detail}`);
|
|
1054
|
+
this.record(service, "unsupported", path, detail);
|
|
1055
|
+
}
|
|
1056
|
+
/** Seeded fault scheduling: the k-th matched request of the fault's scope is faulted iff the seeded draw picked ordinal k. */
|
|
1057
|
+
candidateIndex(key) {
|
|
1058
|
+
const next = this.candidateCounts.get(key) ?? 0;
|
|
1059
|
+
this.candidateCounts.set(key, next + 1);
|
|
1060
|
+
return next;
|
|
27
1061
|
}
|
|
28
1062
|
async start() {
|
|
1063
|
+
if (this.servers.length) throw new Error("World is already started");
|
|
1064
|
+
const fault = this.spec.fault;
|
|
29
1065
|
for (const service of this.spec.services) {
|
|
30
|
-
const token = `at_world_${
|
|
1066
|
+
const token = `at_world_${new SeedStream(this.seed, `credential:${service.name}`).nextHex(24)}`;
|
|
1067
|
+
const faultOrdinalsFor = fault && fault.service === service.name ? faultOrdinals(fault, this.seed) : null;
|
|
1068
|
+
const faultKey = `${fault?.type ?? ""}\0${service.name}\0${fault?.path ?? ""}`;
|
|
1069
|
+
const adapterService = service.adapter === "stripe" || service.adapter && REST_ADAPTER_NAMES.includes(service.adapter);
|
|
1070
|
+
const restAdapter = service.adapter && REST_ADAPTER_NAMES.includes(service.adapter) ? REST_ADAPTERS[service.adapter] : void 0;
|
|
1071
|
+
const altAuth = (headers) => service.adapter === "shopify" ? headers["x-shopify-access-token"] === token : service.adapter === "datadog" ? headers["dd-api-key"] === token && headers["dd-application-key"] === token : service.adapter === "discord" ? headers.authorization === `Bot ${token}` : false;
|
|
31
1072
|
const data = /* @__PURE__ */ Object.create(null);
|
|
32
1073
|
this.state[service.name] = data;
|
|
33
1074
|
for (const c of service.collections) data[c.path] = structuredClone(c.seed);
|
|
34
1075
|
const keys = /* @__PURE__ */ new Map();
|
|
35
|
-
|
|
1076
|
+
this.replayKeys.set(service.name, keys);
|
|
1077
|
+
const latency = this.spec.latency && (this.spec.latency.maxMs > this.spec.latency.minMs || this.spec.latency.minMs > 0) ? new SeedStream(this.seed, `latency:${service.name}`) : null;
|
|
1078
|
+
const latencyMs = () => latency && this.spec.latency ? this.spec.latency.minMs + latency.nextInt(this.spec.latency.maxMs - this.spec.latency.minMs + 1) : 0;
|
|
1079
|
+
const server = createServer((req, res) => this.sourceContext.run(parseRequestSource(req.headers["x-agenttwin-source"], this.spec), async () => {
|
|
1080
|
+
const epoch = this.epoch;
|
|
36
1081
|
const send = (code, body) => {
|
|
37
1082
|
if (!res.destroyed) {
|
|
38
|
-
res.writeHead(code, { "content-type": "application/json" });
|
|
1083
|
+
res.writeHead(code, { "content-type": "application/json", ...code === 501 ? { "X-AgentTwin-Unsupported": "true" } : {} });
|
|
39
1084
|
res.end(JSON.stringify(body));
|
|
40
1085
|
}
|
|
41
1086
|
};
|
|
1087
|
+
const respond = (code, body) => {
|
|
1088
|
+
const ms = latencyMs();
|
|
1089
|
+
if (ms <= 0) {
|
|
1090
|
+
send(code, body);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
const timer = setTimeout(() => {
|
|
1094
|
+
this.timers.delete(timer);
|
|
1095
|
+
if (epoch === this.epoch) send(code, body);
|
|
1096
|
+
}, ms);
|
|
1097
|
+
this.timers.add(timer);
|
|
1098
|
+
};
|
|
1099
|
+
const normalPath = (url, method) => {
|
|
1100
|
+
const parsed = new URL(url, "http://localhost");
|
|
1101
|
+
if (service.adapter === "slack") {
|
|
1102
|
+
const m = parsed.pathname.replace(/^\/api\//, "");
|
|
1103
|
+
if (m === "chat.postMessage" && method === "POST") return "/messages";
|
|
1104
|
+
if (m === "conversations.history" && ["GET", "POST"].includes(method ?? "")) return "/messages";
|
|
1105
|
+
if (m === "conversations.list" && ["GET", "POST"].includes(method ?? "")) return "/channels";
|
|
1106
|
+
return parsed.pathname;
|
|
1107
|
+
}
|
|
1108
|
+
return parsed.pathname;
|
|
1109
|
+
};
|
|
1110
|
+
const matchesFault = (target) => adapterService ? target === fault.path || target.startsWith(`${fault.path}/`) || target.endsWith(fault.path) : target === fault.path;
|
|
1111
|
+
const darkFault = () => {
|
|
1112
|
+
if (!fault || !faultOrdinalsFor || fault.type !== "blackhole" && fault.type !== "server-error") return "none";
|
|
1113
|
+
const target = normalPath(req.url ?? "/", req.method);
|
|
1114
|
+
if (!matchesFault(target)) return "none";
|
|
1115
|
+
const ordinal = this.candidateIndex(faultKey);
|
|
1116
|
+
if (!faultOrdinalsFor.includes(ordinal)) return "none";
|
|
1117
|
+
this.faultActions++;
|
|
1118
|
+
return fault.type;
|
|
1119
|
+
};
|
|
42
1120
|
try {
|
|
43
|
-
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
1121
|
+
if (req.headers.authorization !== `Bearer ${token}` && !altAuth(req.headers)) {
|
|
44
1122
|
this.record(service.name, "auth-denied", req.url ?? "", "invalid test credential");
|
|
45
|
-
|
|
1123
|
+
respond(401, { error: "invalid_test_credential" });
|
|
46
1124
|
return;
|
|
47
1125
|
}
|
|
48
1126
|
if (this.events.length >= 5e4 || this.unsupported.length >= 1e3) {
|
|
49
|
-
|
|
1127
|
+
respond(429, { error: "world_capacity" });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const darkness = darkFault();
|
|
1131
|
+
if (darkness === "blackhole") {
|
|
1132
|
+
this.record(service.name, "fault", fault.path, `blackhole: request dropped (matched ordinal ${this.candidateCounts.get(faultKey) - 1} of the seeded schedule); nothing committed`);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (darkness === "server-error") {
|
|
1136
|
+
const windowMs = fault.windowMs ?? 0;
|
|
1137
|
+
const delay = windowMs <= 0 ? 0 : new SeedStream(this.seed, `server-error:${service.name}:${fault.path}`).nextInt(windowMs + 1);
|
|
1138
|
+
this.record(service.name, "fault", fault.path, `server-error fault: responding 500 after ${delay}ms; nothing committed`);
|
|
1139
|
+
const respond500 = () => respond(500, { error: { type: "api_error", message: "server_error fault" } });
|
|
1140
|
+
if (delay <= 0) {
|
|
1141
|
+
respond500();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const timer = setTimeout(() => {
|
|
1145
|
+
this.timers.delete(timer);
|
|
1146
|
+
if (epoch === this.epoch) respond500();
|
|
1147
|
+
}, delay);
|
|
1148
|
+
this.timers.add(timer);
|
|
50
1149
|
return;
|
|
51
1150
|
}
|
|
52
1151
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -54,13 +1153,13 @@ var HttpWorld = class {
|
|
|
54
1153
|
const slack = service.adapter === "slack";
|
|
55
1154
|
const slackMethod = path.replace(/^\/api\//, "");
|
|
56
1155
|
let payload;
|
|
57
|
-
if (req.method
|
|
1156
|
+
if (["POST", "PUT", "PATCH"].includes(req.method ?? "")) {
|
|
58
1157
|
const chunks = [];
|
|
59
1158
|
let bytes = 0;
|
|
60
1159
|
for await (const chunk of req) {
|
|
61
1160
|
bytes += chunk.length;
|
|
62
1161
|
if (bytes > 1024 * 1024) {
|
|
63
|
-
|
|
1162
|
+
respond(413, { error: "body_too_large" });
|
|
64
1163
|
return;
|
|
65
1164
|
}
|
|
66
1165
|
chunks.push(Buffer.from(chunk));
|
|
@@ -68,63 +1167,116 @@ var HttpWorld = class {
|
|
|
68
1167
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
69
1168
|
let parsed;
|
|
70
1169
|
try {
|
|
71
|
-
parsed = slack && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
1170
|
+
parsed = (slack || service.adapter === "stripe") && req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
|
|
72
1171
|
} catch {
|
|
73
|
-
|
|
1172
|
+
respond(400, { error: "expected_json_object" });
|
|
74
1173
|
return;
|
|
75
1174
|
}
|
|
76
1175
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
77
|
-
|
|
1176
|
+
respond(400, { error: "expected_json_object" });
|
|
78
1177
|
return;
|
|
79
1178
|
}
|
|
80
1179
|
payload = parsed;
|
|
81
1180
|
}
|
|
1181
|
+
if (epoch !== this.epoch) {
|
|
1182
|
+
respond(409, { error: "world_reset" });
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (service.adapter === "stripe" || restAdapter) {
|
|
1186
|
+
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
1187
|
+
const keyHeader2 = req.headers["idempotency-key"];
|
|
1188
|
+
const key2 = req.method === "POST" && typeof keyHeader2 === "string" ? `${path}:${keyHeader2}` : void 0;
|
|
1189
|
+
const signature2 = JSON.stringify(params);
|
|
1190
|
+
const previous2 = key2 ? keys.get(key2) : void 0;
|
|
1191
|
+
if (previous2) {
|
|
1192
|
+
if (previous2.body !== signature2) {
|
|
1193
|
+
respond(400, { error: { type: "idempotency_error", message: "Keys for idempotent requests can only be used with the same parameters" } });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
this.record(service.name, "replay", path, "same-key response replay", previous2.record);
|
|
1197
|
+
respond(previous2.status ?? 200, previous2.record);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const result = restAdapter ? restAdapter(req.method ?? "", path, params, req.headers, data, this.events.length + 1) : stripeOperation(req.method ?? "", path, params, data, this.events.length + 1);
|
|
1201
|
+
if (!result) {
|
|
1202
|
+
this.rejectUnsupported(service.name, req.method ?? "", path, `unsupported ${service.adapter} operation or parameters`);
|
|
1203
|
+
respond(501, restAdapter ? restUnsupportedBody(service.adapter) : { error: { type: "invalid_request_error", code: "unsupported_operation", message: "Outside the verified AgentTwin Stripe subset" }, forwardedToProduction: false });
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (result.operation) this.record(service.name, result.operation, result.collection, path, "record" in result ? result.record : result.body);
|
|
1207
|
+
if (key2 && (result.status === 200 || result.status === 201)) keys.set(key2, { body: signature2, record: structuredClone(result.body), status: result.status });
|
|
1208
|
+
const faultWritesMatch = fault?.type === "duplicate-write" && fault.service === service.name && result.operation === "write" && !key2 && matchesFault(result.collection);
|
|
1209
|
+
const duplicate2 = faultWritesMatch && faultOrdinalsFor !== null && faultOrdinalsFor.includes(this.candidateIndex(faultKey));
|
|
1210
|
+
if (duplicate2) {
|
|
1211
|
+
const second = restAdapter ? restAdapter(req.method ?? "", path, params, req.headers, data, this.events.length + 1) : stripeOperation(req.method ?? "", path, params, data, this.events.length + 1);
|
|
1212
|
+
if (second?.operation === "write") {
|
|
1213
|
+
this.record(service.name, second.operation, second.collection, path, "record" in second ? second.record : second.body);
|
|
1214
|
+
this.faultActions++;
|
|
1215
|
+
this.record(service.name, "fault", fault.path, `duplicate-write: a single write was delivered twice (${result.collection})`);
|
|
1216
|
+
} else if (second) {
|
|
1217
|
+
this.record(service.name, "fault", fault.path, `duplicate-write: second delivery replaced by ${second.operation ?? "failure"}`);
|
|
1218
|
+
respond(second.status, second.body);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (!this.fired && result.operation === "write" && fault?.type === "timeout-after-write" && fault.service === service.name && fault.path === result.collection) {
|
|
1223
|
+
this.fired = true;
|
|
1224
|
+
this.faultActions++;
|
|
1225
|
+
this.record(service.name, "fault", result.collection, `write committed; response delayed ${fault.delayMs}ms`);
|
|
1226
|
+
const timer = setTimeout(() => {
|
|
1227
|
+
this.timers.delete(timer);
|
|
1228
|
+
respond(result.status, result.body);
|
|
1229
|
+
}, fault.delayMs);
|
|
1230
|
+
this.timers.add(timer);
|
|
1231
|
+
} else respond(result.status, result.body);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
82
1234
|
if (slack) {
|
|
83
1235
|
const params = payload ?? Object.fromEntries(url.searchParams);
|
|
84
1236
|
const allowed = slackMethod === "chat.postMessage" ? ["channel", "text"] : slackMethod === "conversations.history" ? ["channel"] : [];
|
|
85
1237
|
if (Object.keys(params).some((k) => !allowed.includes(k))) {
|
|
86
|
-
this.
|
|
87
|
-
|
|
1238
|
+
this.rejectUnsupported(service.name, req.method ?? "", url.pathname, `unsupported parameters for ${slackMethod}`);
|
|
1239
|
+
respond(501, { ok: false, error: "unsupported_parameters", forwardedToProduction: false });
|
|
88
1240
|
return;
|
|
89
1241
|
}
|
|
90
1242
|
if (slackMethod === "chat.postMessage" && req.method === "POST") path = "/messages";
|
|
91
1243
|
else if (slackMethod === "conversations.history" && ["GET", "POST"].includes(req.method ?? "")) path = "/messages";
|
|
92
1244
|
else if (slackMethod === "conversations.list" && ["GET", "POST"].includes(req.method ?? "")) path = "/channels";
|
|
93
1245
|
else {
|
|
94
|
-
this.
|
|
95
|
-
|
|
1246
|
+
this.rejectUnsupported(service.name, req.method ?? "", url.pathname, "unsupported operation");
|
|
1247
|
+
respond(501, { ok: false, error: "unsupported_operation", forwardedToProduction: false });
|
|
96
1248
|
return;
|
|
97
1249
|
}
|
|
98
1250
|
}
|
|
99
1251
|
if (!Object.hasOwn(data, path) || !["GET", "POST"].includes(req.method ?? "")) {
|
|
100
|
-
this.
|
|
101
|
-
|
|
1252
|
+
this.rejectUnsupported(service.name, req.method ?? "", path, "unsupported operation");
|
|
1253
|
+
respond(501, { error: "unsupported_operation", forwardedToProduction: false });
|
|
102
1254
|
return;
|
|
103
1255
|
}
|
|
104
1256
|
if (req.method === "GET" || slack && slackMethod !== "chat.postMessage") {
|
|
105
1257
|
this.record(service.name, "read", path, `${data[path].length} records`);
|
|
106
|
-
if (slack && path === "/channels")
|
|
1258
|
+
if (slack && path === "/channels") respond(200, { ok: true, channels: data[path], response_metadata: { next_cursor: "" } });
|
|
107
1259
|
else if (slack) {
|
|
108
1260
|
const channel = payload?.channel ?? url.searchParams.get("channel");
|
|
109
|
-
if (!data["/channels"].some((c) => c.id === channel))
|
|
110
|
-
else
|
|
111
|
-
} else
|
|
1261
|
+
if (!data["/channels"].some((c) => c.id === channel)) respond(200, { ok: false, error: "channel_not_found" });
|
|
1262
|
+
else respond(200, { ok: true, messages: data[path].filter((m) => m.channel === channel), has_more: false });
|
|
1263
|
+
} else respond(200, { data: data[path] });
|
|
112
1264
|
return;
|
|
113
1265
|
}
|
|
114
1266
|
const body = payload;
|
|
115
1267
|
if (slack) {
|
|
116
1268
|
const message = body;
|
|
117
1269
|
if (!data["/channels"].some((c) => c.id === message.channel)) {
|
|
118
|
-
|
|
1270
|
+
respond(200, { ok: false, error: "channel_not_found" });
|
|
119
1271
|
return;
|
|
120
1272
|
}
|
|
121
1273
|
if (typeof message.text !== "string" || !message.text) {
|
|
122
|
-
|
|
1274
|
+
respond(200, { ok: false, error: "no_text" });
|
|
123
1275
|
return;
|
|
124
1276
|
}
|
|
125
1277
|
}
|
|
126
1278
|
if (data[path].length >= 1e4 || this.events.length >= 5e4) {
|
|
127
|
-
|
|
1279
|
+
respond(429, { error: "world_capacity" });
|
|
128
1280
|
return;
|
|
129
1281
|
}
|
|
130
1282
|
const keyHeader = slack ? void 0 : req.headers["idempotency-key"];
|
|
@@ -133,13 +1285,15 @@ var HttpWorld = class {
|
|
|
133
1285
|
const previous = key ? keys.get(key) : void 0;
|
|
134
1286
|
if (previous) {
|
|
135
1287
|
if (previous.body !== signature) {
|
|
136
|
-
|
|
1288
|
+
respond(409, { error: "idempotency_conflict" });
|
|
137
1289
|
return;
|
|
138
1290
|
}
|
|
139
|
-
this.record(service.name, "replay", path, "same-key response replay");
|
|
140
|
-
|
|
1291
|
+
this.record(service.name, "replay", path, "same-key response replay", previous.record);
|
|
1292
|
+
respond(200, previous.record);
|
|
141
1293
|
return;
|
|
142
1294
|
}
|
|
1295
|
+
const duplicate = fault?.type === "duplicate-write" && fault.service === service.name && fault.path === path && !key && faultOrdinalsFor !== null && faultOrdinalsFor.includes(this.candidateIndex(faultKey));
|
|
1296
|
+
if (duplicate) this.faultActions++;
|
|
143
1297
|
let index = data[path].length + 1;
|
|
144
1298
|
while (data[path].some((r) => r.id === `${service.name}_${index}`)) index++;
|
|
145
1299
|
const row = { ...body, id: `${service.name}_${index}` };
|
|
@@ -147,25 +1301,35 @@ var HttpWorld = class {
|
|
|
147
1301
|
const reply = slack ? { ok: true, channel: row.channel, ts: row.ts, message: row } : row;
|
|
148
1302
|
data[path].push(row);
|
|
149
1303
|
if (key) keys.set(key, { body: signature, record: row });
|
|
150
|
-
this.record(service.name, "write", path, String(row.id));
|
|
151
|
-
|
|
152
|
-
|
|
1304
|
+
this.record(service.name, "write", path, String(row.id), row);
|
|
1305
|
+
if (duplicate) {
|
|
1306
|
+
index++;
|
|
1307
|
+
while (data[path].some((r) => r.id === `${service.name}_${index}`)) index++;
|
|
1308
|
+
const second = { ...body, id: `${service.name}_${index}` };
|
|
1309
|
+
if (slack) second.ts = `${index}.000001`;
|
|
1310
|
+
data[path].push(second);
|
|
1311
|
+
this.record(service.name, "write", path, String(second.id), second);
|
|
1312
|
+
this.record(service.name, "fault", fault.path, `duplicate-write: one write committed as two records (${row.id}, ${second.id})`);
|
|
1313
|
+
}
|
|
1314
|
+
const faultResponse = slack ? 200 : 201;
|
|
1315
|
+
if (!this.fired && fault?.type === "timeout-after-write" && fault.service === service.name && fault.path === path) {
|
|
153
1316
|
this.fired = true;
|
|
1317
|
+
this.faultActions++;
|
|
154
1318
|
this.record(service.name, "fault", path, `write committed; response delayed ${fault.delayMs}ms`);
|
|
155
1319
|
const timer = setTimeout(() => {
|
|
156
1320
|
this.timers.delete(timer);
|
|
157
|
-
|
|
1321
|
+
respond(faultResponse, reply);
|
|
158
1322
|
}, fault.delayMs);
|
|
159
1323
|
this.timers.add(timer);
|
|
160
|
-
} else
|
|
1324
|
+
} else respond(faultResponse, reply);
|
|
161
1325
|
} catch {
|
|
162
|
-
|
|
1326
|
+
respond(500, { error: "world_request_error" });
|
|
163
1327
|
}
|
|
164
|
-
});
|
|
1328
|
+
}));
|
|
165
1329
|
server.requestTimeout = 1e4;
|
|
166
|
-
await new Promise((
|
|
1330
|
+
await new Promise((resolve2, reject) => {
|
|
167
1331
|
server.once("error", reject);
|
|
168
|
-
server.listen(0, "127.0.0.1",
|
|
1332
|
+
server.listen(0, "127.0.0.1", resolve2);
|
|
169
1333
|
});
|
|
170
1334
|
this.servers.push(server);
|
|
171
1335
|
this.credentials[service.name] = { endpoint: `http://127.0.0.1:${server.address().port}`, token };
|
|
@@ -174,26 +1338,70 @@ var HttpWorld = class {
|
|
|
174
1338
|
}
|
|
175
1339
|
assertions() {
|
|
176
1340
|
return this.spec.assertions.map((a) => {
|
|
177
|
-
const
|
|
178
|
-
return { ...a, actual:
|
|
1341
|
+
const rows2 = this.state[a.service][a.path].filter((row) => !a.where || Object.entries(a.where).every(([k, v]) => isDeepStrictEqual(row[k], v)));
|
|
1342
|
+
return { ...a, actual: rows2.length, passed: rows2.length === a.count };
|
|
179
1343
|
});
|
|
180
1344
|
}
|
|
181
1345
|
get faultTriggered() {
|
|
182
|
-
return this.
|
|
1346
|
+
return this.faultActions > 0;
|
|
1347
|
+
}
|
|
1348
|
+
snapshot() {
|
|
1349
|
+
return structuredClone(this.state);
|
|
1350
|
+
}
|
|
1351
|
+
quiesce() {
|
|
1352
|
+
this.epoch++;
|
|
1353
|
+
for (const server of this.servers) server.closeAllConnections();
|
|
1354
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
1355
|
+
this.timers.clear();
|
|
1356
|
+
}
|
|
1357
|
+
reset() {
|
|
1358
|
+
this.quiesce();
|
|
1359
|
+
for (const service of this.spec.services) {
|
|
1360
|
+
for (const collection of service.collections) this.state[service.name][collection.path] = structuredClone(collection.seed);
|
|
1361
|
+
this.replayKeys.get(service.name)?.clear();
|
|
1362
|
+
}
|
|
1363
|
+
this.events.length = 0;
|
|
1364
|
+
this.unsupported.length = 0;
|
|
1365
|
+
this.fired = false;
|
|
1366
|
+
this.faultActions = 0;
|
|
1367
|
+
this.candidateCounts.clear();
|
|
1368
|
+
return this.snapshot();
|
|
183
1369
|
}
|
|
184
1370
|
async close() {
|
|
1371
|
+
this.epoch++;
|
|
185
1372
|
for (const timer of this.timers) clearTimeout(timer);
|
|
186
1373
|
this.timers.clear();
|
|
187
|
-
await Promise.all(this.servers.map((server) => new Promise((
|
|
188
|
-
server.close(() =>
|
|
1374
|
+
await Promise.all(this.servers.map((server) => new Promise((resolve2) => {
|
|
1375
|
+
server.close(() => resolve2());
|
|
189
1376
|
server.closeAllConnections();
|
|
190
1377
|
})));
|
|
1378
|
+
this.servers.length = 0;
|
|
191
1379
|
}
|
|
192
1380
|
};
|
|
193
1381
|
|
|
194
1382
|
// src/runtime/spec.ts
|
|
1383
|
+
var ADAPTER_COLLECTIONS = {
|
|
1384
|
+
slack: ["/messages", "/channels"],
|
|
1385
|
+
stripe: ["/v1/customers", "/v1/payment_intents"],
|
|
1386
|
+
github: ["/repos", "/issues", "/comments", "/pulls", "/check-runs"],
|
|
1387
|
+
linear: ["/issues", "/projects", "/users", "/comments"],
|
|
1388
|
+
notion: ["/pages", "/databases", "/blocks"],
|
|
1389
|
+
hubspot: ["/crm/v3/objects/contacts", "/crm/v3/objects/companies", "/crm/v3/objects/deals", "/crm/v3/objects/tickets"],
|
|
1390
|
+
shopify: ["/admin/products", "/admin/orders", "/admin/customers"],
|
|
1391
|
+
datadog: ["/monitors", "/events", "/metrics"],
|
|
1392
|
+
gmail: ["/gmail/v1/users/me/messages", "/gmail/v1/users/me/labels"],
|
|
1393
|
+
"google-calendar": ["/calendar/v3/users/me/calendarList", "/calendar/v3/calendars/primary/events"],
|
|
1394
|
+
gitlab: ["/api/v4/projects", "/api/v4/issues", "/api/v4/merge_requests", "/api/v4/merge_requests/notes"],
|
|
1395
|
+
jira: ["/rest/api/3/issue", "/rest/api/3/issue/comments", "/rest/api/3/project"],
|
|
1396
|
+
discord: ["/api/channels", "/api/channels/messages", "/api/guilds", "/api/guilds/members"],
|
|
1397
|
+
twilio: ["/2010-04-01/Messages", "/2010-04-01/Calls"],
|
|
1398
|
+
zendesk: ["/api/v2/tickets", "/api/v2/users", "/api/v2/organizations"]
|
|
1399
|
+
};
|
|
1400
|
+
var FAULT_TYPES = ["timeout-after-write", "blackhole", "server-error", "duplicate-write"];
|
|
1401
|
+
var PROPERTY_KINDS = ["unique-per-act", "no-loss", "effect-count"];
|
|
195
1402
|
var object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
196
1403
|
var namePattern = /^[a-z][a-z0-9_]{0,39}$/;
|
|
1404
|
+
var inRangeInt = (x, min, max) => Number.isInteger(x) && Number(x) >= min && Number(x) <= max;
|
|
197
1405
|
function parseWorld(value) {
|
|
198
1406
|
const invalid = (message) => {
|
|
199
1407
|
throw new Error(`Invalid world: ${message}`);
|
|
@@ -212,7 +1420,7 @@ function parseWorld(value) {
|
|
|
212
1420
|
if (!object(service) || typeof service.name !== "string" || !namePattern.test(service.name) || services.has(service.name)) invalid("service names must be unique lowercase identifiers");
|
|
213
1421
|
const item = service;
|
|
214
1422
|
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");
|
|
1423
|
+
if (item.adapter !== void 0 && !["json-collection", "slack", "stripe", "github", "linear", "notion", "hubspot", "shopify", "datadog", "gmail", "google-calendar", "gitlab", "jira", "discord", "twilio", "zendesk"].includes(String(item.adapter))) invalid("unsupported service adapter");
|
|
216
1424
|
const paths = /* @__PURE__ */ new Set();
|
|
217
1425
|
for (const entry of item.collections) {
|
|
218
1426
|
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");
|
|
@@ -220,7 +1428,8 @@ function parseWorld(value) {
|
|
|
220
1428
|
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
1429
|
paths.add(collection.path);
|
|
222
1430
|
}
|
|
223
|
-
|
|
1431
|
+
const required = ADAPTER_COLLECTIONS[item.adapter];
|
|
1432
|
+
if (required && (paths.size !== required.length || !required.every((p) => paths.has(p)))) invalid(`${item.adapter} adapter requires exactly ${required.join(", ")} collections`);
|
|
224
1433
|
services.set(item.name, paths);
|
|
225
1434
|
}
|
|
226
1435
|
const known = (entry) => typeof entry.service === "string" && typeof entry.path === "string" && services.get(entry.service)?.has(entry.path);
|
|
@@ -228,6 +1437,19 @@ function parseWorld(value) {
|
|
|
228
1437
|
for (const assertion of v.assertions) {
|
|
229
1438
|
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
1439
|
}
|
|
1440
|
+
const policyIds = /* @__PURE__ */ new Set();
|
|
1441
|
+
for (const field of ["forbiddenEffects", "preservedState"]) {
|
|
1442
|
+
if (v[field] === void 0) continue;
|
|
1443
|
+
if (!Array.isArray(v[field]) || v[field].length > 100) invalid(`${field} must contain at most 100 checks`);
|
|
1444
|
+
for (const entry of v[field]) {
|
|
1445
|
+
if (!object(entry) || typeof entry.id !== "string" || !namePattern.test(entry.id) || policyIds.has(entry.id) || !known(entry) || entry.where !== void 0 && !object(entry.where)) invalid(`${field} requires unique ids, known collections and an optional where object`);
|
|
1446
|
+
const item = entry;
|
|
1447
|
+
if (field === "forbiddenEffects" && !["write", "read", "replay"].includes(String(item.operation))) invalid("forbiddenEffects operation must be write, read or replay");
|
|
1448
|
+
if (Object.keys(item).some((k) => !["id", "service", "path", "where", ...field === "forbiddenEffects" ? ["operation"] : []].includes(k))) invalid(`unknown ${field} field`);
|
|
1449
|
+
policyIds.add(item.id);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (v.scenario !== void 0 && (!object(v.scenario) || typeof v.scenario.id !== "string" || !namePattern.test(v.scenario.id) || !Number.isSafeInteger(v.scenario.version) || Number(v.scenario.version) < 1 || typeof v.scenario.digest !== "string" || !/^[a-f0-9]{64}$/.test(v.scenario.digest) || Object.keys(v.scenario).some((k) => !["id", "version", "digest"].includes(k)))) invalid("scenario requires id, positive version and SHA-256 digest");
|
|
231
1453
|
if (v.postgres !== void 0) {
|
|
232
1454
|
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
1455
|
for (const a of v.postgres.assertions) {
|
|
@@ -240,17 +1462,436 @@ function parseWorld(value) {
|
|
|
240
1462
|
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
1463
|
}
|
|
242
1464
|
}
|
|
243
|
-
if (v.fault !== void 0
|
|
244
|
-
|
|
1465
|
+
if (v.fault !== void 0) {
|
|
1466
|
+
if (!object(v.fault) || !known(v.fault) || !FAULT_TYPES.includes(v.fault.type)) invalid("fault requires an existing service/path and a supported type: timeout-after-write, blackhole, server-error or duplicate-write");
|
|
1467
|
+
const f = v.fault;
|
|
1468
|
+
const allowed = ["service", "path", "type"];
|
|
1469
|
+
const count = f.count, windowMs = f.windowMs;
|
|
1470
|
+
if (f.type === "timeout-after-write") {
|
|
1471
|
+
if (!inRangeInt(f.delayMs, 1, 1e4)) invalid("timeout-after-write fault requires delayMs 1\u201310000; the delay is declared application-visible behavior, never seeded");
|
|
1472
|
+
allowed.push("delayMs");
|
|
1473
|
+
} else if (f.delayMs !== void 0) invalid(`${f.type} fault does not take delayMs`);
|
|
1474
|
+
if (f.type === "server-error") {
|
|
1475
|
+
if (!inRangeInt(count, 1, 100)) invalid("server-error fault requires count 1\u2013100 (how many matching requests are rejected)");
|
|
1476
|
+
if (windowMs !== void 0 && !inRangeInt(windowMs, 0, 1e4)) invalid("server-error windowMs must be 0\u201310000 (the seeded upper delay before the rejection response)");
|
|
1477
|
+
allowed.push("count", "windowMs");
|
|
1478
|
+
} else {
|
|
1479
|
+
if (!["blackhole", "duplicate-write"].includes(f.type) && windowMs !== void 0) invalid(`${f.type} fault does not take windowMs`);
|
|
1480
|
+
if (["blackhole", "duplicate-write"].includes(f.type) && count !== void 0 && !inRangeInt(count, 1, 100)) invalid(`${f.type} fault count must be 1\u2013100`);
|
|
1481
|
+
if (["blackhole", "duplicate-write"].includes(f.type)) allowed.push("count");
|
|
1482
|
+
}
|
|
1483
|
+
if (Object.keys(f).some((k) => !allowed.includes(k))) invalid(`unknown fault field for ${f.type}`);
|
|
1484
|
+
}
|
|
1485
|
+
if (v.properties !== void 0) {
|
|
1486
|
+
if (!Array.isArray(v.properties) || !v.properties.length || v.properties.length > 50) invalid("properties must contain 1\u201350 named invariants");
|
|
1487
|
+
for (const raw of v.properties) {
|
|
1488
|
+
const p = raw;
|
|
1489
|
+
if (!p || !known(p)) invalid("properties need existing service/path collections and closed kinds");
|
|
1490
|
+
if (typeof p.id !== "string" || !namePattern.test(p.id)) invalid("property id must be a lowercase identifier");
|
|
1491
|
+
if (policyIds.has(p.id)) invalid("property ids must be unique ids across the world's policies");
|
|
1492
|
+
if (!PROPERTY_KINDS.includes(p.kind)) invalid(`unknown property kind: use one of ${PROPERTY_KINDS.join(", ")}`);
|
|
1493
|
+
const params = object(p.params) ? p.params : {};
|
|
1494
|
+
if (p.kind === "unique-per-act") {
|
|
1495
|
+
const field = params.field;
|
|
1496
|
+
if (typeof field !== "string" || !field || field.length > 64 || Object.keys(params).some((k) => k !== "field")) invalid(`property "${p.id}" (unique-per-act) requires params.field (a record field name), nothing else`);
|
|
1497
|
+
} else if (p.kind === "no-loss") {
|
|
1498
|
+
if (p.params !== void 0 && (!object(params) || !object(params.where) || Object.keys(params).some((k) => k !== "where"))) invalid(`property "${p.id}" (no-loss) takes only an optional params.where object`);
|
|
1499
|
+
} else {
|
|
1500
|
+
if (!object(params) || !inRangeInt(params.count, 0, 1e5)) invalid(`property "${p.id}" (effect-count) requires params.count 0\u2013100000`);
|
|
1501
|
+
if (params.operation !== void 0 && !["write", "read", "replay"].includes(String(params.operation))) invalid(`property "${p.id}" (effect-count) operation must be write, read or replay`);
|
|
1502
|
+
if (params.where !== void 0 && !object(params.where)) invalid(`property "${p.id}" (effect-count) params.where must be an object`);
|
|
1503
|
+
if (Object.keys(params).some((k) => !["count", "operation", "where"].includes(k))) invalid(`property "${p.id}" (effect-count) takes only count, operation and where`);
|
|
1504
|
+
}
|
|
1505
|
+
policyIds.add(p.id);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (v.latency !== void 0) {
|
|
1509
|
+
const allowed = ["minMs", "maxMs"];
|
|
1510
|
+
if (!object(v.latency) || Object.keys(v.latency).some((k) => !allowed.includes(k))) invalid("latency requires minMs and maxMs");
|
|
1511
|
+
const l = v.latency;
|
|
1512
|
+
if (!inRangeInt(l.minMs, 0, 1e4) || !inRangeInt(l.maxMs, 0, 1e4) || Number(l.minMs) > Number(l.maxMs)) invalid("latency requires minMs and maxMs in 0\u201310000 with minMs <= maxMs");
|
|
1513
|
+
}
|
|
1514
|
+
if (v.browser !== void 0) {
|
|
1515
|
+
if (!object(v.browser) || typeof v.browser.baseUrl !== "string") invalid("browser requires baseUrl");
|
|
1516
|
+
const browser = v.browser;
|
|
1517
|
+
let u;
|
|
1518
|
+
try {
|
|
1519
|
+
u = new URL(String(v.browser.baseUrl));
|
|
1520
|
+
} catch {
|
|
1521
|
+
invalid("browser baseUrl must be a loopback HTTP URL");
|
|
1522
|
+
}
|
|
1523
|
+
if (u.protocol !== "http:" || u.hostname !== "127.0.0.1" || !u.port || u.username || u.password || u.pathname !== "/" || u.search || u.hash) invalid("browser baseUrl must be http://127.0.0.1:<port>");
|
|
1524
|
+
if (!Array.isArray(browser.steps) || !browser.steps.length || browser.steps.length > 100 || Object.keys(browser).some((k) => !["baseUrl", "steps"].includes(k))) invalid("browser needs 1\u2013100 steps");
|
|
1525
|
+
for (const raw of browser.steps) {
|
|
1526
|
+
const step = raw;
|
|
1527
|
+
if (!object(step) || !["goto", "click", "fill", "expectText"].includes(String(step.action))) invalid("unsupported browser action");
|
|
1528
|
+
if (step.action === "goto") {
|
|
1529
|
+
if (typeof step.path !== "string" || !step.path.startsWith("/") || step.path.startsWith("//") || step.path.includes("\\") || Object.keys(step).some((k) => !["action", "path"].includes(k))) invalid("goto requires a relative application path");
|
|
1530
|
+
} else {
|
|
1531
|
+
if (typeof step.selector !== "string" || !step.selector || step.action === "fill" && typeof step.value !== "string" || step.action === "expectText" && typeof step.text !== "string" || Object.keys(step).some((k) => !["action", "selector", ...step.action === "fill" ? ["value"] : step.action === "expectText" ? ["text"] : []].includes(k))) invalid("invalid browser selector/action parameters");
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
if (v.background !== void 0 && (!object(v.background) || !known(v.background) || !Number.isSafeInteger(v.background.count) || Number(v.background.count) < 0 || !Number.isInteger(v.background.timeoutMs) || Number(v.background.timeoutMs) < 1 || Number(v.background.timeoutMs) > Number(v.timeoutSeconds) * 1e3 || v.background.where !== void 0 && !object(v.background.where) || Object.keys(v.background).some((k) => !["service", "path", "count", "where", "timeoutMs"].includes(k)))) invalid("background requires a known collection, expected count, optional where, and bounded timeoutMs");
|
|
1536
|
+
const keys = ["schema", "name", "workflowId", "files", "command", "timeoutSeconds", "browser", "background", "services", "assertions", "forbiddenEffects", "preservedState", "properties", "scenario", "postgres", "redis", "fault", "latency"];
|
|
245
1537
|
if (Object.keys(v).some((k) => !keys.includes(k))) invalid("unknown world field");
|
|
246
1538
|
return v;
|
|
247
1539
|
}
|
|
248
1540
|
|
|
1541
|
+
// src/runtime/evaluation.ts
|
|
1542
|
+
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
1543
|
+
var matches = (row, where) => !where || Object.entries(where).every(([k, v]) => isDeepStrictEqual2(row[k], v));
|
|
1544
|
+
function evaluateState(spec, initial, final, events) {
|
|
1545
|
+
const checks = spec.assertions.map((a, index) => {
|
|
1546
|
+
const before = initial[a.service]?.[a.path];
|
|
1547
|
+
const after = final[a.service]?.[a.path];
|
|
1548
|
+
const actual = after?.filter((row) => matches(row, a.where)).length;
|
|
1549
|
+
return {
|
|
1550
|
+
id: `assertion_${index + 1}`,
|
|
1551
|
+
kind: "required-outcome",
|
|
1552
|
+
service: a.service,
|
|
1553
|
+
path: a.path,
|
|
1554
|
+
passed: actual === a.count,
|
|
1555
|
+
expected: a.count,
|
|
1556
|
+
actual: actual ?? null,
|
|
1557
|
+
evidence: {
|
|
1558
|
+
initial: `/initialState/${escape(a.service)}/${escape(a.path)}`,
|
|
1559
|
+
final: `/finalState/${escape(a.service)}/${escape(a.path)}`,
|
|
1560
|
+
beforeCount: before?.filter((row) => matches(row, a.where)).length ?? null,
|
|
1561
|
+
eventSequences: events.filter((e) => e.service === a.service && e.path === a.path).map((e) => e.sequence)
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
});
|
|
1565
|
+
const forbidden = (spec.forbiddenEffects ?? []).map((policy) => {
|
|
1566
|
+
const hits = events.filter((e) => e.service === policy.service && e.path === policy.path && e.operation === policy.operation && (!policy.where || e.record !== void 0 && matches(e.record, policy.where)));
|
|
1567
|
+
const unknown = events.some((e) => e.service === policy.service && e.path === policy.path && e.operation === policy.operation && policy.where && !e.record);
|
|
1568
|
+
return {
|
|
1569
|
+
id: policy.id,
|
|
1570
|
+
kind: "forbidden-effect",
|
|
1571
|
+
service: policy.service,
|
|
1572
|
+
path: policy.path,
|
|
1573
|
+
passed: hits.length === 0 && !unknown,
|
|
1574
|
+
expected: 0,
|
|
1575
|
+
actual: hits.length,
|
|
1576
|
+
evidence: { eventSequences: hits.map((e) => e.sequence), evidenceGap: unknown }
|
|
1577
|
+
};
|
|
1578
|
+
});
|
|
1579
|
+
const preserved = (spec.preservedState ?? []).map((policy) => {
|
|
1580
|
+
const before = initial[policy.service]?.[policy.path];
|
|
1581
|
+
const after = final[policy.service]?.[policy.path];
|
|
1582
|
+
const passed = !!before && !!after && isDeepStrictEqual2(before.filter((row) => matches(row, policy.where)), after.filter((row) => matches(row, policy.where)));
|
|
1583
|
+
return {
|
|
1584
|
+
id: policy.id,
|
|
1585
|
+
kind: "preserved-state",
|
|
1586
|
+
service: policy.service,
|
|
1587
|
+
path: policy.path,
|
|
1588
|
+
passed,
|
|
1589
|
+
expected: "unchanged",
|
|
1590
|
+
actual: passed ? "unchanged" : "changed or missing",
|
|
1591
|
+
evidence: { initial: `/initialState/${escape(policy.service)}/${escape(policy.path)}`, final: `/finalState/${escape(policy.service)}/${escape(policy.path)}` }
|
|
1592
|
+
};
|
|
1593
|
+
});
|
|
1594
|
+
const properties = (spec.properties ?? []).map((policy) => evaluateProperty(policy, final, events));
|
|
1595
|
+
const taskPassed = checks.every((c) => c.passed);
|
|
1596
|
+
const safe = [...forbidden, ...preserved, ...properties].every((c) => c.passed);
|
|
1597
|
+
return { schema: "agenttwin/evaluation-v1", outcome: !safe ? "unsafe" : taskPassed ? "passed" : "failed", taskPassed, safe, checks: [...checks, ...forbidden, ...preserved, ...properties] };
|
|
1598
|
+
}
|
|
1599
|
+
var canonical = (value) => Array.isArray(value) ? value.map(canonical) : typeof value === "object" && value !== null ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
|
|
1600
|
+
var short = (value, limit = 120) => value.length <= limit ? value : `${value.slice(0, limit)}\u2026`;
|
|
1601
|
+
function evaluateProperty(policy, final, events) {
|
|
1602
|
+
const base = { id: policy.id, kind: "property", propertyKind: policy.kind, service: policy.service, path: policy.path, passed: false };
|
|
1603
|
+
const rows2 = final[policy.service]?.[policy.path];
|
|
1604
|
+
if (!rows2) return { ...base, expected: "intact", actual: "missing collection", evidence: { evidenceGap: `final state has no collection "${policy.path}" in "${policy.service}"` } };
|
|
1605
|
+
const eventsFor = (operation2, where2) => events.filter((e) => e.service === policy.service && e.path === policy.path && e.operation === operation2 && (!where2 || e.record !== void 0 && matches(e.record, where2)));
|
|
1606
|
+
if (policy.kind === "unique-per-act") {
|
|
1607
|
+
const field = String(policy.params?.field);
|
|
1608
|
+
const keyed = /* @__PURE__ */ new Map();
|
|
1609
|
+
for (const row of rows2) {
|
|
1610
|
+
if (row[field] === void 0) continue;
|
|
1611
|
+
const key = JSON.stringify(canonical(row[field]));
|
|
1612
|
+
keyed.set(key, [...keyed.get(key) ?? [], row.id ?? row]);
|
|
1613
|
+
}
|
|
1614
|
+
const duplicates = [...keyed.entries()].filter(([, list]) => list.length > 1);
|
|
1615
|
+
return {
|
|
1616
|
+
...base,
|
|
1617
|
+
passed: duplicates.length === 0,
|
|
1618
|
+
expected: 0,
|
|
1619
|
+
actual: duplicates.length,
|
|
1620
|
+
evidence: { field, duplicates: duplicates.map(([key, ids]) => ({ value: short(key), ids: ids.map(String) })), eventSequences: eventsFor("write").filter((e) => e.record !== void 0 && e.record[field] !== void 0 && duplicates.some(([key]) => key === JSON.stringify(canonical(e.record[field])))).map((e) => e.sequence) }
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
if (policy.kind === "no-loss") {
|
|
1624
|
+
const writes = eventsFor("write", policy.params?.where);
|
|
1625
|
+
const lost = writes.filter((e) => {
|
|
1626
|
+
if (!e.record) return false;
|
|
1627
|
+
const record = e.record;
|
|
1628
|
+
if (typeof record.id === "string" && record.id) return !rows2.some((row) => row.id === record.id);
|
|
1629
|
+
return !rows2.some((row) => isDeepStrictEqual2(row, record));
|
|
1630
|
+
});
|
|
1631
|
+
const unknown2 = writes.some((e) => !e.record);
|
|
1632
|
+
return {
|
|
1633
|
+
...base,
|
|
1634
|
+
passed: lost.length === 0 && !unknown2,
|
|
1635
|
+
expected: 0,
|
|
1636
|
+
actual: unknown2 ? "unknown" : lost.length,
|
|
1637
|
+
evidence: { evidenceGap: unknown2, eventSequences: lost.map((e) => e.sequence), lost: lost.map((e) => String(e.detail)) }
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
const count = Number(policy.params?.count);
|
|
1641
|
+
const operation = String(policy.params?.operation ?? "write");
|
|
1642
|
+
const where = policy.params?.where;
|
|
1643
|
+
const hits = eventsFor(operation, where);
|
|
1644
|
+
const unknown = where !== void 0 && hits.length > 0 && hits.some((e) => !e.record);
|
|
1645
|
+
return {
|
|
1646
|
+
...base,
|
|
1647
|
+
expected: count,
|
|
1648
|
+
actual: unknown ? "unknown" : hits.length,
|
|
1649
|
+
passed: hits.length === count && !unknown,
|
|
1650
|
+
evidence: { operation, eventSequences: hits.map((e) => e.sequence), evidenceGap: unknown }
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
var escape = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1654
|
+
|
|
1655
|
+
// src/runtime/contracts.ts
|
|
1656
|
+
var CONTRACTS = {
|
|
1657
|
+
"json-collection": {
|
|
1658
|
+
version: "1.0.0",
|
|
1659
|
+
authentication: "Bearer run-scoped test token",
|
|
1660
|
+
operations: [
|
|
1661
|
+
{ method: "GET", endpoint: "<collection path>", effect: "read", response: "{data: records}", errors: ["401 invalid_test_credential", "429 world_capacity"] },
|
|
1662
|
+
{ method: "POST", endpoint: "<collection path>", effect: "append", response: "201 record; 200 idempotency replay", errors: ["400 expected_json_object", "409 idempotency_conflict", "413 body_too_large", "429 world_capacity"] }
|
|
1663
|
+
],
|
|
1664
|
+
limitations: ["Custom API contract, not a vendor replica", "No update/delete, pagination, OAuth, webhooks or schema validation", "Idempotency compares the exact parsed JSON serialization"]
|
|
1665
|
+
},
|
|
1666
|
+
stripe: {
|
|
1667
|
+
version: "1.0.0",
|
|
1668
|
+
authentication: "Bearer run-scoped test token; official Stripe SDK configured with test endpoint",
|
|
1669
|
+
operations: [
|
|
1670
|
+
{ method: "POST", endpoint: "/v1/customers", effect: "append customer", parameters: ["email", "name", "description"], errors: ["400 invalid parameters"] },
|
|
1671
|
+
{ method: "GET", endpoint: "/v1/customers[/<id>]", effect: "retrieve or list customers", parameters: ["limit", "starting_after"], errors: ["404 resource_missing", "400 invalid pagination"] },
|
|
1672
|
+
{ method: "POST", endpoint: "/v1/payment_intents", effect: "create test payment intent", parameters: ["amount", "currency", "customer", "payment_method", "confirm"], errors: ["400 invalid parameters", "400 resource_missing", "402 card_declined"] },
|
|
1673
|
+
{ method: "GET", endpoint: "/v1/payment_intents/<id>", effect: "retrieve intent", parameters: [], errors: ["404 resource_missing"] },
|
|
1674
|
+
{ method: "POST", endpoint: "/v1/payment_intents/<id>/confirm", effect: "test success or decline", parameters: ["payment_method"], errors: ["404 resource_missing", "400 unsupported test method", "402 card_declined"] }
|
|
1675
|
+
],
|
|
1676
|
+
limitations: ["Bounded subset, not full Stripe parity; minimal object fields only; no real money", "Only pm_card_visa and pm_card_chargeDeclined test methods", "No webhooks, refunds, subscriptions, payment-method APIs, metadata, updates or deletes", "Customers list supports limit/starting_after; payment intent listing unsupported", "POST idempotency supported; reset clears records and keys; unsupported operations/parameters return 501 and make coverage incomplete"]
|
|
1677
|
+
},
|
|
1678
|
+
slack: {
|
|
1679
|
+
version: "1.0.0",
|
|
1680
|
+
authentication: "Bearer run-scoped test token; no Slack OAuth",
|
|
1681
|
+
operations: [
|
|
1682
|
+
{ method: "POST", endpoint: "/api/chat.postMessage", effect: "append /messages", parameters: ["channel", "text"], errors: ["channel_not_found", "no_text"] },
|
|
1683
|
+
{ method: "GET|POST", endpoint: "/api/conversations.history", effect: "read /messages", parameters: ["channel"], errors: ["channel_not_found"] },
|
|
1684
|
+
{ method: "GET|POST", endpoint: "/api/conversations.list", effect: "read /channels", parameters: [], errors: [] }
|
|
1685
|
+
],
|
|
1686
|
+
limitations: ["JSON and form bodies only", "No OAuth, scopes, pagination, threads, edits, deletes, attachments or webhooks", "Errors channel_not_found/no_text use HTTP 200 with ok:false", "Unmodeled parameters and operations return HTTP 501 and make coverage incomplete"]
|
|
1687
|
+
},
|
|
1688
|
+
github: {
|
|
1689
|
+
version: "1.0.0",
|
|
1690
|
+
authentication: "Bearer run-scoped test token; GitHub-style Authorization header",
|
|
1691
|
+
operations: [
|
|
1692
|
+
{ method: "GET", endpoint: "/repos", effect: "list repositories", parameters: [], errors: [] },
|
|
1693
|
+
{ method: "GET", endpoint: "/repos/<repo>", effect: "retrieve repository by name or full_name", parameters: [], errors: ["404 Not Found"] },
|
|
1694
|
+
{ method: "GET", endpoint: "/repos/<repo>/issues", effect: "list issues", parameters: ["state", "page", "per_page"], errors: ["404 Not Found", "422 validation"] },
|
|
1695
|
+
{ method: "POST", endpoint: "/repos/<repo>/issues", effect: "create issue", parameters: ["title", "body", "labels"], errors: ["404 Not Found", "422 validation"] },
|
|
1696
|
+
{ method: "GET", endpoint: "/repos/<repo>/issues/<number>", effect: "retrieve issue", parameters: [], errors: ["404 Not Found"] },
|
|
1697
|
+
{ method: "PATCH", endpoint: "/repos/<repo>/issues/<number>", effect: "update title/body or close", parameters: ["state", "title", "body"], errors: ["404 Not Found", "422 validation"] },
|
|
1698
|
+
{ method: "POST", endpoint: "/repos/<repo>/issues/<number>/comments", effect: "create comment", parameters: ["body"], errors: ["404 Not Found", "422 validation"] },
|
|
1699
|
+
{ method: "GET", endpoint: "/repos/<repo>/issues/<number>/comments", effect: "list comments", parameters: [], errors: ["404 Not Found"] },
|
|
1700
|
+
{ method: "GET", endpoint: "/repos/<repo>/pulls", effect: "list pull requests", parameters: ["state"], errors: ["404 Not Found", "422 validation"] },
|
|
1701
|
+
{ method: "GET", endpoint: "/repos/<repo>/pulls/<number>", effect: "retrieve pull request", parameters: [], errors: ["404 Not Found"] },
|
|
1702
|
+
{ method: "PUT", endpoint: "/repos/<repo>/pulls/<number>/merge", effect: "merge pull request", parameters: ["merge_method"], errors: ["404 Not Found", "405 not mergeable/already merged", "422 validation"] },
|
|
1703
|
+
{ method: "POST", endpoint: "/repos/<repo>/check-runs", effect: "create check run", parameters: ["name", "head_sha", "status", "conclusion"], errors: ["404 Not Found", "422 validation"] }
|
|
1704
|
+
],
|
|
1705
|
+
limitations: ["Bounded REST v3-shaped subset; minimal object fields only", "Ids, numbers and timestamps are deterministic synthetic values", "No auth beyond the run token, no reactions, labels mutation, reviews, branches, contents, releases or webhooks", "Unknown query parameters and operations return 501 and make coverage incomplete"]
|
|
1706
|
+
},
|
|
1707
|
+
linear: {
|
|
1708
|
+
version: "1.0.0",
|
|
1709
|
+
authentication: "Bearer run-scoped test token; GraphQL endpoint shape",
|
|
1710
|
+
operations: [
|
|
1711
|
+
{ method: "POST", endpoint: "/graphql query viewer", effect: "read first seeded user as viewer", parameters: [], errors: [] },
|
|
1712
|
+
{ method: "POST", endpoint: "/graphql query issues", effect: "list issues", parameters: [], errors: [] },
|
|
1713
|
+
{ method: "POST", endpoint: "/graphql query issue(id:)", effect: "retrieve issue", parameters: ["id"], errors: ["data:null with Entity not found error"] },
|
|
1714
|
+
{ method: "POST", endpoint: "/graphql query projects", effect: "list projects", parameters: [], errors: [] },
|
|
1715
|
+
{ method: "POST", endpoint: "/graphql query project(id:)", effect: "retrieve project", parameters: ["id"], errors: ["data:null with Entity not found error"] },
|
|
1716
|
+
{ method: "POST", endpoint: "/graphql mutation issueCreate", effect: "create issue", parameters: ["input:title,description,state,assigneeId,projectId,priority"], errors: ["Issue title required"] },
|
|
1717
|
+
{ method: "POST", endpoint: "/graphql mutation issueUpdate", effect: "update issue", parameters: ["id", "input:title,description,state,assigneeId,projectId,priority"], errors: ["data:null with Entity not found error"] },
|
|
1718
|
+
{ method: "POST", endpoint: "/graphql mutation commentCreate", effect: "create issue comment", parameters: ["input:issueId,body"], errors: ["data:null with Entity not found error", "Comment body required"] }
|
|
1719
|
+
],
|
|
1720
|
+
limitations: ["GraphQL-shaped approximation of Linear\u2019s API, not the real schema: a bounded root-field whitelist over the same POST /graphql surface", "Structured inputs must be sent as GraphQL variables; inline object arguments are rejected", "Responses use full bounded node shapes regardless of selection set; no pagination, filters, teams, workflows, attachments or subscriptions", "Known failures (missing entity, validation) use HTTP 200 GraphQL errors; unsupported root fields return 501 and make coverage incomplete"]
|
|
1721
|
+
},
|
|
1722
|
+
notion: {
|
|
1723
|
+
version: "1.0.0",
|
|
1724
|
+
authentication: "Bearer run-scoped test token; requires Notion-Version header",
|
|
1725
|
+
operations: [
|
|
1726
|
+
{ method: "GET", endpoint: "/v1/pages/<id>", effect: "retrieve page", parameters: [], errors: ["404 object_not_found"] },
|
|
1727
|
+
{ method: "POST", endpoint: "/v1/pages", effect: "create page in a seeded database", parameters: ["parent.database_id", "properties"], errors: ["400 validation_error", "404 object_not_found"] },
|
|
1728
|
+
{ method: "PATCH", endpoint: "/v1/pages/<id>", effect: "update properties or archive", parameters: ["properties", "archived"], errors: ["404 object_not_found", "400 validation_error"] },
|
|
1729
|
+
{ method: "GET", endpoint: "/v1/databases/<id>", effect: "retrieve database", parameters: [], errors: ["404 object_not_found"] },
|
|
1730
|
+
{ method: "POST", endpoint: "/v1/databases/<id>/query", effect: "query pages in database", parameters: ["filter(property + equals on title/rich_text/number/select/status/checkbox)", "page_size"], errors: ["404 object_not_found", "400 validation_error"] },
|
|
1731
|
+
{ method: "PATCH", endpoint: "/v1/blocks/<id>/children", effect: "append child blocks", parameters: ["children (1-100)"], errors: ["404 object_not_found", "400 validation_error"] }
|
|
1732
|
+
],
|
|
1733
|
+
limitations: ["Bounded envelope subset: pages must use a seeded database parent", "Ids are deterministic synthetic UUIDs; no url, users, comments, searches or nested rich text parsing", "Databases and blocks are returned as created; schema changes and page_size cursoring are not modeled", "Unsupported filters and operations return 501 and make coverage incomplete"]
|
|
1734
|
+
},
|
|
1735
|
+
hubspot: {
|
|
1736
|
+
version: "1.0.0",
|
|
1737
|
+
authentication: "Bearer run-scoped test token; HubSpot-style Bearer private token",
|
|
1738
|
+
operations: [
|
|
1739
|
+
{ method: "GET", endpoint: "/crm/v3/objects/<contacts|companies|deals|tickets>", effect: "list objects", parameters: ["limit", "after"], errors: ["400 validation"] },
|
|
1740
|
+
{ method: "POST", endpoint: "/crm/v3/objects/<contacts|companies|deals|tickets>", effect: "create object", parameters: ["properties"], errors: ["400 validation"] },
|
|
1741
|
+
{ method: "GET", endpoint: "/crm/v3/objects/<object>/<id>", effect: "retrieve object", parameters: [], errors: ["404 OBJECT_NOT_FOUND"] },
|
|
1742
|
+
{ method: "PATCH", endpoint: "/crm/v3/objects/<object>/<id>", effect: "update properties", parameters: ["properties"], errors: ["404 OBJECT_NOT_FOUND", "400 validation"] },
|
|
1743
|
+
{ method: "POST", endpoint: "/crm/v3/objects/<object>/search", effect: "search objects", parameters: ["query", "filterGroups(EQ only, max 3 filters)", "limit", "after"], errors: ["400 validation"] }
|
|
1744
|
+
],
|
|
1745
|
+
limitations: ["Bounded CRM v3 subset: scalar properties only, no associations, batches, pipelines, owners or dedupe", "Ids and timestamps are deterministic synthetic values", "properties/sorts query parameters and filter operators beyond EQ are unsupported", "Unknown objects, parameters and operations return 501 and make coverage incomplete"]
|
|
1746
|
+
},
|
|
1747
|
+
shopify: {
|
|
1748
|
+
version: "1.0.0",
|
|
1749
|
+
authentication: "Bearer run-scoped test token; also accepts X-Shopify-Access-Token",
|
|
1750
|
+
operations: [
|
|
1751
|
+
{ method: "GET", endpoint: "/admin/api/2024-01/products.json", effect: "list products", parameters: ["limit", "since_id"], errors: ["400 validation"] },
|
|
1752
|
+
{ method: "POST", endpoint: "/admin/api/2024-01/products.json", effect: "create product", parameters: ["product:title,body_html,vendor,product_type,status,tags,variants,options"], errors: ["422 title blank"] },
|
|
1753
|
+
{ method: "GET|PUT", endpoint: "/admin/api/2024-01/products/<id>.json", effect: "retrieve or update product", parameters: ["product"], errors: ["404 Not Found"] },
|
|
1754
|
+
{ method: "GET", endpoint: "/admin/api/2024-01/orders.json", effect: "list orders", parameters: ["limit", "since_id"], errors: ["400 validation"] },
|
|
1755
|
+
{ method: "POST", endpoint: "/admin/api/2024-01/orders.json", effect: "create order with line_items", parameters: ["order:email,phone,line_items,financial_status,fulfillment_status,note,total_price,currency"], errors: ["422 line_items blank"] },
|
|
1756
|
+
{ method: "GET|PUT", endpoint: "/admin/api/2024-01/orders/<id>.json", effect: "retrieve or update order", parameters: ["order"], errors: ["404 Not Found"] },
|
|
1757
|
+
{ method: "GET", endpoint: "/admin/api/2024-01/customers.json", effect: "list customers", parameters: ["limit", "since_id"], errors: ["400 validation"] },
|
|
1758
|
+
{ method: "POST", endpoint: "/admin/api/2024-01/customers.json", effect: "create customer", parameters: ["customer:first_name,last_name,email,phone,note,tags"], errors: [] },
|
|
1759
|
+
{ method: "GET|PUT", endpoint: "/admin/api/2024-01/customers/<id>.json", effect: "retrieve or update customer", parameters: ["customer"], errors: ["404 Not Found"] }
|
|
1760
|
+
],
|
|
1761
|
+
limitations: ["REST Admin API pinned to version 2024-01; .json endpoints only", "No metafields, variants mutation, fulfillment, transactions, webhooks, GraphQL or counts endpoints", "Ids and timestamps are deterministic synthetic values", "Unknown parameters and operations return 501 and make coverage incomplete"]
|
|
1762
|
+
},
|
|
1763
|
+
datadog: {
|
|
1764
|
+
version: "1.0.0",
|
|
1765
|
+
authentication: "Bearer run-scoped test token; also accepts DD-API-KEY and DD-APPLICATION-KEY headers",
|
|
1766
|
+
operations: [
|
|
1767
|
+
{ method: "GET", endpoint: "/api/v1/monitors", effect: "list monitors", parameters: ["name"], errors: [] },
|
|
1768
|
+
{ method: "POST", endpoint: "/api/v1/monitors", effect: "create monitor", parameters: ["name", "type", "query", "message", "tags", "options", "priority"], errors: ["400 invalid name/type/query"] },
|
|
1769
|
+
{ method: "GET", endpoint: "/api/v1/monitor/<id>", effect: "retrieve monitor", parameters: [], errors: ["404 Monitor not found"] },
|
|
1770
|
+
{ method: "POST", endpoint: "/api/v1/monitor/<id>/mute", effect: "mute monitor", parameters: ["scope", "end"], errors: ["404 Monitor not found"] },
|
|
1771
|
+
{ method: "POST", endpoint: "/api/v1/events", effect: "post event", parameters: ["title", "text", "tags", "priority", "alert_type", "source"], errors: ["400 invalid title/text"] },
|
|
1772
|
+
{ method: "GET", endpoint: "/api/v1/events", effect: "list events in window", parameters: ["start", "end", "priority", "tags"], errors: ["400 start/end required"] },
|
|
1773
|
+
{ method: "GET", endpoint: "/api/v1/query", effect: "query seeded metric series", parameters: ["from", "to", "query (avg/min/max/sum:metric{scope})"], errors: ["400 malformed query"] }
|
|
1774
|
+
],
|
|
1775
|
+
limitations: ["Bounded v1 subset: no dashboards, logs, SLOs, downtimes, unmute or v2 endpoints", "Overall state is always OK; monitor unmute and event deletion are unsupported", "Metric queries return seeded /metrics points clipped to the window; unknown metrics return empty series", "Unknown parameters and operations return 501 and make coverage incomplete"]
|
|
1776
|
+
},
|
|
1777
|
+
gmail: {
|
|
1778
|
+
version: "1.0.0",
|
|
1779
|
+
authentication: "Bearer run-scoped test token; Google-style OAuth2 Bearer header",
|
|
1780
|
+
operations: [
|
|
1781
|
+
{ method: "POST", endpoint: "/gmail/v1/users/me/messages/send", effect: "append a sent message", parameters: ["text", "raw", "to", "subject", "threadId"], errors: ["400 body required", "400 invalid field type"] },
|
|
1782
|
+
{ method: "GET", endpoint: "/gmail/v1/users/me/messages", effect: "list message ids with a snippet q filter", parameters: ["q"], errors: ["400 validation"] },
|
|
1783
|
+
{ method: "GET", endpoint: "/gmail/v1/users/me/messages/<id>", effect: "retrieve message", parameters: [], errors: ["404 NOT_FOUND"] },
|
|
1784
|
+
{ method: "GET", endpoint: "/gmail/v1/users/me/labels", effect: "list seeded labels", parameters: [], errors: [] }
|
|
1785
|
+
],
|
|
1786
|
+
limitations: ["Bounded Gmail v1 subset: send, list, get and labels only", "Sent messages carry a bounded Message envelope; MIME bodies are not parsed and attachments are not modeled", "No drafts, threads, history, modify, trash or watch endpoints", "Unknown fields and operations return 501 and make coverage incomplete"]
|
|
1787
|
+
},
|
|
1788
|
+
"google-calendar": {
|
|
1789
|
+
version: "1.1.0",
|
|
1790
|
+
authentication: "Bearer run-scoped test token; Google-style OAuth2 Bearer header",
|
|
1791
|
+
operations: [
|
|
1792
|
+
{ method: "GET", endpoint: "/calendar/v3/users/me/calendarList", effect: "list seeded calendars", parameters: [], errors: [] },
|
|
1793
|
+
{ method: "GET", endpoint: "/calendar/v3/calendars/primary/events", effect: "filter and paginate nonrecurring timed primary-calendar events", parameters: ["timeMin", "timeMax", "maxResults", "pageToken", "singleEvents", "orderBy=startTime", "conferenceDataVersion=1"], errors: ["400 invalid range, page size, ordering or stale page token"] },
|
|
1794
|
+
{ method: "POST", endpoint: "/calendar/v3/calendars/primary/events", effect: "create event", parameters: ["summary", "description", "location", "start", "end", "attendees", "status"], errors: ["400 summary required", "400 invalid field type", "404 unknown calendar"] },
|
|
1795
|
+
{ method: "GET", endpoint: "/calendar/v3/calendars/primary/events/<id>", effect: "retrieve event", parameters: [], errors: ["404 NOT_FOUND"] },
|
|
1796
|
+
{ method: "PATCH", endpoint: "/calendar/v3/calendars/primary/events/<id>", effect: "update event fields", parameters: ["summary", "description", "location", "start", "end", "attendees", "status"], errors: ["404 NOT_FOUND", "400 invalid field type"] },
|
|
1797
|
+
{ method: "DELETE", endpoint: "/calendar/v3/calendars/primary/events/<id>", effect: "delete event (responds 200 with an empty object; production returns 204)", parameters: [], errors: ["404 NOT_FOUND"] }
|
|
1798
|
+
],
|
|
1799
|
+
limitations: ["Bounded Calendar v3 subset: events on the primary seeded calendar only; requests to any other calendar id return 404", "Recurring-event expansion, all-day timezone filtering, reminders, conference data and sendUpdates are not modeled; these queries return 501", "Pagination tokens are synthetic and expire when query or event state changes; calendar queries follow documented end > timeMin and start < timeMax semantics", "Unknown fields and operations return 501 and make coverage incomplete"]
|
|
1800
|
+
},
|
|
1801
|
+
gitlab: {
|
|
1802
|
+
version: "1.0.0",
|
|
1803
|
+
authentication: "Bearer run-scoped test token; GitLab-style Bearer/OAuth token",
|
|
1804
|
+
operations: [
|
|
1805
|
+
{ method: "GET", endpoint: "/api/v4/projects", effect: "list projects", parameters: [], errors: [] },
|
|
1806
|
+
{ method: "POST", endpoint: "/api/v4/projects", effect: "create project", parameters: ["name", "description", "visibility"], errors: ["400 name missing"] },
|
|
1807
|
+
{ method: "GET", endpoint: "/api/v4/projects/<id>", effect: "retrieve project", parameters: [], errors: ["404 Not Found"] },
|
|
1808
|
+
{ method: "GET", endpoint: "/api/v4/issues", effect: "list issues", parameters: [], errors: [] },
|
|
1809
|
+
{ method: "POST", endpoint: "/api/v4/issues", effect: "create issue in a seeded project", parameters: ["title", "project_id", "description", "labels", "assignee_ids"], errors: ["400 missing title/project_id", "404 Project Not Found"] },
|
|
1810
|
+
{ method: "GET", endpoint: "/api/v4/issues/<id>", effect: "retrieve issue", parameters: [], errors: ["404 Not Found"] },
|
|
1811
|
+
{ method: "GET", endpoint: "/api/v4/merge_requests", effect: "list merge requests", parameters: [], errors: [] },
|
|
1812
|
+
{ method: "POST", endpoint: "/api/v4/merge_requests", effect: "create merge request in a seeded project", parameters: ["title", "project_id", "source_branch", "target_branch", "description"], errors: ["400 missing fields", "404 Project Not Found"] },
|
|
1813
|
+
{ method: "GET", endpoint: "/api/v4/merge_requests/<iid>", effect: "retrieve merge request", parameters: [], errors: ["404 Not Found"] },
|
|
1814
|
+
{ method: "GET", endpoint: "/api/v4/merge_requests/<iid>/notes", effect: "list merge request notes", parameters: [], errors: ["404 Merge Request Not Found"] },
|
|
1815
|
+
{ method: "POST", endpoint: "/api/v4/merge_requests/<iid>/notes", effect: "create merge request note", parameters: ["body"], errors: ["404 Merge Request Not Found", "400 body required"] }
|
|
1816
|
+
],
|
|
1817
|
+
limitations: ["Bounded v4 subset: groups, branches, pipelines, environments, tags workflows and uploads are not modeled", "Ids, iids and timestamps are deterministic synthetic values", "List endpoints take no query parameters; unsupported filters return 501", "Unknown operations return 501 and make coverage incomplete"]
|
|
1818
|
+
},
|
|
1819
|
+
jira: {
|
|
1820
|
+
version: "1.0.0",
|
|
1821
|
+
authentication: "Bearer run-scoped test token; Atlassian PAT Bearer header",
|
|
1822
|
+
operations: [
|
|
1823
|
+
{ method: "POST", endpoint: "/rest/api/3/issue", effect: "create issue", parameters: ["fields:project.key,issuetype.name,summary,description,priority,labels,assignee"], errors: ["400 missing fields", "400 unknown project"] },
|
|
1824
|
+
{ method: "GET", endpoint: "/rest/api/3/issue/<keyOrId>", effect: "retrieve issue", parameters: [], errors: ["404 Issue does not exist"] },
|
|
1825
|
+
{ method: "POST", endpoint: "/rest/api/3/search", effect: "search issues with bounded JQL (project = KEY only)", parameters: ["jql", "maxResults", "fields"], errors: ["400 jql required", "501 JQL clauses beyond project ="] },
|
|
1826
|
+
{ method: "POST", endpoint: "/rest/api/3/issue/<key>/comment", effect: "create issue comment (ADF doc or plain string)", parameters: ["body"], errors: ["404 Issue does not exist", "400 body format"] },
|
|
1827
|
+
{ method: "POST", endpoint: "/rest/api/3/issue/<key>/transitions", effect: "apply a bounded transition (11 To Do, 21 In Progress, 31 Done)", parameters: ["transition.id"], errors: ["404 Issue does not exist", "400 unknown transition id"] },
|
|
1828
|
+
{ method: "GET", endpoint: "/rest/api/3/project", effect: "list seeded projects", parameters: [], errors: [] },
|
|
1829
|
+
{ method: "GET", endpoint: "/rest/api/3/project/<key>", effect: "retrieve project", parameters: [], errors: ["404 No project could be found"] }
|
|
1830
|
+
],
|
|
1831
|
+
limitations: ["Bounded /rest/api/3 subset", "JQL supports only the project = KEY clause; unsupported clauses return 501", "Transitions are the fixed ids 11/21/31 and respond 200 with an empty object", "Comment bodies accept ADF doc objects or plain strings; no rich-text validation", "No attachments, watchers, boards, bulk operations or user endpoints", "Unknown fields and operations return 501 and make coverage incomplete"]
|
|
1832
|
+
},
|
|
1833
|
+
discord: {
|
|
1834
|
+
version: "1.0.0",
|
|
1835
|
+
authentication: "Bearer run-scoped test token; also accepts the Discord-idiomatic Authorization: Bot header",
|
|
1836
|
+
operations: [
|
|
1837
|
+
{ method: "GET", endpoint: "/api/channels/<id>", effect: "retrieve seeded channel", parameters: [], errors: ["404 Unknown Channel"] },
|
|
1838
|
+
{ method: "GET", endpoint: "/api/channels/<id>/messages", effect: "list channel messages (newest first)", parameters: [], errors: ["404 Unknown Channel"] },
|
|
1839
|
+
{ method: "POST", endpoint: "/api/channels/<id>/messages", effect: "create channel message", parameters: ["content", "tts"], errors: ["404 Unknown Channel", "400 content empty", "400 invalid field type"] },
|
|
1840
|
+
{ method: "GET", endpoint: "/api/guilds/<id>", effect: "retrieve seeded guild", parameters: [], errors: ["404 Unknown Guild"] },
|
|
1841
|
+
{ method: "GET", endpoint: "/api/guilds/<id>/members", effect: "list guild members", parameters: ["limit"], errors: ["404 Unknown Guild", "400 invalid limit"] }
|
|
1842
|
+
],
|
|
1843
|
+
limitations: ["Bounded subset: channels and guilds exist only as seeded reference state", "Message edit/delete, reactions, roles, threads and guild creation are unsupported", "Member results are seeded records; no gateway presence or intents"]
|
|
1844
|
+
},
|
|
1845
|
+
twilio: {
|
|
1846
|
+
version: "1.0.0",
|
|
1847
|
+
authentication: "Bearer run-scoped test token; production Twilio HTTP Basic auth is not required by this twin",
|
|
1848
|
+
operations: [
|
|
1849
|
+
{ method: "POST", endpoint: "/2010-04-01/Accounts/<sid>/Messages.json", effect: "create outbound message (status queued)", parameters: ["To", "From", "Body"], errors: ["400 missing parameters"] },
|
|
1850
|
+
{ method: "GET", endpoint: "/2010-04-01/Accounts/<sid>/Messages.json", effect: "list messages", parameters: ["To", "From"], errors: ["400 validation"] },
|
|
1851
|
+
{ method: "POST", endpoint: "/2010-04-01/Accounts/<sid>/Calls.json", effect: "create outbound call (status queued)", parameters: ["To", "From", "Url"], errors: ["400 missing parameters"] },
|
|
1852
|
+
{ method: "GET", endpoint: "/2010-04-01/Accounts/<sid>/Calls.json", effect: "list calls", parameters: ["To", "From"], errors: ["400 validation"] }
|
|
1853
|
+
],
|
|
1854
|
+
limitations: ["JSON bodies accepted in place of Twilio\u2019s form encoding; the .json suffix is optional", "Messages and Calls only; statuses stay queued and never progress in-flight", "No call updates, lookups, queues, recordings, SMS status callbacks or rate limits", "Deterministic synthetic sids; any account sid is accepted", "Unknown parameters and operations return 501 and make coverage incomplete"]
|
|
1855
|
+
},
|
|
1856
|
+
zendesk: {
|
|
1857
|
+
version: "1.0.0",
|
|
1858
|
+
authentication: "Bearer run-scoped test token; production Zendesk Basic auth is not required by this twin",
|
|
1859
|
+
operations: [
|
|
1860
|
+
{ method: "GET", endpoint: "/api/v2/tickets.json", effect: "list tickets", parameters: [], errors: [] },
|
|
1861
|
+
{ method: "POST", endpoint: "/api/v2/tickets.json", effect: "create ticket", parameters: ["ticket:subject,comment.body,requester,status,priority,tags,description,type"], errors: ["400 RecordInvalid subject/comment required", "400 InvalidStatus"] },
|
|
1862
|
+
{ method: "GET", endpoint: "/api/v2/tickets/<id>.json", effect: "retrieve ticket", parameters: [], errors: ["404 RecordNotFound"] },
|
|
1863
|
+
{ method: "PUT", endpoint: "/api/v2/tickets/<id>.json", effect: "update ticket fields", parameters: ["ticket:subject,status,priority,tags"], errors: ["404 RecordNotFound", "400 InvalidStatus"] },
|
|
1864
|
+
{ method: "GET", endpoint: "/api/v2/users.json", effect: "list seeded users", parameters: [], errors: [] },
|
|
1865
|
+
{ method: "GET", endpoint: "/api/v2/organizations.json", effect: "list seeded organizations", parameters: [], errors: [] }
|
|
1866
|
+
],
|
|
1867
|
+
limitations: ["Bounded Support API v2 subset: ticket create/list/get/update plus users and organizations lists", "No macros, views, triggers, audits, attachments, bulk updates or deletes", "Users and organizations are seed-only records; their updates are unsupported", "Unknown fields and operations return 501 and make coverage incomplete"]
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
function operationMatrix(spec) {
|
|
1871
|
+
return {
|
|
1872
|
+
schema: "agenttwin/contracts-v1",
|
|
1873
|
+
unsupported: { status: 501, marker: "X-AgentTwin-Unsupported", coverage: "incomplete", forwardedToProduction: false },
|
|
1874
|
+
adapters: CONTRACTS,
|
|
1875
|
+
services: spec?.services.map((s) => ({
|
|
1876
|
+
name: s.name,
|
|
1877
|
+
adapter: s.adapter ?? "json-collection",
|
|
1878
|
+
version: CONTRACTS[s.adapter ?? "json-collection"].version,
|
|
1879
|
+
operations: s.adapter === "slack" ? CONTRACTS.slack.operations : s.adapter === "stripe" ? CONTRACTS.stripe.operations : s.adapter && s.adapter !== "json-collection" ? CONTRACTS[s.adapter].operations : s.collections.flatMap((c) => CONTRACTS["json-collection"].operations.map((op) => ({ ...op, endpoint: c.path })))
|
|
1880
|
+
})) ?? []
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
|
|
249
1884
|
// src/runtime/worker.ts
|
|
250
1885
|
var secrets = [];
|
|
251
1886
|
async function main() {
|
|
252
1887
|
const spec = parseWorld(JSON.parse(await readFile("/input/world.json", "utf8")));
|
|
253
|
-
|
|
1888
|
+
if (spec.browser || spec.background) throw new Error("Browser and background execution require the local runtime; this hosted worker does not provision Chromium.");
|
|
1889
|
+
let suppliedSeed;
|
|
1890
|
+
try {
|
|
1891
|
+
suppliedSeed = normalizeSeed(JSON.parse(await readFile("/input/seed.json", "utf8")).seed);
|
|
1892
|
+
} catch {
|
|
1893
|
+
}
|
|
1894
|
+
const world = new HttpWorld(spec, suppliedSeed);
|
|
254
1895
|
const started = Date.now();
|
|
255
1896
|
try {
|
|
256
1897
|
const credentials = await world.start();
|
|
@@ -282,9 +1923,9 @@ async function main() {
|
|
|
282
1923
|
timedOut = true;
|
|
283
1924
|
kill();
|
|
284
1925
|
}, spec.timeoutSeconds * 1e3);
|
|
285
|
-
const exitCode = await new Promise((
|
|
1926
|
+
const exitCode = await new Promise((resolve2, reject) => {
|
|
286
1927
|
child.once("error", reject);
|
|
287
|
-
child.once("exit",
|
|
1928
|
+
child.once("exit", resolve2);
|
|
288
1929
|
}).finally(() => {
|
|
289
1930
|
clearTimeout(timer);
|
|
290
1931
|
kill();
|
|
@@ -303,7 +1944,9 @@ async function main() {
|
|
|
303
1944
|
const actual = exists.trim() === "0" ? null : output.replace(/\n$/, "");
|
|
304
1945
|
infrastructureAssertions.push({ service: "redis", key: assertion.key, expected: assertion.equals, actual, passed: actual === assertion.equals });
|
|
305
1946
|
}
|
|
306
|
-
const
|
|
1947
|
+
const initialState = Object.fromEntries(spec.services.map((s) => [s.name, Object.fromEntries(s.collections.map((c) => [c.path, c.seed]))]));
|
|
1948
|
+
const evaluation = evaluateState(spec, initialState, world.state, world.events);
|
|
1949
|
+
const status = world.unsupported.length || spec.fault && !world.faultTriggered ? "incomplete" : timedOut || exitCode !== 0 ? "error" : evaluation.outcome === "passed" && infrastructureAssertions.every((a) => a.passed) ? "passed" : "failed";
|
|
307
1950
|
for (const c of Object.values(credentials)) {
|
|
308
1951
|
stdout = stdout.split(c.token).join("[test-token]");
|
|
309
1952
|
stderr = stderr.split(c.token).join("[test-token]");
|
|
@@ -320,7 +1963,12 @@ async function main() {
|
|
|
320
1963
|
application: { command: spec.command, exitCode, timedOut, stdout, stderr },
|
|
321
1964
|
assertions,
|
|
322
1965
|
infrastructureAssertions,
|
|
1966
|
+
evaluation,
|
|
1967
|
+
contracts: operationMatrix(spec),
|
|
1968
|
+
scenario: spec.scenario,
|
|
323
1969
|
fault: spec.fault ? { ...spec.fault, triggered: world.faultTriggered } : null,
|
|
1970
|
+
seed: world.seed,
|
|
1971
|
+
reproducibility: "Seed " + world.seed + " deterministically drives twin-side fault scheduling, latency jitter and test credentials. The seed does not control the application's own randomness, wall-clock behavior or threading; compare runs only over twin events and final state.",
|
|
324
1972
|
initialState: Object.fromEntries(spec.services.map((s) => [s.name, Object.fromEntries(s.collections.map((c) => [c.path, c.seed]))])),
|
|
325
1973
|
finalState: world.state,
|
|
326
1974
|
events: world.events,
|