@tapi-dev/sdk 0.1.10 → 0.1.12
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 +42 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +444 -2
- package/dist/client.d.ts +2 -0
- package/dist/client.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/triggers.d.ts +17 -0
- package/dist/triggers.js +30 -0
- package/dist/types.d.ts +48 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,48 @@ const completedRun = await tapi.runs.wait(run.id);
|
|
|
101
101
|
console.log(completedRun.status, completedRun.result);
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
## API-Call Triggers
|
|
105
|
+
|
|
106
|
+
Triggers are scheduled calls to published website APIs. Keep trigger definitions
|
|
107
|
+
in the app repo and sync them after publishing the API catalog:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// tapi.config.ts
|
|
111
|
+
export default {
|
|
112
|
+
triggers: {
|
|
113
|
+
nightlyBalance: {
|
|
114
|
+
apiRequest: "schwab.get_balance",
|
|
115
|
+
schedule: { cron: "0 9 * * MON-FRI" },
|
|
116
|
+
inputs: { accountId: "main" },
|
|
117
|
+
runtime: { profileRef: "perm_default" },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
TAPI_API_KEY=tapi_project_key npx tapi triggers sync
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`triggers sync` upserts by trigger name for the current `.tapi/project.json`
|
|
128
|
+
project. Schedules support standard five-field cron strings or intervals as
|
|
129
|
+
numbers of seconds / strings like `30s`, `15m`, `2h`, and `1d`.
|
|
130
|
+
|
|
131
|
+
You can also manage triggers directly:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
await tapi.triggers.create({
|
|
135
|
+
name: "nightlyBalance",
|
|
136
|
+
apiRequest: "schwab.get_balance",
|
|
137
|
+
schedule: { cron: "0 9 * * MON-FRI" },
|
|
138
|
+
inputs: { accountId: "main" },
|
|
139
|
+
runtime: { profileRef: "perm_default" },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await tapi.triggers.fire("act_123");
|
|
143
|
+
await tapi.triggers.disable("act_123");
|
|
144
|
+
```
|
|
145
|
+
|
|
104
146
|
You can inspect the same input/output contract from the CLI:
|
|
105
147
|
|
|
106
148
|
```bash
|
package/dist/cli.d.ts
CHANGED
|
@@ -64,8 +64,18 @@ interface StudioCliOptions {
|
|
|
64
64
|
projectSlug?: string;
|
|
65
65
|
workspaceMode: boolean;
|
|
66
66
|
}
|
|
67
|
+
export interface ServiceStatus {
|
|
68
|
+
installed: boolean;
|
|
69
|
+
name?: string;
|
|
70
|
+
status?: string;
|
|
71
|
+
pathName?: string;
|
|
72
|
+
installedVersion?: string;
|
|
73
|
+
}
|
|
74
|
+
type FetchLike = typeof fetch;
|
|
67
75
|
export declare function runCli(argv?: string[]): Promise<number>;
|
|
68
76
|
export declare function parseStudioOptions(args: string[]): StudioCliOptions;
|
|
77
|
+
export declare function installedServiceVersionFromPathName(pathName: string): string | undefined;
|
|
78
|
+
export declare function serviceNeedsInstall(status: ServiceStatus, manifest: ServiceReleaseManifest): boolean;
|
|
69
79
|
export declare function getDefaultStudioCacheDir(): string;
|
|
70
80
|
export declare function getDefaultServiceCacheDir(): string;
|
|
71
81
|
export declare class CliWideEvent {
|
|
@@ -85,5 +95,6 @@ export declare function getStudioExecutableCandidates(): string[];
|
|
|
85
95
|
export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
|
|
86
96
|
export declare function validateServiceReleaseManifest(input: unknown): ServiceReleaseManifest;
|
|
87
97
|
export declare function compareVersions(left: string, right: string): number;
|
|
98
|
+
export declare function waitForHttpOk(url: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: FetchLike): Promise<void>;
|
|
88
99
|
export declare function findPortableStudioServerExe(releaseDir: string, manifest: StudioReleaseManifest): string | undefined;
|
|
89
100
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
10
10
|
import { performance } from "node:perf_hooks";
|
|
11
11
|
import { Readable } from "node:stream";
|
|
12
12
|
import { pipeline } from "node:stream/promises";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
14
|
import { TapiClient } from "./index.js";
|
|
15
15
|
import { loadWorkspace, normalizeProjectValue, writeWorkspaceConfig, } from "./workspace.js";
|
|
16
16
|
const DEFAULT_DOWNLOADS_BASE_URL = "https://d4xaf52nfwiok.cloudfront.net";
|
|
@@ -20,6 +20,8 @@ const DEFAULT_FIREBASE_API_KEY = "AIzaSyCDZR8lWyVQcWYfFdNZa4vuL4IWEC0h6gE";
|
|
|
20
20
|
const DEFAULT_CHANNEL = "pilot";
|
|
21
21
|
const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
|
|
22
22
|
const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
|
|
23
|
+
const PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS = 30_000;
|
|
24
|
+
const PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS = 250;
|
|
23
25
|
const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
|
|
24
26
|
class StudioInstallApprovalError extends Error {
|
|
25
27
|
code;
|
|
@@ -88,6 +90,18 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
88
90
|
printApisHelp();
|
|
89
91
|
return 1;
|
|
90
92
|
}
|
|
93
|
+
if (command === "triggers") {
|
|
94
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h" || hasHelpFlag(rest)) {
|
|
95
|
+
printTriggersHelp();
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (subcommand === "sync") {
|
|
99
|
+
return syncApiTriggers(rest);
|
|
100
|
+
}
|
|
101
|
+
console.error(`Unknown triggers command: ${subcommand}`);
|
|
102
|
+
printTriggersHelp();
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
91
105
|
if (command !== "studio") {
|
|
92
106
|
console.error(`Unknown command: ${command}`);
|
|
93
107
|
printHelp();
|
|
@@ -433,6 +447,298 @@ async function generateApiClient(args) {
|
|
|
433
447
|
return 1;
|
|
434
448
|
}
|
|
435
449
|
}
|
|
450
|
+
function parseTriggerSyncOptions(args) {
|
|
451
|
+
const workspace = loadWorkspace();
|
|
452
|
+
let apiBaseUrl = envString("TAPI_BASE_URL") || workspace?.apiBaseUrl || DEFAULT_STUDIO_API_BASE_URL;
|
|
453
|
+
let apiKey = envString("TAPI_API_KEY") || "";
|
|
454
|
+
let projectId = envString("TAPI_PROJECT_ID") || workspace?.projectId || "";
|
|
455
|
+
let configPath = "";
|
|
456
|
+
const root = workspace?.root || process.cwd();
|
|
457
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
458
|
+
const arg = args[index];
|
|
459
|
+
if (!arg)
|
|
460
|
+
continue;
|
|
461
|
+
if (arg === "--api-base-url" || arg === "--server") {
|
|
462
|
+
apiBaseUrl = requireOptionValue(args, ++index, arg);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (arg.startsWith("--api-base-url=")) {
|
|
466
|
+
apiBaseUrl = arg.slice("--api-base-url=".length);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (arg.startsWith("--server=")) {
|
|
470
|
+
apiBaseUrl = arg.slice("--server=".length);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (arg === "--api-key") {
|
|
474
|
+
apiKey = requireOptionValue(args, ++index, "--api-key");
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (arg.startsWith("--api-key=")) {
|
|
478
|
+
apiKey = arg.slice("--api-key=".length);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (arg === "--project") {
|
|
482
|
+
projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (arg.startsWith("--project=")) {
|
|
486
|
+
projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (arg === "--config") {
|
|
490
|
+
configPath = requireOptionValue(args, ++index, "--config");
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (arg.startsWith("--config=")) {
|
|
494
|
+
configPath = arg.slice("--config=".length);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
throw new Error(`Unknown triggers option: ${arg}`);
|
|
498
|
+
}
|
|
499
|
+
if (!apiKey) {
|
|
500
|
+
throw new Error("triggers sync requires --api-key or TAPI_API_KEY.");
|
|
501
|
+
}
|
|
502
|
+
if (!projectId) {
|
|
503
|
+
throw new Error("triggers sync requires --project, TAPI_PROJECT_ID, or .tapi/project.json.");
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
apiBaseUrl: normalizeHttpUrl(apiBaseUrl, "Tapi API base URL"),
|
|
507
|
+
apiKey,
|
|
508
|
+
projectId,
|
|
509
|
+
configPath: configPath ? resolve(root, configPath) : defaultTriggerConfigPath(root),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async function syncApiTriggers(args) {
|
|
513
|
+
try {
|
|
514
|
+
const options = parseTriggerSyncOptions(args);
|
|
515
|
+
const triggers = await readApiTriggerConfig(options.configPath);
|
|
516
|
+
if (triggers.length === 0) {
|
|
517
|
+
throw new Error(`No Tapi API triggers found in ${options.configPath}.`);
|
|
518
|
+
}
|
|
519
|
+
const client = new TapiClient({
|
|
520
|
+
baseUrl: options.apiBaseUrl,
|
|
521
|
+
apiKey: options.apiKey,
|
|
522
|
+
projectId: options.projectId,
|
|
523
|
+
});
|
|
524
|
+
for (const trigger of triggers) {
|
|
525
|
+
await client.triggers.create(trigger);
|
|
526
|
+
}
|
|
527
|
+
console.log(`Synced ${triggers.length} Tapi API trigger(s) from ${options.configPath}.`);
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
console.error(formatError(error));
|
|
532
|
+
return 1;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function defaultTriggerConfigPath(root) {
|
|
536
|
+
for (const name of ["tapi.config.ts", "tapi.config.mjs", "tapi.config.js", "tapi.config.cjs", "tapi.config.json"]) {
|
|
537
|
+
const path = join(root, name);
|
|
538
|
+
if (existsSync(path)) {
|
|
539
|
+
return path;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return join(root, "tapi.config.json");
|
|
543
|
+
}
|
|
544
|
+
async function readApiTriggerConfig(configPath) {
|
|
545
|
+
if (!existsSync(configPath)) {
|
|
546
|
+
throw new Error(`Tapi trigger config not found at ${configPath}. Create tapi.config.json or pass --config.`);
|
|
547
|
+
}
|
|
548
|
+
const payload = await loadTriggerConfigPayload(configPath);
|
|
549
|
+
return normalizeTriggerConfig(payload, configPath);
|
|
550
|
+
}
|
|
551
|
+
async function loadTriggerConfigPayload(configPath) {
|
|
552
|
+
if (configPath.endsWith(".json")) {
|
|
553
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
554
|
+
}
|
|
555
|
+
if (configPath.endsWith(".ts")) {
|
|
556
|
+
return evaluateTypeScriptTriggerConfig(configPath);
|
|
557
|
+
}
|
|
558
|
+
if (configPath.endsWith(".js") || configPath.endsWith(".mjs") || configPath.endsWith(".cjs")) {
|
|
559
|
+
const moduleUrl = pathToFileURL(configPath).href;
|
|
560
|
+
const module = await import(moduleUrl);
|
|
561
|
+
return module.default ?? module;
|
|
562
|
+
}
|
|
563
|
+
throw new Error("Tapi trigger config must be a .json, .js, .mjs, .cjs, or simple .ts file.");
|
|
564
|
+
}
|
|
565
|
+
function evaluateTypeScriptTriggerConfig(configPath) {
|
|
566
|
+
let source = readFileSync(configPath, "utf8");
|
|
567
|
+
source = source.replace(/^\s*import\s+type\s+[\s\S]*?;\s*$/gm, "");
|
|
568
|
+
source = source.replace(/^\s*import\s+[\s\S]*?;\s*$/gm, "");
|
|
569
|
+
const marker = "export default";
|
|
570
|
+
const markerIndex = source.indexOf(marker);
|
|
571
|
+
if (markerIndex < 0) {
|
|
572
|
+
throw new Error("tapi.config.ts must use `export default { ... }`.");
|
|
573
|
+
}
|
|
574
|
+
let expression = source.slice(markerIndex + marker.length).trim();
|
|
575
|
+
if (expression.endsWith(";")) {
|
|
576
|
+
expression = expression.slice(0, -1).trim();
|
|
577
|
+
}
|
|
578
|
+
expression = expression.replace(/\s+satisfies\s+[\s\S]+$/m, "").trim();
|
|
579
|
+
expression = expression.replace(/\s+as\s+const\s*$/m, "").trim();
|
|
580
|
+
try {
|
|
581
|
+
return Function("process", `"use strict"; return (${expression});`)(process);
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
throw new Error(`Could not evaluate ${configPath}: ${formatError(error)}. Use a simple export default object or switch to tapi.config.js/json.`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function normalizeTriggerConfig(payload, configPath) {
|
|
588
|
+
const rawTriggers = isRecord(payload) && "triggers" in payload ? payload.triggers : payload;
|
|
589
|
+
if (Array.isArray(rawTriggers)) {
|
|
590
|
+
return rawTriggers.map((entry, index) => normalizeTriggerEntry(entry, index, configPath));
|
|
591
|
+
}
|
|
592
|
+
if (isRecord(rawTriggers)) {
|
|
593
|
+
return Object.entries(rawTriggers).map(([name, entry], index) => {
|
|
594
|
+
if (!isRecord(entry)) {
|
|
595
|
+
throw new Error(`Trigger '${name}' in ${configPath} must be an object.`);
|
|
596
|
+
}
|
|
597
|
+
return normalizeTriggerEntry({ name, ...entry }, index, configPath);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
throw new Error(`Tapi trigger config at ${configPath} must contain a triggers array or object.`);
|
|
601
|
+
}
|
|
602
|
+
function normalizeTriggerEntry(entry, index, configPath) {
|
|
603
|
+
if (!isRecord(entry)) {
|
|
604
|
+
throw new Error(`Trigger #${index + 1} in ${configPath} must be an object.`);
|
|
605
|
+
}
|
|
606
|
+
const name = stringField(entry.name, `Trigger #${index + 1} name`);
|
|
607
|
+
const apiRequest = optionalStringField(entry.apiRequest, "apiRequest") ??
|
|
608
|
+
optionalStringField(entry.api, "api") ??
|
|
609
|
+
apiRequestFromParts(entry.apiName, entry.requestKey);
|
|
610
|
+
if (!apiRequest) {
|
|
611
|
+
throw new Error(`Trigger '${name}' requires apiRequest, api, or apiName/requestKey.`);
|
|
612
|
+
}
|
|
613
|
+
if (!apiRequest.includes(".")) {
|
|
614
|
+
throw new Error(`Trigger '${name}' apiRequest must be formatted as '<apiName>.<requestKey>'.`);
|
|
615
|
+
}
|
|
616
|
+
const schedule = normalizeTriggerSchedule(entry.schedule, entry.interval, name);
|
|
617
|
+
const request = {
|
|
618
|
+
name,
|
|
619
|
+
apiRequest,
|
|
620
|
+
...(entry.enabled === undefined ? {} : { enabled: booleanField(entry.enabled, `Trigger '${name}' enabled`) }),
|
|
621
|
+
...(schedule ? { schedule } : {}),
|
|
622
|
+
...(entry.inputs === undefined ? {} : { inputs: recordField(entry.inputs, `Trigger '${name}' inputs`) }),
|
|
623
|
+
...(entry.runtime === undefined ? {} : { runtime: recordField(entry.runtime, `Trigger '${name}' runtime`) }),
|
|
624
|
+
...(entry.runnerId === undefined ? {} : { runnerId: stringField(entry.runnerId, `Trigger '${name}' runnerId`) }),
|
|
625
|
+
...(entry.priority === undefined ? {} : { priority: numberField(entry.priority, `Trigger '${name}' priority`) }),
|
|
626
|
+
...(entry.site === undefined ? {} : { site: stringField(entry.site, `Trigger '${name}' site`) }),
|
|
627
|
+
};
|
|
628
|
+
return request;
|
|
629
|
+
}
|
|
630
|
+
function normalizeTriggerSchedule(scheduleValue, intervalValue, name) {
|
|
631
|
+
if (scheduleValue === undefined && intervalValue === undefined) {
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
if (typeof scheduleValue === "string" && looksLikeCron(scheduleValue)) {
|
|
635
|
+
return { cron: scheduleValue.trim() };
|
|
636
|
+
}
|
|
637
|
+
if (typeof scheduleValue === "number" || typeof scheduleValue === "string") {
|
|
638
|
+
return { intervalSeconds: parseDurationSeconds(scheduleValue, `Trigger '${name}' schedule`) };
|
|
639
|
+
}
|
|
640
|
+
if (scheduleValue !== undefined && !isRecord(scheduleValue)) {
|
|
641
|
+
throw new Error(`Trigger '${name}' schedule must be an object, number, or duration string.`);
|
|
642
|
+
}
|
|
643
|
+
const schedule = scheduleValue === undefined ? {} : { ...scheduleValue };
|
|
644
|
+
if (intervalValue !== undefined) {
|
|
645
|
+
if ("cron" in schedule) {
|
|
646
|
+
throw new Error(`Trigger '${name}' cannot define both cron and interval.`);
|
|
647
|
+
}
|
|
648
|
+
schedule.intervalSeconds = parseDurationSeconds(intervalValue, `Trigger '${name}' interval`);
|
|
649
|
+
}
|
|
650
|
+
else if ("interval" in schedule) {
|
|
651
|
+
if ("cron" in schedule) {
|
|
652
|
+
throw new Error(`Trigger '${name}' cannot define both cron and interval.`);
|
|
653
|
+
}
|
|
654
|
+
schedule.intervalSeconds = parseDurationSeconds(schedule.interval, `Trigger '${name}' schedule.interval`);
|
|
655
|
+
delete schedule.interval;
|
|
656
|
+
}
|
|
657
|
+
else if ("interval_seconds" in schedule) {
|
|
658
|
+
if ("cron" in schedule) {
|
|
659
|
+
throw new Error(`Trigger '${name}' cannot define both cron and interval_seconds.`);
|
|
660
|
+
}
|
|
661
|
+
schedule.intervalSeconds = parseDurationSeconds(schedule.interval_seconds, `Trigger '${name}' schedule.interval_seconds`);
|
|
662
|
+
delete schedule.interval_seconds;
|
|
663
|
+
}
|
|
664
|
+
else if ("intervalSeconds" in schedule) {
|
|
665
|
+
if ("cron" in schedule) {
|
|
666
|
+
throw new Error(`Trigger '${name}' cannot define both cron and intervalSeconds.`);
|
|
667
|
+
}
|
|
668
|
+
schedule.intervalSeconds = parseDurationSeconds(schedule.intervalSeconds, `Trigger '${name}' schedule.intervalSeconds`);
|
|
669
|
+
}
|
|
670
|
+
else if ("cron" in schedule) {
|
|
671
|
+
schedule.cron = stringField(schedule.cron, `Trigger '${name}' schedule.cron`);
|
|
672
|
+
}
|
|
673
|
+
if (!("intervalSeconds" in schedule) && !("cron" in schedule)) {
|
|
674
|
+
throw new Error(`Trigger '${name}' schedule requires intervalSeconds, interval, or cron.`);
|
|
675
|
+
}
|
|
676
|
+
return schedule;
|
|
677
|
+
}
|
|
678
|
+
function looksLikeCron(value) {
|
|
679
|
+
return value.trim().split(/\s+/).length === 5;
|
|
680
|
+
}
|
|
681
|
+
function parseDurationSeconds(value, fieldName) {
|
|
682
|
+
if (typeof value === "number") {
|
|
683
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
684
|
+
throw new Error(`${fieldName} must be a positive number of seconds.`);
|
|
685
|
+
}
|
|
686
|
+
return Math.round(value);
|
|
687
|
+
}
|
|
688
|
+
if (typeof value !== "string") {
|
|
689
|
+
throw new Error(`${fieldName} must be a number of seconds or a duration string like '15m'.`);
|
|
690
|
+
}
|
|
691
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)?$/i);
|
|
692
|
+
if (!match) {
|
|
693
|
+
throw new Error(`${fieldName} must be a duration like '30s', '15m', '2h', or '1d'.`);
|
|
694
|
+
}
|
|
695
|
+
const amount = Number(match[1]);
|
|
696
|
+
const unit = (match[2] || "s").toLowerCase();
|
|
697
|
+
const multiplier = unit.startsWith("m") ? 60 :
|
|
698
|
+
unit.startsWith("h") ? 3600 :
|
|
699
|
+
unit.startsWith("d") ? 86400 :
|
|
700
|
+
1;
|
|
701
|
+
const seconds = Math.round(amount * multiplier);
|
|
702
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
703
|
+
throw new Error(`${fieldName} must be greater than zero seconds.`);
|
|
704
|
+
}
|
|
705
|
+
return seconds;
|
|
706
|
+
}
|
|
707
|
+
function apiRequestFromParts(apiName, requestKey) {
|
|
708
|
+
const api = optionalStringField(apiName, "apiName");
|
|
709
|
+
const request = optionalStringField(requestKey, "requestKey");
|
|
710
|
+
return api && request ? `${api}.${request}` : undefined;
|
|
711
|
+
}
|
|
712
|
+
function stringField(value, fieldName) {
|
|
713
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
714
|
+
throw new Error(`${fieldName} must be a non-empty string.`);
|
|
715
|
+
}
|
|
716
|
+
return value.trim();
|
|
717
|
+
}
|
|
718
|
+
function optionalStringField(value, fieldName) {
|
|
719
|
+
if (value === undefined || value === null || value === "") {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
return stringField(value, fieldName);
|
|
723
|
+
}
|
|
724
|
+
function booleanField(value, fieldName) {
|
|
725
|
+
if (typeof value !== "boolean") {
|
|
726
|
+
throw new Error(`${fieldName} must be true or false.`);
|
|
727
|
+
}
|
|
728
|
+
return value;
|
|
729
|
+
}
|
|
730
|
+
function numberField(value, fieldName) {
|
|
731
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
732
|
+
throw new Error(`${fieldName} must be a finite number.`);
|
|
733
|
+
}
|
|
734
|
+
return value;
|
|
735
|
+
}
|
|
736
|
+
function recordField(value, fieldName) {
|
|
737
|
+
if (!isRecord(value)) {
|
|
738
|
+
throw new Error(`${fieldName} must be an object.`);
|
|
739
|
+
}
|
|
740
|
+
return value;
|
|
741
|
+
}
|
|
436
742
|
async function fetchApiCatalog(options) {
|
|
437
743
|
const client = new TapiClient({
|
|
438
744
|
baseUrl: options.apiBaseUrl,
|
|
@@ -620,7 +926,7 @@ async function repairService(options) {
|
|
|
620
926
|
}
|
|
621
927
|
function servicePowerShell(action) {
|
|
622
928
|
const serviceName = "tapi-service";
|
|
623
|
-
const status = `$svc = Get-Service -Name '${serviceName}' -ErrorAction SilentlyContinue; if ($null -eq $svc) { [pscustomobject]@{ installed = $false; name = '${serviceName}'; status = 'not_installed' } | ConvertTo-Json -Compress; exit 0 }; [pscustomobject]@{ installed = $true; name = $svc.Name; status = $svc.Status.ToString() } | ConvertTo-Json -Compress`;
|
|
929
|
+
const status = `$svc = Get-Service -Name '${serviceName}' -ErrorAction SilentlyContinue; if ($null -eq $svc) { [pscustomobject]@{ installed = $false; name = '${serviceName}'; status = 'not_installed'; pathName = '' } | ConvertTo-Json -Compress; exit 0 }; $cim = Get-CimInstance Win32_Service -Filter "Name='${serviceName}'" -ErrorAction SilentlyContinue; $pathName = if ($null -ne $cim) { [string]$cim.PathName } else { '' }; [pscustomobject]@{ installed = $true; name = $svc.Name; status = $svc.Status.ToString(); pathName = $pathName } | ConvertTo-Json -Compress`;
|
|
624
930
|
if (action === "status") {
|
|
625
931
|
return status;
|
|
626
932
|
}
|
|
@@ -649,6 +955,8 @@ async function getServiceStatus() {
|
|
|
649
955
|
installed: Boolean(payload.installed),
|
|
650
956
|
name: typeof payload.name === "string" ? payload.name : "tapi-service",
|
|
651
957
|
status: typeof payload.status === "string" ? payload.status : "unknown",
|
|
958
|
+
pathName: typeof payload.pathName === "string" ? payload.pathName : "",
|
|
959
|
+
installedVersion: installedServiceVersionFromPathName(typeof payload.pathName === "string" ? payload.pathName : ""),
|
|
652
960
|
};
|
|
653
961
|
}
|
|
654
962
|
catch {
|
|
@@ -665,6 +973,16 @@ async function ensureServiceReadyForStudio(options) {
|
|
|
665
973
|
}
|
|
666
974
|
return;
|
|
667
975
|
}
|
|
976
|
+
const manifest = await fetchServiceManifestForEnsure(options);
|
|
977
|
+
if (manifest && serviceNeedsInstall(status, manifest)) {
|
|
978
|
+
const current = status.installedVersion || "unknown";
|
|
979
|
+
console.log(`Updating Tapi Service from ${current} to ${manifest.version}...`);
|
|
980
|
+
const code = await installService(options);
|
|
981
|
+
if (code !== 0) {
|
|
982
|
+
throw new Error("Tapi Service update failed.");
|
|
983
|
+
}
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
668
986
|
if (status.status !== "Running") {
|
|
669
987
|
console.log("Starting Tapi Service...");
|
|
670
988
|
const output = await runProcessCapture("powershell.exe", [
|
|
@@ -679,6 +997,75 @@ async function ensureServiceReadyForStudio(options) {
|
|
|
679
997
|
}
|
|
680
998
|
}
|
|
681
999
|
}
|
|
1000
|
+
async function fetchServiceManifestForEnsure(options) {
|
|
1001
|
+
const event = await createCliWideEvent("tapi_cli.service_ensure", {
|
|
1002
|
+
sdk_version: sdkVersion,
|
|
1003
|
+
options: installEventOptions(options),
|
|
1004
|
+
});
|
|
1005
|
+
try {
|
|
1006
|
+
let installToken = options.installToken?.trim();
|
|
1007
|
+
if (!installToken) {
|
|
1008
|
+
await event.phase("auth.install_token.request.start", {
|
|
1009
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
1010
|
+
channel: options.channel,
|
|
1011
|
+
});
|
|
1012
|
+
installToken = await obtainStudioInstallToken(options, event);
|
|
1013
|
+
await event.phase("auth.install_token.request.success", {
|
|
1014
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
1015
|
+
channel: options.channel,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
await event.phase("service_manifest.fetch.start", {
|
|
1019
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
1020
|
+
channel: options.channel,
|
|
1021
|
+
});
|
|
1022
|
+
const manifest = await fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken);
|
|
1023
|
+
ensureCompatibleServiceManifest(manifest);
|
|
1024
|
+
await event.finish(true, {
|
|
1025
|
+
manifest: serviceManifestEventSummary(manifest),
|
|
1026
|
+
});
|
|
1027
|
+
return manifest;
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
await event.finish(false, {
|
|
1031
|
+
error: errorDetails(error),
|
|
1032
|
+
});
|
|
1033
|
+
if (isProtectedServiceManifestAuthError(error)) {
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
console.warn(`Could not check for Tapi Service updates: ${formatError(error)}`);
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function isProtectedServiceManifestAuthError(error) {
|
|
1041
|
+
const message = formatError(error);
|
|
1042
|
+
return (message.includes("Failed to fetch protected Tapi Service manifest: HTTP 401")
|
|
1043
|
+
|| message.includes("Failed to fetch protected Tapi Service manifest: HTTP 403")
|
|
1044
|
+
|| message.includes("Worker bootstrap secret required"));
|
|
1045
|
+
}
|
|
1046
|
+
export function installedServiceVersionFromPathName(pathName) {
|
|
1047
|
+
const normalized = String(pathName || "").replace(/\\/g, "/");
|
|
1048
|
+
const parts = normalized
|
|
1049
|
+
.split("/")
|
|
1050
|
+
.map((part) => part.trim().replace(/^"+|"+$/g, ""))
|
|
1051
|
+
.filter(Boolean);
|
|
1052
|
+
const releasesIndex = parts.findIndex((part) => part.toLowerCase() === "releases");
|
|
1053
|
+
if (releasesIndex < 0 || releasesIndex + 1 >= parts.length) {
|
|
1054
|
+
return undefined;
|
|
1055
|
+
}
|
|
1056
|
+
const version = parts[releasesIndex + 1]?.replace(/^"+|"+$/g, "").trim();
|
|
1057
|
+
return version || undefined;
|
|
1058
|
+
}
|
|
1059
|
+
export function serviceNeedsInstall(status, manifest) {
|
|
1060
|
+
if (!status.installed) {
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
const installedVersion = status.installedVersion || installedServiceVersionFromPathName(status.pathName || "");
|
|
1064
|
+
if (!installedVersion) {
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
return installedVersion !== manifest.version;
|
|
1068
|
+
}
|
|
682
1069
|
async function installService(options) {
|
|
683
1070
|
const event = await createCliWideEvent("tapi_cli.service_install", {
|
|
684
1071
|
sdk_version: sdkVersion,
|
|
@@ -1468,9 +1855,36 @@ async function launchPortableStudioServer(exePath, options) {
|
|
|
1468
1855
|
});
|
|
1469
1856
|
child.unref();
|
|
1470
1857
|
const url = `http://${host}:${port}`;
|
|
1858
|
+
await waitForHttpOk(`${url}/healthz`, PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS);
|
|
1471
1859
|
openBrowser(url);
|
|
1472
1860
|
console.log(`Opened Tapi Studio: ${url}`);
|
|
1473
1861
|
}
|
|
1862
|
+
export async function waitForHttpOk(url, timeoutMs = PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, intervalMs = PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS, fetchImpl = fetch) {
|
|
1863
|
+
const startedAt = performance.now();
|
|
1864
|
+
let lastError = "";
|
|
1865
|
+
while (performance.now() - startedAt < timeoutMs) {
|
|
1866
|
+
try {
|
|
1867
|
+
const response = await fetchImpl(url, {
|
|
1868
|
+
headers: { Accept: "application/json" },
|
|
1869
|
+
});
|
|
1870
|
+
if (response.ok) {
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
lastError = `HTTP ${response.status}`;
|
|
1874
|
+
}
|
|
1875
|
+
catch (error) {
|
|
1876
|
+
lastError = formatError(error);
|
|
1877
|
+
}
|
|
1878
|
+
await delay(intervalMs);
|
|
1879
|
+
}
|
|
1880
|
+
const suffix = lastError ? ` Last error: ${lastError}.` : "";
|
|
1881
|
+
throw new Error(`Tapi Studio server did not become ready at ${url} within ${Math.ceil(timeoutMs / 1000)}s.${suffix}`);
|
|
1882
|
+
}
|
|
1883
|
+
function delay(ms) {
|
|
1884
|
+
return new Promise((resolvePromise) => {
|
|
1885
|
+
setTimeout(resolvePromise, Math.max(0, ms));
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1474
1888
|
async function chooseStudioPort(preferred = 18766) {
|
|
1475
1889
|
for (let port = preferred; port < preferred + 25; port += 1) {
|
|
1476
1890
|
if (await isPortAvailable(port)) {
|
|
@@ -2085,6 +2499,7 @@ Usage:
|
|
|
2085
2499
|
tapi apis describe <namespace.operation>
|
|
2086
2500
|
tapi apis sync
|
|
2087
2501
|
tapi apis generate
|
|
2502
|
+
tapi triggers sync
|
|
2088
2503
|
tapi publish
|
|
2089
2504
|
tapi service status
|
|
2090
2505
|
tapi doctor
|
|
@@ -2099,6 +2514,7 @@ Commands:
|
|
|
2099
2514
|
apis describe Print a generated website API input/output contract
|
|
2100
2515
|
apis sync Save the published API catalog to .tapi/generated
|
|
2101
2516
|
apis generate Generate a TypeScript runtime wrapper from the catalog
|
|
2517
|
+
triggers sync Upsert API-call triggers from tapi.config
|
|
2102
2518
|
publish Upload local .tapi API drafts and publish ready requests
|
|
2103
2519
|
service Inspect or control the local Tapi Windows service
|
|
2104
2520
|
doctor Alias for studio doctor
|
|
@@ -2121,6 +2537,32 @@ Options:
|
|
|
2121
2537
|
--out <path> Generated TypeScript output path
|
|
2122
2538
|
`);
|
|
2123
2539
|
}
|
|
2540
|
+
function printTriggersHelp() {
|
|
2541
|
+
console.log(`Tapi API trigger commands
|
|
2542
|
+
|
|
2543
|
+
Usage:
|
|
2544
|
+
tapi triggers sync [--config FILE] [--api-base-url URL] [--api-key KEY] [--project PROJECT]
|
|
2545
|
+
|
|
2546
|
+
Options:
|
|
2547
|
+
--config <path> Trigger config path; defaults to tapi.config.ts/js/json in the workspace root
|
|
2548
|
+
--api-base-url <url> Tapi API base URL
|
|
2549
|
+
--server <url> Alias for --api-base-url
|
|
2550
|
+
--api-key <key> Tapi API key; defaults to TAPI_API_KEY
|
|
2551
|
+
--project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
|
|
2552
|
+
|
|
2553
|
+
Config:
|
|
2554
|
+
export default {
|
|
2555
|
+
triggers: {
|
|
2556
|
+
nightlyBalance: {
|
|
2557
|
+
apiRequest: "schwab.get_balance",
|
|
2558
|
+
interval: "1h",
|
|
2559
|
+
inputs: { accountId: "main" },
|
|
2560
|
+
runtime: { profileRef: "perm_default" },
|
|
2561
|
+
},
|
|
2562
|
+
},
|
|
2563
|
+
};
|
|
2564
|
+
`);
|
|
2565
|
+
}
|
|
2124
2566
|
function printStudioHelp() {
|
|
2125
2567
|
console.log(`Tapi Studio commands
|
|
2126
2568
|
|
package/dist/client.d.ts
CHANGED
|
@@ -7,5 +7,7 @@ export declare class HttpClient {
|
|
|
7
7
|
constructor(options: TapiClientOptions);
|
|
8
8
|
get<T>(path: string): Promise<T>;
|
|
9
9
|
post<T>(path: string, body?: unknown): Promise<T>;
|
|
10
|
+
patch<T>(path: string, body?: unknown): Promise<T>;
|
|
11
|
+
delete<T>(path: string): Promise<T>;
|
|
10
12
|
private request;
|
|
11
13
|
}
|
package/dist/client.js
CHANGED
|
@@ -16,6 +16,12 @@ export class HttpClient {
|
|
|
16
16
|
async post(path, body) {
|
|
17
17
|
return this.request("POST", path, body);
|
|
18
18
|
}
|
|
19
|
+
async patch(path, body) {
|
|
20
|
+
return this.request("PATCH", path, body);
|
|
21
|
+
}
|
|
22
|
+
async delete(path) {
|
|
23
|
+
return this.request("DELETE", path);
|
|
24
|
+
}
|
|
19
25
|
async request(method, path, body) {
|
|
20
26
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
21
27
|
method,
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CatalogResource } from "./catalog.js";
|
|
|
3
3
|
import { RunnersResource } from "./runners.js";
|
|
4
4
|
import { RunsResource } from "./runs.js";
|
|
5
5
|
import { RuntimeResource } from "./runtime.js";
|
|
6
|
+
import { TriggersResource } from "./triggers.js";
|
|
6
7
|
import type { TapiClientOptions } from "./types.js";
|
|
7
8
|
import { WebsiteApisResource } from "./website-apis.js";
|
|
8
9
|
export declare class TapiClient {
|
|
@@ -11,6 +12,7 @@ export declare class TapiClient {
|
|
|
11
12
|
readonly runners: RunnersResource;
|
|
12
13
|
readonly runs: RunsResource;
|
|
13
14
|
readonly runtime: RuntimeResource;
|
|
15
|
+
readonly triggers: TriggersResource;
|
|
14
16
|
readonly websiteApis: WebsiteApisResource;
|
|
15
17
|
constructor(options: TapiClientOptions);
|
|
16
18
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CatalogResource } from "./catalog.js";
|
|
|
4
4
|
import { RunnersResource } from "./runners.js";
|
|
5
5
|
import { RunsResource } from "./runs.js";
|
|
6
6
|
import { RuntimeResource } from "./runtime.js";
|
|
7
|
+
import { TriggersResource } from "./triggers.js";
|
|
7
8
|
import { WebsiteApisResource } from "./website-apis.js";
|
|
8
9
|
export class TapiClient {
|
|
9
10
|
catalog;
|
|
@@ -11,6 +12,7 @@ export class TapiClient {
|
|
|
11
12
|
runners;
|
|
12
13
|
runs;
|
|
13
14
|
runtime;
|
|
15
|
+
triggers;
|
|
14
16
|
websiteApis;
|
|
15
17
|
constructor(options) {
|
|
16
18
|
const http = new HttpClient(options);
|
|
@@ -19,6 +21,7 @@ export class TapiClient {
|
|
|
19
21
|
this.runners = new RunnersResource(http);
|
|
20
22
|
this.runs = new RunsResource(http);
|
|
21
23
|
this.runtime = new RuntimeResource(http, options);
|
|
24
|
+
this.triggers = new TriggersResource(http);
|
|
22
25
|
this.websiteApis = new WebsiteApisResource(http);
|
|
23
26
|
}
|
|
24
27
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HttpClient } from "./client.js";
|
|
2
|
+
import type { WebsiteApiTrigger, WebsiteApiTriggerCreateRequest, WebsiteApiTriggerList, WebsiteApiTriggerRunResult, WebsiteApiTriggerUpdateRequest } from "./types.js";
|
|
3
|
+
export declare class TriggersResource {
|
|
4
|
+
private readonly http;
|
|
5
|
+
constructor(http: HttpClient);
|
|
6
|
+
list(): Promise<WebsiteApiTriggerList>;
|
|
7
|
+
create(request: WebsiteApiTriggerCreateRequest): Promise<WebsiteApiTrigger>;
|
|
8
|
+
get(triggerId: string): Promise<WebsiteApiTrigger>;
|
|
9
|
+
update(triggerId: string, request: WebsiteApiTriggerUpdateRequest): Promise<WebsiteApiTrigger>;
|
|
10
|
+
enable(triggerId: string): Promise<WebsiteApiTrigger>;
|
|
11
|
+
disable(triggerId: string): Promise<WebsiteApiTrigger>;
|
|
12
|
+
delete(triggerId: string): Promise<{
|
|
13
|
+
deleted: boolean;
|
|
14
|
+
id: string;
|
|
15
|
+
}>;
|
|
16
|
+
fire(triggerId: string): Promise<WebsiteApiTriggerRunResult>;
|
|
17
|
+
}
|
package/dist/triggers.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class TriggersResource {
|
|
2
|
+
http;
|
|
3
|
+
constructor(http) {
|
|
4
|
+
this.http = http;
|
|
5
|
+
}
|
|
6
|
+
list() {
|
|
7
|
+
return this.http.get("/api/sdk/v1/website-api-triggers");
|
|
8
|
+
}
|
|
9
|
+
create(request) {
|
|
10
|
+
return this.http.post("/api/sdk/v1/website-api-triggers", request);
|
|
11
|
+
}
|
|
12
|
+
get(triggerId) {
|
|
13
|
+
return this.http.get(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}`);
|
|
14
|
+
}
|
|
15
|
+
update(triggerId, request) {
|
|
16
|
+
return this.http.patch(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}`, request);
|
|
17
|
+
}
|
|
18
|
+
enable(triggerId) {
|
|
19
|
+
return this.update(triggerId, { enabled: true });
|
|
20
|
+
}
|
|
21
|
+
disable(triggerId) {
|
|
22
|
+
return this.update(triggerId, { enabled: false });
|
|
23
|
+
}
|
|
24
|
+
delete(triggerId) {
|
|
25
|
+
return this.http.delete(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}`);
|
|
26
|
+
}
|
|
27
|
+
fire(triggerId) {
|
|
28
|
+
return this.http.post(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}/run`);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -162,6 +162,54 @@ export interface WebsiteApiOperation {
|
|
|
162
162
|
outputSchema?: Record<string, unknown>;
|
|
163
163
|
[key: string]: unknown;
|
|
164
164
|
}
|
|
165
|
+
export interface WebsiteApiTriggerSchedule {
|
|
166
|
+
intervalSeconds?: number;
|
|
167
|
+
cron?: string;
|
|
168
|
+
[key: string]: unknown;
|
|
169
|
+
}
|
|
170
|
+
export interface WebsiteApiTriggerCreateRequest {
|
|
171
|
+
name: string;
|
|
172
|
+
apiRequest: string;
|
|
173
|
+
enabled?: boolean;
|
|
174
|
+
schedule?: WebsiteApiTriggerSchedule;
|
|
175
|
+
inputs?: Record<string, unknown>;
|
|
176
|
+
runtime?: RuntimeRunOptions;
|
|
177
|
+
runnerId?: string;
|
|
178
|
+
priority?: number;
|
|
179
|
+
site?: string;
|
|
180
|
+
}
|
|
181
|
+
export interface WebsiteApiTriggerUpdateRequest {
|
|
182
|
+
enabled: boolean;
|
|
183
|
+
}
|
|
184
|
+
export interface WebsiteApiTrigger {
|
|
185
|
+
id: string;
|
|
186
|
+
name: string;
|
|
187
|
+
apiRequest: string;
|
|
188
|
+
apiName: string;
|
|
189
|
+
requestKey: string;
|
|
190
|
+
site?: string;
|
|
191
|
+
enabled: boolean;
|
|
192
|
+
schedule?: WebsiteApiTriggerSchedule | Record<string, unknown>;
|
|
193
|
+
inputs?: Record<string, unknown>;
|
|
194
|
+
runtime?: RuntimeRunOptions | Record<string, unknown>;
|
|
195
|
+
runnerId?: string;
|
|
196
|
+
priority?: number;
|
|
197
|
+
lastRun?: TapiRun | Record<string, unknown> | null;
|
|
198
|
+
lastError?: string;
|
|
199
|
+
lastFiredAt?: string | null;
|
|
200
|
+
nextFireAt?: string | null;
|
|
201
|
+
createdAt?: string;
|
|
202
|
+
updatedAt?: string;
|
|
203
|
+
[key: string]: unknown;
|
|
204
|
+
}
|
|
205
|
+
export interface WebsiteApiTriggerList {
|
|
206
|
+
triggers: WebsiteApiTrigger[];
|
|
207
|
+
}
|
|
208
|
+
export interface WebsiteApiTriggerRunResult {
|
|
209
|
+
success: boolean;
|
|
210
|
+
trigger: WebsiteApiTrigger;
|
|
211
|
+
run: TapiRun | Record<string, unknown>;
|
|
212
|
+
}
|
|
165
213
|
export interface TapiRun {
|
|
166
214
|
id: string;
|
|
167
215
|
status: RunStatus;
|