@myapihq/cli 1.2.9 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/email/mailbox.js +28 -1
- package/dist/commands/queue-validation.test.d.ts +1 -0
- package/dist/commands/queue-validation.test.js +38 -0
- package/dist/commands/queue.d.ts +14 -0
- package/dist/commands/queue.js +215 -0
- package/dist/commands/task-validation.test.d.ts +1 -0
- package/dist/commands/task-validation.test.js +37 -0
- package/dist/commands/task.d.ts +18 -0
- package/dist/commands/task.js +288 -0
- package/dist/commands/workflow-validation.test.js +27 -0
- package/dist/commands/workflow.js +17 -1
- package/dist/completion.js +4 -2
- package/dist/exposes.test.js +2 -0
- package/dist/index.js +14 -0
- package/dist/sdk-email-forwarding.test.d.ts +1 -0
- package/dist/sdk-email-forwarding.test.js +48 -0
- package/dist/sdk-queue.test.d.ts +1 -0
- package/dist/sdk-queue.test.js +86 -0
- package/dist/sdk-task.test.d.ts +1 -0
- package/dist/sdk-task.test.js +110 -0
- package/dist/skills/my-workflow-api/SKILL.md +10 -1
- package/package.json +2 -2
|
@@ -4,6 +4,8 @@ import { success, error, printTable, info } from '../../output.js';
|
|
|
4
4
|
export const EXPOSES = [
|
|
5
5
|
'POST /email/mailboxes/create',
|
|
6
6
|
'GET /email/mailboxes',
|
|
7
|
+
'PUT /email/mailboxes/{address}/forwarding',
|
|
8
|
+
'DELETE /email/mailboxes/{address}/forwarding',
|
|
7
9
|
'POST /email/sending/activate',
|
|
8
10
|
];
|
|
9
11
|
async function create(addressArg, flags) {
|
|
@@ -52,6 +54,22 @@ async function activateSending(flags) {
|
|
|
52
54
|
const res = await sdkEmail.activateSending(config.api_key, address);
|
|
53
55
|
success(`Sending activated: ${address} (${res.emails_quota_remaining} emails/day quota)`);
|
|
54
56
|
}
|
|
57
|
+
async function setForwarding(address, forwardTo, _flags) {
|
|
58
|
+
const config = requireConfig();
|
|
59
|
+
if (!address || !forwardTo) {
|
|
60
|
+
error('Missing required arguments.\nUsage: myapi email mailbox set-forwarding <user@domain> <forward-to@domain>');
|
|
61
|
+
}
|
|
62
|
+
const res = await sdkEmail.setForwarding(config.api_key, address, forwardTo);
|
|
63
|
+
success(`Forwarding set: ${res.address} → ${res.forward_to}`);
|
|
64
|
+
info('A copy of every incoming message is redirected; the original is kept in the mailbox.');
|
|
65
|
+
}
|
|
66
|
+
async function clearForwarding(address, _flags) {
|
|
67
|
+
const config = requireConfig();
|
|
68
|
+
if (!address)
|
|
69
|
+
error('Missing required arguments.\nUsage: myapi email mailbox clear-forwarding <user@domain>');
|
|
70
|
+
await sdkEmail.deleteForwarding(config.api_key, address);
|
|
71
|
+
success(`Forwarding cleared for ${address}`);
|
|
72
|
+
}
|
|
55
73
|
const USAGE = {
|
|
56
74
|
'create': `myapi email mailbox create <user@domain> [--display-name <name>]
|
|
57
75
|
myapi email mailbox create --username <u> --domain <d> [--display-name <name>]
|
|
@@ -65,6 +83,11 @@ matches the rest of the CLI ("first required arg is positional").`,
|
|
|
65
83
|
org (orphaned mailboxes — domain was unassigned
|
|
66
84
|
without first deleting them).`,
|
|
67
85
|
'activate-sending': 'myapi email mailbox activate-sending --address <email>',
|
|
86
|
+
'set-forwarding': `myapi email mailbox set-forwarding <user@domain> <forward-to@domain>
|
|
87
|
+
|
|
88
|
+
Redirects a copy of every incoming message to an external address
|
|
89
|
+
(server-side). The original is kept in the mailbox.`,
|
|
90
|
+
'clear-forwarding': 'myapi email mailbox clear-forwarding <user@domain>',
|
|
68
91
|
};
|
|
69
92
|
export async function run(sub, args, flags) {
|
|
70
93
|
if (!sub || (flags.help && !sub)) {
|
|
@@ -73,7 +96,9 @@ export async function run(sub, args, flags) {
|
|
|
73
96
|
Subcommands:
|
|
74
97
|
create Create a mailbox (positional <user@domain> or --username/--domain)
|
|
75
98
|
list List mailboxes on a domain (--domain) or orphaned ones (--filter unassigned)
|
|
76
|
-
activate-sending Activate outbound sending for a mailbox
|
|
99
|
+
activate-sending Activate outbound sending for a mailbox
|
|
100
|
+
set-forwarding Forward a copy of incoming mail to an external address
|
|
101
|
+
clear-forwarding Stop forwarding for a mailbox`);
|
|
77
102
|
return;
|
|
78
103
|
}
|
|
79
104
|
if (flags.help) {
|
|
@@ -88,6 +113,8 @@ Subcommands:
|
|
|
88
113
|
case 'create': return create(args[0], flags);
|
|
89
114
|
case 'list': return list(flags);
|
|
90
115
|
case 'activate-sending': return activateSending(flags);
|
|
116
|
+
case 'set-forwarding': return setForwarding(args[0], args[1], flags);
|
|
117
|
+
case 'clear-forwarding': return clearForwarding(args[0], flags);
|
|
91
118
|
default: error(`Unknown subcommand: ${sub}. Run "myapi email mailbox --help" for the list.`);
|
|
92
119
|
}
|
|
93
120
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Unit tests for the queue CLI's pure validators.
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { _validateConsumerUrl, _parseDependsOn } from './queue.js';
|
|
4
|
+
describe('_validateConsumerUrl', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
'https://example.com/consume',
|
|
7
|
+
'http://example.com/consume',
|
|
8
|
+
'https://fn.myapi.com/org/fn_123',
|
|
9
|
+
'https://example.com:8443/path?q=v',
|
|
10
|
+
])('accepts %s', (url) => {
|
|
11
|
+
expect(_validateConsumerUrl(url)).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
it.each([
|
|
14
|
+
['ftp://example.com', /http:\/\/ or https:\/\//],
|
|
15
|
+
['javascript:alert(1)', /http:\/\/ or https:\/\//],
|
|
16
|
+
['file:///etc/passwd', /http:\/\/ or https:\/\//],
|
|
17
|
+
])('rejects non-http(s) %s', (url, re) => {
|
|
18
|
+
expect(_validateConsumerUrl(url)).toMatch(re);
|
|
19
|
+
});
|
|
20
|
+
it.each(['not a url', 'just-text', 'http//missing-colon'])('rejects malformed %s', (url) => {
|
|
21
|
+
expect(_validateConsumerUrl(url)).toMatch(/not a valid URL/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('_parseDependsOn', () => {
|
|
25
|
+
it('returns undefined for empty / non-string input', () => {
|
|
26
|
+
expect(_parseDependsOn(undefined)).toBeUndefined();
|
|
27
|
+
expect(_parseDependsOn('')).toBeUndefined();
|
|
28
|
+
expect(_parseDependsOn(' ')).toBeUndefined();
|
|
29
|
+
expect(_parseDependsOn(true)).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
it('splits a comma-separated list and trims', () => {
|
|
32
|
+
expect(_parseDependsOn('j1,j2,j3')).toEqual(['j1', 'j2', 'j3']);
|
|
33
|
+
expect(_parseDependsOn(' j1 , j2 ')).toEqual(['j1', 'j2']);
|
|
34
|
+
});
|
|
35
|
+
it('drops empty segments', () => {
|
|
36
|
+
expect(_parseDependsOn('j1,,j2,')).toEqual(['j1', 'j2']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FlagSchema } from '../flags.js';
|
|
2
|
+
import { type Flags } from '../helpers.js';
|
|
3
|
+
import type { Exposes } from '../exposes.js';
|
|
4
|
+
export declare const EXPOSES: Exposes;
|
|
5
|
+
export declare const SCHEMA: FlagSchema;
|
|
6
|
+
export declare function _validateConsumerUrl(url: string): string | null;
|
|
7
|
+
export declare function _parseDependsOn(raw: unknown): string[] | undefined;
|
|
8
|
+
export declare function create(name: string, flags: Flags): Promise<void>;
|
|
9
|
+
export declare function list(flags: Flags): Promise<void>;
|
|
10
|
+
export declare function get(name: string, flags: Flags): Promise<void>;
|
|
11
|
+
export declare function enqueue(name: string, flags: Flags): Promise<void>;
|
|
12
|
+
export declare function jobs(name: string, flags: Flags): Promise<void>;
|
|
13
|
+
export declare function job(jobId: string, flags: Flags): Promise<void>;
|
|
14
|
+
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { queue as sdkQueue } from '@myapihq/sdk';
|
|
2
|
+
import { requireConfig } from '../config.js';
|
|
3
|
+
import { success, error, printTable, info, printJson } from '../output.js';
|
|
4
|
+
import { formatDate } from '../utils.js';
|
|
5
|
+
import { requireOrg, requireArg } from '../helpers.js';
|
|
6
|
+
export const EXPOSES = [
|
|
7
|
+
'POST /queue/orgs/{org_id}/queues',
|
|
8
|
+
'GET /queue/orgs/{org_id}/queues',
|
|
9
|
+
'GET /queue/orgs/{org_id}/queues/{name}',
|
|
10
|
+
'POST /queue/orgs/{org_id}/queues/{name}/jobs',
|
|
11
|
+
'GET /queue/orgs/{org_id}/queues/{name}/jobs',
|
|
12
|
+
'GET /queue/orgs/{org_id}/jobs/{id}',
|
|
13
|
+
];
|
|
14
|
+
export const SCHEMA = {
|
|
15
|
+
name: 'string',
|
|
16
|
+
'consumer-url': 'string',
|
|
17
|
+
'max-attempts': 'number',
|
|
18
|
+
'max-concurrency': 'number',
|
|
19
|
+
payload: 'string',
|
|
20
|
+
'dedup-key': 'string',
|
|
21
|
+
delay: 'number',
|
|
22
|
+
'depends-on': 'string',
|
|
23
|
+
status: 'string',
|
|
24
|
+
limit: 'number',
|
|
25
|
+
};
|
|
26
|
+
// Pure validator — returns an error message or null. Mirrors
|
|
27
|
+
// webhook.ts's _validateForwardUrl so it's unit-testable.
|
|
28
|
+
export function _validateConsumerUrl(url) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(url);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return `consumer URL "${url}" is not a valid URL.`;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
37
|
+
return `consumer URL must use http:// or https:// — got "${url}".`;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Parses a comma-separated --depends-on flag into a clean id array.
|
|
42
|
+
export function _parseDependsOn(raw) {
|
|
43
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
44
|
+
return undefined;
|
|
45
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
// ── Queues ───────────────────────────────────────────────────────────────────
|
|
48
|
+
export async function create(name, flags) {
|
|
49
|
+
const config = requireConfig();
|
|
50
|
+
const orgId = requireOrg(flags, config, 'myapi queue create <name> --consumer-url <url> [--org <id>]');
|
|
51
|
+
const queueName = name || flags.name;
|
|
52
|
+
requireArg(queueName, 'name', 'myapi queue create <name> --consumer-url <url>');
|
|
53
|
+
const consumerUrl = flags['consumer-url'];
|
|
54
|
+
if (typeof consumerUrl !== 'string' || consumerUrl === '') {
|
|
55
|
+
error('Missing --consumer-url.\nUsage: myapi queue create <name> --consumer-url <url> [--max-attempts <n>] [--max-concurrency <n>]');
|
|
56
|
+
}
|
|
57
|
+
const urlErr = _validateConsumerUrl(consumerUrl);
|
|
58
|
+
if (urlErr)
|
|
59
|
+
error(urlErr);
|
|
60
|
+
const q = await sdkQueue.createQueue(config.api_key, orgId, {
|
|
61
|
+
name: queueName,
|
|
62
|
+
consumerUrl,
|
|
63
|
+
maxAttempts: typeof flags['max-attempts'] === 'number' ? flags['max-attempts'] : undefined,
|
|
64
|
+
maxConcurrency: typeof flags['max-concurrency'] === 'number' ? flags['max-concurrency'] : undefined,
|
|
65
|
+
});
|
|
66
|
+
if (flags.json) {
|
|
67
|
+
printJson(q);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
success(`Queue created: ${q.name}`);
|
|
71
|
+
info(`Consumer: ${q.consumer_url}`);
|
|
72
|
+
info(`Max attempts: ${q.max_attempts}`);
|
|
73
|
+
info(`Max concurrency: ${q.max_concurrency}`);
|
|
74
|
+
}
|
|
75
|
+
export async function list(flags) {
|
|
76
|
+
const config = requireConfig();
|
|
77
|
+
const orgId = requireOrg(flags, config, 'myapi queue list [--org <id>]');
|
|
78
|
+
const queues = await sdkQueue.listQueues(config.api_key, orgId);
|
|
79
|
+
if (flags.json) {
|
|
80
|
+
printJson(queues);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
printTable(queues.map(q => ({ name: q.name })), {
|
|
84
|
+
flags,
|
|
85
|
+
empty: 'No queues yet. Create one with: myapi queue create <name> --consumer-url <url>',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export async function get(name, flags) {
|
|
89
|
+
const config = requireConfig();
|
|
90
|
+
const orgId = requireOrg(flags, config, 'myapi queue get <name> [--org <id>]');
|
|
91
|
+
requireArg(name, 'name', 'myapi queue get <name>');
|
|
92
|
+
const q = await sdkQueue.getQueue(config.api_key, orgId, name);
|
|
93
|
+
if (flags.json) {
|
|
94
|
+
printJson(q);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
info(`Name: ${q.name}`);
|
|
98
|
+
info(`Consumer: ${q.consumer_url}`);
|
|
99
|
+
info(`Max attempts: ${q.max_attempts}`);
|
|
100
|
+
info(`Max concurrency: ${q.max_concurrency}`);
|
|
101
|
+
}
|
|
102
|
+
// ── Jobs ─────────────────────────────────────────────────────────────────────
|
|
103
|
+
export async function enqueue(name, flags) {
|
|
104
|
+
const config = requireConfig();
|
|
105
|
+
const orgId = requireOrg(flags, config, 'myapi queue enqueue <name> --payload <json> [--org <id>]');
|
|
106
|
+
requireArg(name, 'name', 'myapi queue enqueue <name> --payload <json>');
|
|
107
|
+
let payload;
|
|
108
|
+
if (typeof flags.payload === 'string') {
|
|
109
|
+
try {
|
|
110
|
+
payload = JSON.parse(flags.payload);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
error(`--payload is not valid JSON: ${e?.message ?? e}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (flags.payload === true) {
|
|
117
|
+
error('--payload needs a JSON value, e.g. --payload \'{"order_id":42}\'');
|
|
118
|
+
}
|
|
119
|
+
const job = await sdkQueue.enqueueJob(config.api_key, orgId, name, {
|
|
120
|
+
payload,
|
|
121
|
+
dedupKey: typeof flags['dedup-key'] === 'string' ? flags['dedup-key'] : undefined,
|
|
122
|
+
delaySeconds: typeof flags.delay === 'number' ? flags.delay : undefined,
|
|
123
|
+
dependsOn: _parseDependsOn(flags['depends-on']),
|
|
124
|
+
});
|
|
125
|
+
if (flags.json) {
|
|
126
|
+
printJson(job);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
success(`Job enqueued: ${job.id}`);
|
|
130
|
+
info(`Status: ${job.status}`);
|
|
131
|
+
}
|
|
132
|
+
export async function jobs(name, flags) {
|
|
133
|
+
const config = requireConfig();
|
|
134
|
+
const orgId = requireOrg(flags, config, 'myapi queue jobs <name> [--status <s>] [--limit <n>] [--org <id>]');
|
|
135
|
+
requireArg(name, 'name', 'myapi queue jobs <name>');
|
|
136
|
+
const list = await sdkQueue.listJobs(config.api_key, orgId, name, {
|
|
137
|
+
status: typeof flags.status === 'string' ? flags.status : undefined,
|
|
138
|
+
limit: typeof flags.limit === 'number' ? flags.limit : undefined,
|
|
139
|
+
});
|
|
140
|
+
if (flags.json) {
|
|
141
|
+
printJson(list);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
printTable(list.map(j => ({
|
|
145
|
+
id: j.id,
|
|
146
|
+
status: j.status,
|
|
147
|
+
attempt: j.attempt,
|
|
148
|
+
created: j.created_at ? formatDate(j.created_at) : '',
|
|
149
|
+
})), { flags, empty: 'No jobs in this queue.' });
|
|
150
|
+
}
|
|
151
|
+
export async function job(jobId, flags) {
|
|
152
|
+
const config = requireConfig();
|
|
153
|
+
const orgId = requireOrg(flags, config, 'myapi queue job <job_id> [--org <id>]');
|
|
154
|
+
requireArg(jobId, 'job_id', 'myapi queue job <job_id>');
|
|
155
|
+
const j = await sdkQueue.getJob(config.api_key, orgId, jobId);
|
|
156
|
+
if (flags.json) {
|
|
157
|
+
printJson(j);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
info(`Job: ${j.id}`);
|
|
161
|
+
info(`Queue: ${j.queue_id}`);
|
|
162
|
+
info(`Status: ${j.status}`);
|
|
163
|
+
info(`Attempt: ${j.attempt} / ${j.max_attempts}`);
|
|
164
|
+
if (j.last_error)
|
|
165
|
+
info(`Error: ${j.last_error}`);
|
|
166
|
+
}
|
|
167
|
+
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
168
|
+
const SUBCOMMAND_USAGE = {
|
|
169
|
+
'create': 'myapi queue create <name> --consumer-url <url> [--max-attempts <n>] [--max-concurrency <n>] [--org <id>]',
|
|
170
|
+
'list': 'myapi queue list [--org <id>] [--json]',
|
|
171
|
+
'get': 'myapi queue get <name> [--org <id>] [--json]',
|
|
172
|
+
'enqueue': 'myapi queue enqueue <name> --payload <json> [--dedup-key <k>] [--delay <seconds>] [--depends-on <id,id>] [--org <id>]',
|
|
173
|
+
'jobs': 'myapi queue jobs <name> [--status <s>] [--limit <n>] [--org <id>] [--json]',
|
|
174
|
+
'job': 'myapi queue job <job_id> [--org <id>] [--json]',
|
|
175
|
+
};
|
|
176
|
+
export async function run(subcommand, args, flags) {
|
|
177
|
+
if (!subcommand || (flags.help && !subcommand)) {
|
|
178
|
+
info(`Usage: myapi queue <subcommand>
|
|
179
|
+
|
|
180
|
+
A durable HTTP-consumer job queue — jobs are POSTed to the queue's
|
|
181
|
+
consumer_url with retry/backoff, a concurrency cap, and a dependency DAG.
|
|
182
|
+
|
|
183
|
+
Queues:
|
|
184
|
+
create <name> Create a queue (--consumer-url, --max-attempts, --max-concurrency)
|
|
185
|
+
list List queues
|
|
186
|
+
get <name> Show a queue's policy
|
|
187
|
+
|
|
188
|
+
Jobs:
|
|
189
|
+
enqueue <name> Enqueue a job (--payload, --dedup-key, --delay, --depends-on)
|
|
190
|
+
jobs <name> List a queue's jobs (--status, --limit)
|
|
191
|
+
job <job_id> Show a single job's status
|
|
192
|
+
|
|
193
|
+
For "needs a decision" work (agent/human), see: myapi task --help
|
|
194
|
+
|
|
195
|
+
All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (flags.help) {
|
|
199
|
+
const usage = SUBCOMMAND_USAGE[subcommand];
|
|
200
|
+
if (usage)
|
|
201
|
+
info(`Usage: ${usage}`);
|
|
202
|
+
else
|
|
203
|
+
info(`Unknown subcommand: ${subcommand}. Run "myapi queue --help" for the list.`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
switch (subcommand) {
|
|
207
|
+
case 'create': return create(args[0], flags);
|
|
208
|
+
case 'list': return list(flags);
|
|
209
|
+
case 'get': return get(args[0], flags);
|
|
210
|
+
case 'enqueue': return enqueue(args[0], flags);
|
|
211
|
+
case 'jobs': return jobs(args[0], flags);
|
|
212
|
+
case 'job': return job(args[0], flags);
|
|
213
|
+
default: error(`Unknown subcommand: ${subcommand}. Run "myapi queue --help" for a list of valid subcommands.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Unit tests for the task CLI's pure parsers.
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { _parseResolveOn, _splitList } from './task.js';
|
|
4
|
+
describe('_parseResolveOn', () => {
|
|
5
|
+
it('parses a bare event type', () => {
|
|
6
|
+
expect(_parseResolveOn('payment.succeeded')).toEqual({ event_type: 'payment.succeeded' });
|
|
7
|
+
});
|
|
8
|
+
it('parses event_type:field=value', () => {
|
|
9
|
+
expect(_parseResolveOn('payment.succeeded:order_id=o_42')).toEqual({
|
|
10
|
+
event_type: 'payment.succeeded', field: 'order_id', value: 'o_42',
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
it('keeps = signs inside the value', () => {
|
|
14
|
+
expect(_parseResolveOn('evt:token=a=b=c')).toEqual({
|
|
15
|
+
event_type: 'evt', field: 'token', value: 'a=b=c',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it('rejects an empty string', () => {
|
|
19
|
+
expect(_parseResolveOn(' ')).toMatch(/cannot be empty/);
|
|
20
|
+
});
|
|
21
|
+
it('rejects a leading colon (no event type)', () => {
|
|
22
|
+
expect(_parseResolveOn(':field=value')).toMatch(/must start with an event type/);
|
|
23
|
+
});
|
|
24
|
+
it('rejects a field match without =', () => {
|
|
25
|
+
expect(_parseResolveOn('evt:justfield')).toMatch(/field=value/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('_splitList', () => {
|
|
29
|
+
it('returns undefined for empty / non-string input', () => {
|
|
30
|
+
expect(_splitList(undefined)).toBeUndefined();
|
|
31
|
+
expect(_splitList('')).toBeUndefined();
|
|
32
|
+
expect(_splitList(true)).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it('splits and trims, dropping empties', () => {
|
|
35
|
+
expect(_splitList('support, sales ,, billing')).toEqual(['support', 'sales', 'billing']);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { task as sdkTask } from '@myapihq/sdk';
|
|
2
|
+
import type { FlagSchema } from '../flags.js';
|
|
3
|
+
import { type Flags } from '../helpers.js';
|
|
4
|
+
import type { Exposes } from '../exposes.js';
|
|
5
|
+
export declare const EXPOSES: Exposes;
|
|
6
|
+
export declare const SCHEMA: FlagSchema;
|
|
7
|
+
export declare const TASK_IMPORTANCE: readonly ["low", "normal", "high", "critical"];
|
|
8
|
+
export declare function _parseResolveOn(raw: string): sdkTask.ResolveOn | string;
|
|
9
|
+
export declare function _splitList(raw: unknown): string[] | undefined;
|
|
10
|
+
export declare function create(description: string, flags: Flags): Promise<void>;
|
|
11
|
+
export declare function list(flags: Flags): Promise<void>;
|
|
12
|
+
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
13
|
+
export declare function claim(id: string, flags: Flags): Promise<void>;
|
|
14
|
+
export declare function extend(id: string, flags: Flags): Promise<void>;
|
|
15
|
+
export declare function resolve(id: string, flags: Flags): Promise<void>;
|
|
16
|
+
export declare function fail(id: string, flags: Flags): Promise<void>;
|
|
17
|
+
export declare function cancel(id: string, flags: Flags): Promise<void>;
|
|
18
|
+
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { task as sdkTask } from '@myapihq/sdk';
|
|
2
|
+
import { requireConfig } from '../config.js';
|
|
3
|
+
import { success, error, printTable, info, printJson } from '../output.js';
|
|
4
|
+
import { formatDate } from '../utils.js';
|
|
5
|
+
import { requireOrg, requireArg } from '../helpers.js';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
export const EXPOSES = [
|
|
8
|
+
'POST /task/orgs/{org_id}/tasks',
|
|
9
|
+
'GET /task/orgs/{org_id}/tasks',
|
|
10
|
+
'GET /task/orgs/{org_id}/tasks/{id}',
|
|
11
|
+
'DELETE /task/orgs/{org_id}/tasks/{id}',
|
|
12
|
+
'GET /task/orgs/{org_id}/tasks/{id}/body',
|
|
13
|
+
'POST /task/orgs/{org_id}/tasks/{id}/claim',
|
|
14
|
+
'POST /task/orgs/{org_id}/tasks/{id}/extend',
|
|
15
|
+
'POST /task/orgs/{org_id}/tasks/{id}/fail',
|
|
16
|
+
'POST /task/orgs/{org_id}/tasks/{id}/resolve',
|
|
17
|
+
];
|
|
18
|
+
export const SCHEMA = {
|
|
19
|
+
body: 'string',
|
|
20
|
+
importance: 'string',
|
|
21
|
+
due: 'string',
|
|
22
|
+
assignee: 'string',
|
|
23
|
+
tag: 'string',
|
|
24
|
+
'depends-on': 'string',
|
|
25
|
+
'dedup-key': 'string',
|
|
26
|
+
'resolve-on': 'string',
|
|
27
|
+
source: 'string',
|
|
28
|
+
status: 'string',
|
|
29
|
+
limit: 'number',
|
|
30
|
+
lease: 'number',
|
|
31
|
+
worker: 'string',
|
|
32
|
+
reason: 'string',
|
|
33
|
+
};
|
|
34
|
+
// The backend's fixed importance enum — validated client-side so a typo
|
|
35
|
+
// fails before the network call.
|
|
36
|
+
export const TASK_IMPORTANCE = ['low', 'normal', 'high', 'critical'];
|
|
37
|
+
// Parses --resolve-on "<event_type>[:<field>=<value>]" into a matcher.
|
|
38
|
+
// Returns the parsed object, or a string error message.
|
|
39
|
+
export function _parseResolveOn(raw) {
|
|
40
|
+
const trimmed = raw.trim();
|
|
41
|
+
if (trimmed === '')
|
|
42
|
+
return '--resolve-on cannot be empty.';
|
|
43
|
+
const colon = trimmed.indexOf(':');
|
|
44
|
+
if (colon === -1)
|
|
45
|
+
return { event_type: trimmed };
|
|
46
|
+
const eventType = trimmed.slice(0, colon);
|
|
47
|
+
const rest = trimmed.slice(colon + 1);
|
|
48
|
+
if (eventType === '')
|
|
49
|
+
return '--resolve-on must start with an event type, e.g. payment.succeeded:amount=100';
|
|
50
|
+
const eq = rest.indexOf('=');
|
|
51
|
+
if (eq === -1) {
|
|
52
|
+
return `--resolve-on field match must look like field=value — got "${rest}".`;
|
|
53
|
+
}
|
|
54
|
+
return { event_type: eventType, field: rest.slice(0, eq), value: rest.slice(eq + 1) };
|
|
55
|
+
}
|
|
56
|
+
// Parses a comma-separated --tag / --depends-on flag into a clean array.
|
|
57
|
+
export function _splitList(raw) {
|
|
58
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
59
|
+
return undefined;
|
|
60
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
// --body accepts inline text or @path to read a Markdown file.
|
|
63
|
+
function resolveBody(raw) {
|
|
64
|
+
if (raw.startsWith('@')) {
|
|
65
|
+
const path = raw.slice(1);
|
|
66
|
+
try {
|
|
67
|
+
return fs.readFileSync(path, 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
error(`Could not read --body file "${path}": ${e?.message ?? e}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return raw;
|
|
74
|
+
}
|
|
75
|
+
// ── Create / list ────────────────────────────────────────────────────────────
|
|
76
|
+
export async function create(description, flags) {
|
|
77
|
+
const config = requireConfig();
|
|
78
|
+
const orgId = requireOrg(flags, config, 'myapi task create "<description>" [--org <id>]');
|
|
79
|
+
requireArg(description, 'description', 'myapi task create "<description>"');
|
|
80
|
+
let resolveOn;
|
|
81
|
+
if (typeof flags['resolve-on'] === 'string') {
|
|
82
|
+
const parsed = _parseResolveOn(flags['resolve-on']);
|
|
83
|
+
if (typeof parsed === 'string')
|
|
84
|
+
error(parsed);
|
|
85
|
+
resolveOn = parsed;
|
|
86
|
+
}
|
|
87
|
+
let body;
|
|
88
|
+
if (typeof flags.body === 'string')
|
|
89
|
+
body = resolveBody(flags.body);
|
|
90
|
+
else if (flags.body === true)
|
|
91
|
+
error('--body needs a value: inline Markdown or @path-to-file.');
|
|
92
|
+
let importance;
|
|
93
|
+
if (typeof flags.importance === 'string') {
|
|
94
|
+
if (!TASK_IMPORTANCE.includes(flags.importance)) {
|
|
95
|
+
error(`Invalid --importance "${flags.importance}". Must be one of: ${TASK_IMPORTANCE.join(', ')}.`);
|
|
96
|
+
}
|
|
97
|
+
importance = flags.importance;
|
|
98
|
+
}
|
|
99
|
+
const t = await sdkTask.createTask(config.api_key, orgId, {
|
|
100
|
+
description,
|
|
101
|
+
body,
|
|
102
|
+
importance,
|
|
103
|
+
dueAt: typeof flags.due === 'string' ? flags.due : undefined,
|
|
104
|
+
assignee: typeof flags.assignee === 'string' ? flags.assignee : undefined,
|
|
105
|
+
tags: _splitList(flags.tag),
|
|
106
|
+
dependsOn: _splitList(flags['depends-on']),
|
|
107
|
+
dedupKey: typeof flags['dedup-key'] === 'string' ? flags['dedup-key'] : undefined,
|
|
108
|
+
resolveOn,
|
|
109
|
+
source: typeof flags.source === 'string' ? flags.source : undefined,
|
|
110
|
+
});
|
|
111
|
+
if (flags.json) {
|
|
112
|
+
printJson(t);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
success(`Task created: ${t.id}`);
|
|
116
|
+
info(`Status: ${t.status}`);
|
|
117
|
+
if (t.assignee)
|
|
118
|
+
info(`Assignee: ${t.assignee} (magic link emailed)`);
|
|
119
|
+
}
|
|
120
|
+
export async function list(flags) {
|
|
121
|
+
const config = requireConfig();
|
|
122
|
+
const orgId = requireOrg(flags, config, 'myapi task list [--org <id>]');
|
|
123
|
+
const res = await sdkTask.listTasks(config.api_key, orgId, {
|
|
124
|
+
status: typeof flags.status === 'string' ? flags.status : undefined,
|
|
125
|
+
tag: typeof flags.tag === 'string' ? flags.tag : undefined,
|
|
126
|
+
importance: typeof flags.importance === 'string' ? flags.importance : undefined,
|
|
127
|
+
assignee: typeof flags.assignee === 'string' ? flags.assignee : undefined,
|
|
128
|
+
source: typeof flags.source === 'string' ? flags.source : undefined,
|
|
129
|
+
limit: typeof flags.limit === 'number' ? flags.limit : undefined,
|
|
130
|
+
});
|
|
131
|
+
if (flags.json) {
|
|
132
|
+
printJson(res);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
printTable(res.tasks.map(t => ({
|
|
136
|
+
id: t.id,
|
|
137
|
+
score: t.score,
|
|
138
|
+
description: t.description,
|
|
139
|
+
})), { flags, empty: 'No open tasks.' });
|
|
140
|
+
info('');
|
|
141
|
+
info(`Showing ${res.meta.shown} of ${res.meta.total_open} open task(s).`);
|
|
142
|
+
}
|
|
143
|
+
// ── Read ─────────────────────────────────────────────────────────────────────
|
|
144
|
+
export async function get(id, flags) {
|
|
145
|
+
const config = requireConfig();
|
|
146
|
+
const orgId = requireOrg(flags, config, 'myapi task get <id> [--body] [--org <id>]');
|
|
147
|
+
requireArg(id, 'id', 'myapi task get <id>');
|
|
148
|
+
const t = await sdkTask.getTask(config.api_key, orgId, id);
|
|
149
|
+
// --body opts in to the separate body tier — never fetched implicitly.
|
|
150
|
+
const wantBody = flags.body === true || typeof flags.body === 'string';
|
|
151
|
+
const body = wantBody ? await sdkTask.getTaskBody(config.api_key, orgId, id) : undefined;
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
printJson(wantBody ? { ...t, body } : t);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
info(`Task: ${t.id}`);
|
|
157
|
+
info(`Status: ${t.status}`);
|
|
158
|
+
info(`Description: ${t.description}`);
|
|
159
|
+
if (t.importance)
|
|
160
|
+
info(`Importance: ${t.importance}`);
|
|
161
|
+
if (t.due_at)
|
|
162
|
+
info(`Due: ${formatDate(t.due_at)}`);
|
|
163
|
+
if (t.assignee)
|
|
164
|
+
info(`Assignee: ${t.assignee}`);
|
|
165
|
+
if (t.tags?.length)
|
|
166
|
+
info(`Tags: ${t.tags.join(', ')}`);
|
|
167
|
+
if (t.depends_on?.length)
|
|
168
|
+
info(`Depends on: ${t.depends_on.join(', ')}`);
|
|
169
|
+
if (t.claimed_by)
|
|
170
|
+
info(`Claimed by: ${t.claimed_by}`);
|
|
171
|
+
if (t.lease_expires_at)
|
|
172
|
+
info(`Lease until: ${formatDate(t.lease_expires_at)}`);
|
|
173
|
+
if (wantBody) {
|
|
174
|
+
info('');
|
|
175
|
+
info('── body ──');
|
|
176
|
+
info(body ?? '');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
180
|
+
export async function claim(id, flags) {
|
|
181
|
+
const config = requireConfig();
|
|
182
|
+
const orgId = requireOrg(flags, config, 'myapi task claim <id> [--lease <seconds>] [--worker <name>] [--org <id>]');
|
|
183
|
+
requireArg(id, 'id', 'myapi task claim <id>');
|
|
184
|
+
const t = await sdkTask.claimTask(config.api_key, orgId, id, {
|
|
185
|
+
leaseSeconds: typeof flags.lease === 'number' ? flags.lease : undefined,
|
|
186
|
+
worker: typeof flags.worker === 'string' ? flags.worker : undefined,
|
|
187
|
+
});
|
|
188
|
+
if (flags.json) {
|
|
189
|
+
printJson(t);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
success(`Claimed task ${t.id}`);
|
|
193
|
+
if (t.lease_expires_at)
|
|
194
|
+
info(`Lease expires: ${formatDate(t.lease_expires_at)} — heartbeat with: myapi task extend ${t.id}`);
|
|
195
|
+
}
|
|
196
|
+
export async function extend(id, flags) {
|
|
197
|
+
const config = requireConfig();
|
|
198
|
+
const orgId = requireOrg(flags, config, 'myapi task extend <id> [--lease <seconds>] [--org <id>]');
|
|
199
|
+
requireArg(id, 'id', 'myapi task extend <id>');
|
|
200
|
+
const t = await sdkTask.extendTask(config.api_key, orgId, id, {
|
|
201
|
+
leaseSeconds: typeof flags.lease === 'number' ? flags.lease : undefined,
|
|
202
|
+
});
|
|
203
|
+
if (flags.json) {
|
|
204
|
+
printJson(t);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
success(`Extended lease on task ${t.id}`);
|
|
208
|
+
if (t.lease_expires_at)
|
|
209
|
+
info(`Lease expires: ${formatDate(t.lease_expires_at)}`);
|
|
210
|
+
}
|
|
211
|
+
export async function resolve(id, flags) {
|
|
212
|
+
const config = requireConfig();
|
|
213
|
+
const orgId = requireOrg(flags, config, 'myapi task resolve <id> [--org <id>]');
|
|
214
|
+
requireArg(id, 'id', 'myapi task resolve <id>');
|
|
215
|
+
await sdkTask.resolveTask(config.api_key, orgId, id);
|
|
216
|
+
success(`Resolved task ${id}`);
|
|
217
|
+
}
|
|
218
|
+
export async function fail(id, flags) {
|
|
219
|
+
const config = requireConfig();
|
|
220
|
+
const orgId = requireOrg(flags, config, 'myapi task fail <id> --reason "<why>" [--org <id>]');
|
|
221
|
+
requireArg(id, 'id', 'myapi task fail <id> --reason "<why>"');
|
|
222
|
+
const reason = flags.reason;
|
|
223
|
+
if (typeof reason !== 'string' || reason.trim() === '') {
|
|
224
|
+
error('Missing --reason.\nUsage: myapi task fail <id> --reason "<why>"');
|
|
225
|
+
}
|
|
226
|
+
await sdkTask.failTask(config.api_key, orgId, id, reason);
|
|
227
|
+
success(`Failed task ${id}`);
|
|
228
|
+
}
|
|
229
|
+
export async function cancel(id, flags) {
|
|
230
|
+
const config = requireConfig();
|
|
231
|
+
const orgId = requireOrg(flags, config, 'myapi task cancel <id> [--org <id>]');
|
|
232
|
+
requireArg(id, 'id', 'myapi task cancel <id>');
|
|
233
|
+
await sdkTask.cancelTask(config.api_key, orgId, id);
|
|
234
|
+
success(`Cancelled task ${id}`);
|
|
235
|
+
}
|
|
236
|
+
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
237
|
+
const SUBCOMMAND_USAGE = {
|
|
238
|
+
'create': 'myapi task create "<description>" [--body <md|@file>] [--importance <i>] [--due <rfc3339>] [--assignee <email>] [--tag <t,t>] [--depends-on <id,id>] [--dedup-key <k>] [--resolve-on <event[:field=value]>] [--source <s>] [--org <id>]',
|
|
239
|
+
'list': 'myapi task list [--status <s>] [--tag <t>] [--importance <i>] [--assignee <email>] [--source <s>] [--limit <n>] [--org <id>] [--json]',
|
|
240
|
+
'get': 'myapi task get <id> [--body] [--org <id>] [--json]\n\n--body additionally fetches the Markdown body tier (a separate read).',
|
|
241
|
+
'claim': 'myapi task claim <id> [--lease <seconds>] [--worker <name>] [--org <id>]',
|
|
242
|
+
'extend': 'myapi task extend <id> [--lease <seconds>] [--org <id>]',
|
|
243
|
+
'resolve': 'myapi task resolve <id> [--org <id>]',
|
|
244
|
+
'fail': 'myapi task fail <id> --reason "<why>" [--org <id>]',
|
|
245
|
+
'cancel': 'myapi task cancel <id> [--org <id>]',
|
|
246
|
+
};
|
|
247
|
+
export async function run(subcommand, args, flags) {
|
|
248
|
+
if (!subcommand || (flags.help && !subcommand)) {
|
|
249
|
+
info(`Usage: myapi task <subcommand>
|
|
250
|
+
|
|
251
|
+
An agent-task queue — the agent-loop hot path. Tasks are ranked by score,
|
|
252
|
+
claimed under a lease, then resolved, failed, or cancelled.
|
|
253
|
+
|
|
254
|
+
create "<description>" File a task (--body, --importance, --assignee, --resolve-on, ...)
|
|
255
|
+
list Top open tasks, ranked (--status, --tag, --limit)
|
|
256
|
+
get <id> Show a task; --body also fetches the Markdown body tier
|
|
257
|
+
claim <id> Take an atomic lease (default 10 min)
|
|
258
|
+
extend <id> Heartbeat a live claim
|
|
259
|
+
resolve <id> Resolve a task (terminal) — unblocks dependents
|
|
260
|
+
fail <id> Fail a task (terminal) — requires --reason
|
|
261
|
+
cancel <id> Cancel a task (terminal, distinct from fail)
|
|
262
|
+
|
|
263
|
+
The agent loop is: list → get <id> --body → claim → resolve.
|
|
264
|
+
For durable machine work (retried HTTP jobs), see: myapi queue --help
|
|
265
|
+
|
|
266
|
+
All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (flags.help) {
|
|
270
|
+
const usage = SUBCOMMAND_USAGE[subcommand];
|
|
271
|
+
if (usage)
|
|
272
|
+
info(`Usage: ${usage}`);
|
|
273
|
+
else
|
|
274
|
+
info(`Unknown subcommand: ${subcommand}. Run "myapi task --help" for the list.`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
switch (subcommand) {
|
|
278
|
+
case 'create': return create(args[0], flags);
|
|
279
|
+
case 'list': return list(flags);
|
|
280
|
+
case 'get': return get(args[0], flags);
|
|
281
|
+
case 'claim': return claim(args[0], flags);
|
|
282
|
+
case 'extend': return extend(args[0], flags);
|
|
283
|
+
case 'resolve': return resolve(args[0], flags);
|
|
284
|
+
case 'fail': return fail(args[0], flags);
|
|
285
|
+
case 'cancel': return cancel(args[0], flags);
|
|
286
|
+
default: error(`Unknown subcommand: ${subcommand}. Run "myapi task --help" for a list of valid subcommands.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -120,6 +120,33 @@ describe('_validateSteps — http_request / http (2026-05-15)', () => {
|
|
|
120
120
|
.toMatch(/"headers" value for "X-Token" must be a string/);
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
|
+
describe('_validateSteps — enqueue_job / enqueue (2026-05-19)', () => {
|
|
124
|
+
const VALID_ENQUEUE = { type: 'enqueue_job', queue: 'thumbnails' };
|
|
125
|
+
it('accepts the canonical type name', () => {
|
|
126
|
+
expect(_validateSteps([VALID_ENQUEUE])).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
it('accepts the `enqueue` alias', () => {
|
|
129
|
+
expect(_validateSteps([{ ...VALID_ENQUEUE, type: 'enqueue' }])).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
it('accepts a string payload with templating', () => {
|
|
132
|
+
expect(_validateSteps([{ ...VALID_ENQUEUE, payload: '{"email":"{{ payload.email }}"}' }])).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
it('accepts dedup_key and delay_seconds', () => {
|
|
135
|
+
expect(_validateSteps([{ ...VALID_ENQUEUE, dedup_key: 'k1', delay_seconds: 30 }])).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
it('rejects when queue is missing', () => {
|
|
138
|
+
expect(_validateSteps([{ type: 'enqueue_job' }]))
|
|
139
|
+
.toMatch(/missing required field "queue"/);
|
|
140
|
+
});
|
|
141
|
+
it('rejects a non-string payload', () => {
|
|
142
|
+
expect(_validateSteps([{ ...VALID_ENQUEUE, payload: { not: 'a string' } }]))
|
|
143
|
+
.toMatch(/"payload" must be a string/);
|
|
144
|
+
});
|
|
145
|
+
it('rejects a non-number delay_seconds', () => {
|
|
146
|
+
expect(_validateSteps([{ ...VALID_ENQUEUE, delay_seconds: '30' }]))
|
|
147
|
+
.toMatch(/"delay_seconds" must be a number/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
123
150
|
describe('_validateSteps — mixed-type pipelines', () => {
|
|
124
151
|
it('accepts an http_request after a send_email (pipeline shape)', () => {
|
|
125
152
|
expect(_validateSteps([
|
|
@@ -26,7 +26,9 @@ export const SCHEMA = {
|
|
|
26
26
|
//
|
|
27
27
|
// 2026-05-15: backend added `http_request` (alias `http`). See
|
|
28
28
|
// myapi-hq/internal/routes/workflow/execute.go.
|
|
29
|
-
|
|
29
|
+
// 2026-05-19: `enqueue_job` (alias `enqueue`) added — hands durable work to
|
|
30
|
+
// my-queue-api. Backend step pending; see the orchestration cross-repo prompt.
|
|
31
|
+
const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack', 'http_request', 'http', 'enqueue_job', 'enqueue'];
|
|
30
32
|
const HTTP_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']);
|
|
31
33
|
const SLACK_HOOK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/;
|
|
32
34
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -110,6 +112,20 @@ export function _validateSteps(steps) {
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
}
|
|
115
|
+
if (s.type === 'enqueue_job' || s.type === 'enqueue') {
|
|
116
|
+
if (!s.queue || typeof s.queue !== 'string') {
|
|
117
|
+
return `${where} (${s.type}): missing required field "queue" (the target queue name).`;
|
|
118
|
+
}
|
|
119
|
+
if (s.payload !== undefined && typeof s.payload !== 'string') {
|
|
120
|
+
return `${where} (${s.type}): "payload" must be a string (template substitutions supported).`;
|
|
121
|
+
}
|
|
122
|
+
if (s.dedup_key !== undefined && typeof s.dedup_key !== 'string') {
|
|
123
|
+
return `${where} (${s.type}): "dedup_key" must be a string.`;
|
|
124
|
+
}
|
|
125
|
+
if (s.delay_seconds !== undefined && typeof s.delay_seconds !== 'number') {
|
|
126
|
+
return `${where} (${s.type}): "delay_seconds" must be a number.`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
113
129
|
}
|
|
114
130
|
return null;
|
|
115
131
|
}
|
package/dist/completion.js
CHANGED
|
@@ -28,8 +28,8 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
|
|
|
28
28
|
export const COMMANDS = [
|
|
29
29
|
'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
|
|
30
30
|
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
|
|
31
|
-
'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel',
|
|
32
|
-
'setup', 'status', 'storage', 'update', 'url', 'webhook', 'whoami',
|
|
31
|
+
'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel', 'queue',
|
|
32
|
+
'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
|
|
33
33
|
'workflow',
|
|
34
34
|
];
|
|
35
35
|
// command → subcommands, for `myapi <command> <TAB>`. Mirrors each
|
|
@@ -59,6 +59,8 @@ export const SUBCOMMANDS = {
|
|
|
59
59
|
payments: ['connect', 'status', 'charge', 'list', 'get', 'refund'],
|
|
60
60
|
container: ['create', 'deploy', 'list', 'get', 'logs', 'delete'],
|
|
61
61
|
git: ['create', 'list', 'get', 'delete', 'refs', 'log', 'show', 'tree', 'blob', 'diff', 'commit', 'create-branch', 'delete-branch', 'tag', 'merge', 'repack'],
|
|
62
|
+
queue: ['create', 'list', 'get', 'enqueue', 'jobs', 'job'],
|
|
63
|
+
task: ['create', 'list', 'get', 'claim', 'extend', 'resolve', 'fail', 'cancel'],
|
|
62
64
|
completion: ['install', 'uninstall'],
|
|
63
65
|
};
|
|
64
66
|
// ── Completion request handling ──────────────────────────────────────────────
|
package/dist/exposes.test.js
CHANGED
|
@@ -41,6 +41,8 @@ const COMMAND_MODULES = [
|
|
|
41
41
|
'./commands/payments.js',
|
|
42
42
|
'./commands/container.js',
|
|
43
43
|
'./commands/git.js',
|
|
44
|
+
'./commands/queue.js',
|
|
45
|
+
'./commands/task.js',
|
|
44
46
|
];
|
|
45
47
|
const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
|
|
46
48
|
describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,8 @@ import * as fnCmd from './commands/fn.js';
|
|
|
33
33
|
import * as paymentsCmd from './commands/payments.js';
|
|
34
34
|
import * as containerCmd from './commands/container.js';
|
|
35
35
|
import * as gitCmd from './commands/git.js';
|
|
36
|
+
import * as queueCmd from './commands/queue.js';
|
|
37
|
+
import * as taskCmd from './commands/task.js';
|
|
36
38
|
// Each command file declares the value flags it understands. We union them
|
|
37
39
|
// into a single schema for the upfront parse, so adding a new value flag in
|
|
38
40
|
// one command means editing one file (its SCHEMA), not a global allowlist.
|
|
@@ -62,6 +64,8 @@ const COMBINED_SCHEMA = {
|
|
|
62
64
|
...paymentsCmd.SCHEMA,
|
|
63
65
|
...containerCmd.SCHEMA,
|
|
64
66
|
...gitCmd.SCHEMA,
|
|
67
|
+
...queueCmd.SCHEMA,
|
|
68
|
+
...taskCmd.SCHEMA,
|
|
65
69
|
// Top-level flags
|
|
66
70
|
version: 'boolean',
|
|
67
71
|
V: 'boolean',
|
|
@@ -224,6 +228,12 @@ async function main() {
|
|
|
224
228
|
case 'git':
|
|
225
229
|
await gitCmd.run(subcommand, restArgs, flags);
|
|
226
230
|
break;
|
|
231
|
+
case 'queue':
|
|
232
|
+
await queueCmd.run(subcommand, restArgs, flags);
|
|
233
|
+
break;
|
|
234
|
+
case 'task':
|
|
235
|
+
await taskCmd.run(subcommand, restArgs, flags);
|
|
236
|
+
break;
|
|
227
237
|
// Convenience aliases
|
|
228
238
|
case 'setup':
|
|
229
239
|
await setupCmd.setup(flags);
|
|
@@ -348,6 +358,8 @@ const HELP_TARGETS = {
|
|
|
348
358
|
payments: f => paymentsCmd.run(undefined, [], f),
|
|
349
359
|
container: f => containerCmd.run(undefined, [], f),
|
|
350
360
|
git: f => gitCmd.run(undefined, [], f),
|
|
361
|
+
queue: f => queueCmd.run(undefined, [], f),
|
|
362
|
+
task: f => taskCmd.run(undefined, [], f),
|
|
351
363
|
org: f => orgCmd.run(undefined, [], f),
|
|
352
364
|
billing: f => billingCmd.run(undefined, [], f),
|
|
353
365
|
keys: f => keysCmd.run(undefined, [], f),
|
|
@@ -392,6 +404,8 @@ Commands:
|
|
|
392
404
|
fn Create and deploy functions on the edge runtime
|
|
393
405
|
container Run containers — services, workers, and scheduled jobs
|
|
394
406
|
git Hosted git repositories — repos, commits, branches, history
|
|
407
|
+
queue Durable job queue — enqueue work, retried against an HTTP consumer
|
|
408
|
+
task Agent-task queue — file, claim, and resolve units of work
|
|
395
409
|
payments Take payments with Stripe Checkout (connect, charge, refund)
|
|
396
410
|
webhook Manage inbound webhook endpoints and inspect deliveries
|
|
397
411
|
email Manage mailboxes, send/read email, templates, and campaigns
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SDK-level unit tests for mailbox forwarding —
|
|
2
|
+
// email.setForwarding + email.deleteForwarding. Mocks fetch — no network.
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
4
|
+
import { email } from '@myapihq/sdk';
|
|
5
|
+
const API_KEY = 'myapi_test_abc';
|
|
6
|
+
let fetchMock;
|
|
7
|
+
function ok(data, status = 200) {
|
|
8
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
9
|
+
status, headers: { 'content-type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function fail(code, status) {
|
|
13
|
+
return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
|
|
14
|
+
status, headers: { 'content-type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
|
|
18
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
19
|
+
describe('email.setForwarding', () => {
|
|
20
|
+
it('PUTs {forward_to} to the mailbox forwarding endpoint', async () => {
|
|
21
|
+
fetchMock.mockResolvedValueOnce(ok({ address: 'a@x.com', forward_to: 'b@y.com' }));
|
|
22
|
+
const res = await email.setForwarding(API_KEY, 'a@x.com', 'b@y.com');
|
|
23
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
24
|
+
expect(url).toContain('/email/mailboxes/a%40x.com/forwarding');
|
|
25
|
+
expect(init.method).toBe('PUT');
|
|
26
|
+
expect(JSON.parse(init.body)).toEqual({ forward_to: 'b@y.com' });
|
|
27
|
+
expect(res.forward_to).toBe('b@y.com');
|
|
28
|
+
});
|
|
29
|
+
it('surfaces FORWARD_LOOP (400)', async () => {
|
|
30
|
+
fetchMock.mockResolvedValueOnce(fail('FORWARD_LOOP', 400));
|
|
31
|
+
await expect(email.setForwarding(API_KEY, 'a@x.com', 'a@x.com'))
|
|
32
|
+
.rejects.toMatchObject({ code: 'FORWARD_LOOP', status: 400 });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('email.deleteForwarding', () => {
|
|
36
|
+
it('DELETEs the forwarding endpoint and resolves on 204', async () => {
|
|
37
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
38
|
+
await email.deleteForwarding(API_KEY, 'a@x.com');
|
|
39
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
40
|
+
expect(url).toContain('/email/mailboxes/a%40x.com/forwarding');
|
|
41
|
+
expect(init.method).toBe('DELETE');
|
|
42
|
+
});
|
|
43
|
+
it('surfaces MAILBOX_NOT_OWNED (403)', async () => {
|
|
44
|
+
fetchMock.mockResolvedValueOnce(fail('MAILBOX_NOT_OWNED', 403));
|
|
45
|
+
await expect(email.deleteForwarding(API_KEY, 'a@x.com'))
|
|
46
|
+
.rejects.toMatchObject({ code: 'MAILBOX_NOT_OWNED', status: 403 });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SDK-level unit tests for the queue module. Verifies URL/body shape,
|
|
2
|
+
// envelope-unwrapping, query params, and error handling against
|
|
3
|
+
// myapi-hq/internal/routes/queue/. Mocks global fetch — no network.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { queue } from '@myapihq/sdk';
|
|
6
|
+
const API_KEY = 'myapi_test_abc';
|
|
7
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
8
|
+
let fetchMock;
|
|
9
|
+
function ok(data, status = 200) {
|
|
10
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
11
|
+
status, headers: { 'content-type': 'application/json' },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function fail(code, status) {
|
|
15
|
+
return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
|
|
16
|
+
status, headers: { 'content-type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
|
|
20
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
21
|
+
describe('queue queues', () => {
|
|
22
|
+
it('createQueue POSTs {name, consumer_url} and optional policy fields', async () => {
|
|
23
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'q', consumer_url: 'https://c', max_attempts: 5, max_concurrency: 5 }, 201));
|
|
24
|
+
await queue.createQueue(API_KEY, ORG, {
|
|
25
|
+
name: 'q', consumerUrl: 'https://c', maxAttempts: 3, maxConcurrency: 2,
|
|
26
|
+
});
|
|
27
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
28
|
+
expect(url).toContain(`/queue/orgs/${ORG}/queues`);
|
|
29
|
+
expect(init.method).toBe('POST');
|
|
30
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
31
|
+
name: 'q', consumer_url: 'https://c', max_attempts: 3, max_concurrency: 2,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('createQueue omits unset optional fields', async () => {
|
|
35
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'q', consumer_url: 'https://c', max_attempts: 5, max_concurrency: 5 }, 201));
|
|
36
|
+
await queue.createQueue(API_KEY, ORG, { name: 'q', consumerUrl: 'https://c' });
|
|
37
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'q', consumer_url: 'https://c' });
|
|
38
|
+
});
|
|
39
|
+
it('listQueues unwraps the {queues:[...]} envelope to an array', async () => {
|
|
40
|
+
fetchMock.mockResolvedValueOnce(ok({ queues: [{ name: 'a' }, { name: 'b' }] }));
|
|
41
|
+
expect(await queue.listQueues(API_KEY, ORG)).toEqual([{ name: 'a' }, { name: 'b' }]);
|
|
42
|
+
});
|
|
43
|
+
it('getQueue GETs /queues/{name}', async () => {
|
|
44
|
+
fetchMock.mockResolvedValueOnce(ok({ name: 'q', consumer_url: 'https://c', max_attempts: 5, max_concurrency: 5 }));
|
|
45
|
+
const q = await queue.getQueue(API_KEY, ORG, 'q');
|
|
46
|
+
expect(q.max_attempts).toBe(5);
|
|
47
|
+
expect(fetchMock.mock.calls[0][0]).toContain(`/queue/orgs/${ORG}/queues/q`);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('queue jobs', () => {
|
|
51
|
+
it('enqueueJob POSTs payload/dedup_key/delay_seconds/depends_on', async () => {
|
|
52
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 'j1', queue: 'q', status: 'pending', attempts: 0 }, 201));
|
|
53
|
+
await queue.enqueueJob(API_KEY, ORG, 'q', {
|
|
54
|
+
payload: { x: 1 }, dedupKey: 'k', delaySeconds: 30, dependsOn: ['j0'],
|
|
55
|
+
});
|
|
56
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
57
|
+
expect(url).toContain(`/queue/orgs/${ORG}/queues/q/jobs`);
|
|
58
|
+
expect(init.method).toBe('POST');
|
|
59
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
60
|
+
payload: { x: 1 }, dedup_key: 'k', delay_seconds: 30, depends_on: ['j0'],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
it('listJobs unwraps {jobs} and passes status/limit as query', async () => {
|
|
64
|
+
fetchMock.mockResolvedValueOnce(ok({ jobs: [{ id: 'j1', status: 'dead' }] }));
|
|
65
|
+
const jobs = await queue.listJobs(API_KEY, ORG, 'q', { status: 'dead', limit: 50 });
|
|
66
|
+
expect(jobs).toHaveLength(1);
|
|
67
|
+
const url = fetchMock.mock.calls[0][0];
|
|
68
|
+
expect(url).toMatch(/\/jobs\?/);
|
|
69
|
+
expect(url).toContain('status=dead');
|
|
70
|
+
expect(url).toContain('limit=50');
|
|
71
|
+
});
|
|
72
|
+
it('getJob GETs /jobs/{id} and surfaces 404', async () => {
|
|
73
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 'j1', queue: 'q', status: 'succeeded', attempts: 1 }));
|
|
74
|
+
expect((await queue.getJob(API_KEY, ORG, 'j1')).status).toBe('succeeded');
|
|
75
|
+
expect(fetchMock.mock.calls[0][0]).toContain(`/queue/orgs/${ORG}/jobs/j1`);
|
|
76
|
+
fetchMock.mockResolvedValueOnce(fail('job not found', 404));
|
|
77
|
+
await expect(queue.getJob(API_KEY, ORG, 'gone')).rejects.toMatchObject({ status: 404 });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('queue.EXPOSES', () => {
|
|
81
|
+
it('covers all 6 queue endpoints', () => {
|
|
82
|
+
expect(queue.EXPOSES).toHaveLength(6);
|
|
83
|
+
expect(queue.EXPOSES).toContain('POST /queue/orgs/{org_id}/queues/{name}/jobs');
|
|
84
|
+
expect(queue.EXPOSES).toContain('GET /queue/orgs/{org_id}/jobs/{id}');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SDK-level unit tests for the task module. Verifies URL/body shape,
|
|
2
|
+
// envelope-unwrapping, the list meta {shown,total_open}, the separate body
|
|
3
|
+
// tier, and lease modeling against myapi-hq/internal/routes/task/.
|
|
4
|
+
// Mocks global fetch — no network.
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
import { task } from '@myapihq/sdk';
|
|
7
|
+
const API_KEY = 'myapi_test_abc';
|
|
8
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
9
|
+
let fetchMock;
|
|
10
|
+
function ok(data, status = 200) {
|
|
11
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
12
|
+
status, headers: { 'content-type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function fail(code, status) {
|
|
16
|
+
return new Response(JSON.stringify({ success: false, error: { code, message: code }, meta: {} }), {
|
|
17
|
+
status, headers: { 'content-type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
beforeEach(() => { fetchMock = vi.fn(); globalThis.fetch = fetchMock; });
|
|
21
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
22
|
+
describe('task create / list', () => {
|
|
23
|
+
it('createTask POSTs {description} plus only the set optional fields', async () => {
|
|
24
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'do x', status: 'open' }, 201));
|
|
25
|
+
await task.createTask(API_KEY, ORG, {
|
|
26
|
+
description: 'do x', importance: 'high', dependsOn: ['t0'],
|
|
27
|
+
resolveOn: { event_type: 'payment.succeeded', field: 'id', value: '42' },
|
|
28
|
+
});
|
|
29
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
30
|
+
expect(url).toContain(`/task/orgs/${ORG}/tasks`);
|
|
31
|
+
expect(init.method).toBe('POST');
|
|
32
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
33
|
+
description: 'do x', importance: 'high', depends_on: ['t0'],
|
|
34
|
+
resolve_on: { event_type: 'payment.succeeded', field: 'id', value: '42' },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it('listTasks reads tasks + data.meta {shown, total_open}', async () => {
|
|
38
|
+
fetchMock.mockResolvedValueOnce(ok({ tasks: [{ id: 't1', description: 'a', score: 9 }], meta: { shown: 1, total_open: 12 } }));
|
|
39
|
+
const res = await task.listTasks(API_KEY, ORG);
|
|
40
|
+
expect(res.tasks).toHaveLength(1);
|
|
41
|
+
expect(res.meta).toEqual({ shown: 1, total_open: 12 });
|
|
42
|
+
});
|
|
43
|
+
it('listTasks passes filter options as query params', async () => {
|
|
44
|
+
fetchMock.mockResolvedValueOnce(ok({ tasks: [], meta: { shown: 0, total_open: 0 } }));
|
|
45
|
+
await task.listTasks(API_KEY, ORG, { status: 'open', limit: 5 });
|
|
46
|
+
const url = fetchMock.mock.calls[0][0];
|
|
47
|
+
expect(url).toContain('status=open');
|
|
48
|
+
expect(url).toContain('limit=5');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('task get / body tier', () => {
|
|
52
|
+
it('getTask GETs /tasks/{id} and does NOT touch the body endpoint', async () => {
|
|
53
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'open' }));
|
|
54
|
+
await task.getTask(API_KEY, ORG, 't1');
|
|
55
|
+
const url = fetchMock.mock.calls[0][0];
|
|
56
|
+
expect(url).toContain(`/task/orgs/${ORG}/tasks/t1`);
|
|
57
|
+
expect(url).not.toMatch(/\/body$/);
|
|
58
|
+
});
|
|
59
|
+
it('getTaskBody fetches the /body tier and unwraps {task_id, body}', async () => {
|
|
60
|
+
fetchMock.mockResolvedValueOnce(ok({ task_id: 't1', body: '# context' }));
|
|
61
|
+
expect(await task.getTaskBody(API_KEY, ORG, 't1')).toBe('# context');
|
|
62
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/body$/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('task lifecycle', () => {
|
|
66
|
+
it('claimTask POSTs {lease_seconds, worker} and returns the full task', async () => {
|
|
67
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
68
|
+
id: 't1', description: 'a', status: 'claimed',
|
|
69
|
+
lease_expires_at: '2026-05-19T10:10:00Z', claimed_by: 'agent-7',
|
|
70
|
+
}));
|
|
71
|
+
const t = await task.claimTask(API_KEY, ORG, 't1', { leaseSeconds: 600, worker: 'agent-7' });
|
|
72
|
+
expect(t.status).toBe('claimed');
|
|
73
|
+
expect(t.lease_expires_at).toBe('2026-05-19T10:10:00Z');
|
|
74
|
+
expect(t.claimed_by).toBe('agent-7');
|
|
75
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
76
|
+
expect(url).toMatch(/\/tasks\/t1\/claim$/);
|
|
77
|
+
expect(JSON.parse(init.body)).toEqual({ lease_seconds: 600, worker: 'agent-7' });
|
|
78
|
+
});
|
|
79
|
+
it('extendTask POSTs to /extend', async () => {
|
|
80
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'claimed' }));
|
|
81
|
+
await task.extendTask(API_KEY, ORG, 't1', { leaseSeconds: 300 });
|
|
82
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/extend$/);
|
|
83
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ lease_seconds: 300 });
|
|
84
|
+
});
|
|
85
|
+
it('resolveTask POSTs to /resolve', async () => {
|
|
86
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'resolved' }));
|
|
87
|
+
await task.resolveTask(API_KEY, ORG, 't1');
|
|
88
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/resolve$/);
|
|
89
|
+
});
|
|
90
|
+
it('failTask POSTs {reason} to /fail', async () => {
|
|
91
|
+
fetchMock.mockResolvedValueOnce(ok({ id: 't1', description: 'a', status: 'failed' }));
|
|
92
|
+
await task.failTask(API_KEY, ORG, 't1', 'blocked on legal');
|
|
93
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/tasks\/t1\/fail$/);
|
|
94
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ reason: 'blocked on legal' });
|
|
95
|
+
});
|
|
96
|
+
it('cancelTask DELETEs the task and surfaces 404', async () => {
|
|
97
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
98
|
+
await task.cancelTask(API_KEY, ORG, 't1');
|
|
99
|
+
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE');
|
|
100
|
+
fetchMock.mockResolvedValueOnce(fail('task not found', 404));
|
|
101
|
+
await expect(task.cancelTask(API_KEY, ORG, 'gone')).rejects.toMatchObject({ status: 404 });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('task.EXPOSES', () => {
|
|
105
|
+
it('covers all 9 task endpoints', () => {
|
|
106
|
+
expect(task.EXPOSES).toHaveLength(9);
|
|
107
|
+
expect(task.EXPOSES).toContain('GET /task/orgs/{org_id}/tasks/{id}/body');
|
|
108
|
+
expect(task.EXPOSES).toContain('POST /task/orgs/{org_id}/tasks/{id}/claim');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -21,8 +21,10 @@ Step types today:
|
|
|
21
21
|
|---|---|---|
|
|
22
22
|
| `send_email` | `email` | `from`, `to`, `subject`, plus one of `body` / `html` / `template_id` |
|
|
23
23
|
| `slack_message` | `slack` | `webhook_url`, `text` |
|
|
24
|
+
| `http_request` | `http` | `url` (optional: `method`, `body`, `headers`) |
|
|
25
|
+
| `enqueue_job` | `enqueue` | `queue` (optional: `payload`, `dedup_key`, `delay_seconds`) — hands durable work to **my-queue-api** |
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
All step types support **payload templating** with `{{ payload.fieldname }}`. The webhook payload is the entire POST body; named fields are accessed dotted. Whitespace inside braces is fine — both `{{ payload.email }}` and `{{payload.email}}` work.
|
|
26
28
|
|
|
27
29
|
Unknown step types are rejected at create time, so typos surface immediately rather than after 3 failed retries during execution.
|
|
28
30
|
|
|
@@ -95,4 +97,11 @@ myapi workflow enable <id>
|
|
|
95
97
|
- Disable to stop firing without losing the configuration.
|
|
96
98
|
- Deleting a workflow purges its run history; the webhook endpoint remains.
|
|
97
99
|
|
|
100
|
+
## See also
|
|
101
|
+
|
|
102
|
+
`workflow` is one of three orchestration slots. Use **workflow** to react to
|
|
103
|
+
inbound webhooks; use **my-queue-api** for durable retried machine work; use
|
|
104
|
+
**my-task-api** for work that needs an agent/human decision. Full comparison:
|
|
105
|
+
`docs/orchestration-decision-guide.md`.
|
|
106
|
+
|
|
98
107
|
Run `myapi workflow --help` for full flag reference.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.0",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"lint:changelog": "node ../../scripts/lint-changelog.js"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@myapihq/sdk": "^1.
|
|
32
|
+
"@myapihq/sdk": "^1.3.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.6.0",
|