run402 4.17.9 → 4.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +12 -0
- package/lib/buzz.mjs +3 -0
- package/lib/claims.mjs +165 -0
- package/lib/command-manifest.mjs +8 -0
- package/lib/credentials.mjs +29 -5
- package/lib/pay.mjs +35 -4
- package/lib/pay.test.mjs +23 -1
- package/lib/rooms-context.mjs +132 -0
- package/lib/rooms.mjs +297 -0
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +9 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +9 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/rooms.d.ts +165 -0
- package/sdk/dist/namespaces/rooms.d.ts.map +1 -0
- package/sdk/dist/namespaces/rooms.js +399 -0
- package/sdk/dist/namespaces/rooms.js.map +1 -0
- package/sdk/dist/namespaces/rooms.types.d.ts +296 -0
- package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -0
- package/sdk/dist/namespaces/rooms.types.js +27 -0
- package/sdk/dist/namespaces/rooms.types.js.map +1 -0
package/cli.mjs
CHANGED
|
@@ -45,6 +45,8 @@ Commands:
|
|
|
45
45
|
grants Per-project capability grants for agent/CI principals (create, revoke)
|
|
46
46
|
delegates Scoped deploy credentials for agents (create, list, revoke, rotate)
|
|
47
47
|
events What happened to your project since you last looked (cursored feed)
|
|
48
|
+
rooms Coordinate with the other agents on your project (who/send/ack)
|
|
49
|
+
claims Say what you're working on before you collide (advisory)
|
|
48
50
|
errors Grouped error fingerprints + a promote/revert verdict (release-baselined)
|
|
49
51
|
jobs Submit and inspect platform-managed jobs
|
|
50
52
|
functions Manage serverless functions (deploy, invoke, logs, list, delete)
|
|
@@ -273,6 +275,16 @@ switch (cmd) {
|
|
|
273
275
|
await run(sub, rest);
|
|
274
276
|
break;
|
|
275
277
|
}
|
|
278
|
+
case "rooms": {
|
|
279
|
+
const { run } = await import("./lib/rooms.mjs");
|
|
280
|
+
await run(sub, rest);
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "claims": {
|
|
284
|
+
const { run } = await import("./lib/claims.mjs");
|
|
285
|
+
await run(sub, rest);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
276
288
|
case "errors": {
|
|
277
289
|
const { run } = await import("./lib/errors.mjs");
|
|
278
290
|
await run(sub, rest);
|
package/lib/buzz.mjs
CHANGED
|
@@ -22,6 +22,9 @@ The four independent states are:
|
|
|
22
22
|
Canonical workflows:
|
|
23
23
|
run402 buzz status
|
|
24
24
|
run402 buzz adopt offer --org <org_id> --identity-link <idlnk_id> [--deployment-context-file <json>]
|
|
25
|
+
--org the Run402 organization id as "run402 org whoami" returns it (a UUID)
|
|
26
|
+
--deployment-context-file JSON with exactly these five non-empty strings, and no others:
|
|
27
|
+
project_id, release_id, live_url, source_revision, verified_at
|
|
25
28
|
run402 buzz install --org <org_id> --community <buzz:community:host> --authority <hex-pubkey>
|
|
26
29
|
run402 buzz enroll --installation <buzzci_id> --identity-link <idlnk_id> --grants-file <json> --expires-at <ISO-8601>
|
|
27
30
|
|
package/lib/claims.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run402 claims` — advisory work claims for agent coordination.
|
|
3
|
+
*
|
|
4
|
+
* Gateway subsystem: add-agent-messaging (/orgs/v1/:org_id/rooms/:room_key/claims).
|
|
5
|
+
* A claim declares "I'm working on X until T" so concurrent agents discover
|
|
6
|
+
* collisions BEFORE they happen. Claims are ADVISORY: creating a conflicting
|
|
7
|
+
* claim succeeds and reports the complete conflict set — nothing, ever, is
|
|
8
|
+
* blocked by a claim (deploys included). JSON to stdout (pipe contract).
|
|
9
|
+
*/
|
|
10
|
+
import { getSdk } from "./sdk.mjs";
|
|
11
|
+
import { reportSdkError } from "./sdk-errors.mjs";
|
|
12
|
+
import {
|
|
13
|
+
normalizeArgv,
|
|
14
|
+
hasHelp,
|
|
15
|
+
assertKnownFlags,
|
|
16
|
+
assertAllowedValue,
|
|
17
|
+
parseIntegerFlag,
|
|
18
|
+
flagValue,
|
|
19
|
+
positionalArgs,
|
|
20
|
+
requirePositionalCount,
|
|
21
|
+
failUnknownSubcommand,
|
|
22
|
+
} from "./argparse.mjs";
|
|
23
|
+
import { resolveRoom, withPresenceRetry } from "./rooms-context.mjs";
|
|
24
|
+
|
|
25
|
+
export const MODES = ["exclusive", "shared"];
|
|
26
|
+
|
|
27
|
+
const ROOM_FLAGS = ["--project", "--org", "--room"];
|
|
28
|
+
|
|
29
|
+
const HELP = `run402 claims — say what you're working on before you collide
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
run402 claims create <resource> [--mode exclusive|shared] [--ttl <seconds>]
|
|
33
|
+
run402 claims list [--all]
|
|
34
|
+
run402 claims release <claim_id>
|
|
35
|
+
|
|
36
|
+
Resources (namespaced; conflicts never cross namespaces):
|
|
37
|
+
repo:<glob> Repo paths — repo:src/auth/** (glob overlap detection)
|
|
38
|
+
function:<name> A deployed function by name
|
|
39
|
+
table:<name> A database table
|
|
40
|
+
deploy The deploy itself (a soft mutex by convention)
|
|
41
|
+
<free-form> Anything else (exact-match overlap)
|
|
42
|
+
|
|
43
|
+
Advisory, always:
|
|
44
|
+
Creating a conflicting claim SUCCEEDS — the response carries the complete
|
|
45
|
+
conflicts[] (holder, resource, mode, expiry) and the deploy-path responses
|
|
46
|
+
surface other agents' claims automatically. A claim never blocks anything;
|
|
47
|
+
it makes collisions visible before they happen. Claims auto-expire (default
|
|
48
|
+
1h, max 24h) so a dead session cannot wedge the room; <=32 active per
|
|
49
|
+
presence.
|
|
50
|
+
|
|
51
|
+
Room addressing:
|
|
52
|
+
Default room of the active project with no flags; --project <id> for another
|
|
53
|
+
project; --org <org_id> --room <key> (or RUN402_ROOM=<org_id>/<key>) for a
|
|
54
|
+
named org room.
|
|
55
|
+
|
|
56
|
+
Options:
|
|
57
|
+
--mode <m> exclusive (default) — one worker; shared — parallel-safe.
|
|
58
|
+
--ttl <seconds> Claim lifetime (default 3600, max 86400).
|
|
59
|
+
--note <text> Why you're claiming it (shown to everyone).
|
|
60
|
+
--all list: include released/expired history.
|
|
61
|
+
|
|
62
|
+
Tip: claim before you edit, release when you hand off — and put the handoff
|
|
63
|
+
in \`run402 rooms send\` so the room's timeline tells the story.
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
run402 claims create repo:src/auth/** --note "migrating to passkeys"
|
|
67
|
+
run402 claims create deploy --ttl 900
|
|
68
|
+
run402 claims list
|
|
69
|
+
run402 claims release clm_1a
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
async function create(args) {
|
|
73
|
+
const a = normalizeArgv(args);
|
|
74
|
+
const valueFlags = [...ROOM_FLAGS, "--mode", "--ttl", "--note"];
|
|
75
|
+
assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
|
|
76
|
+
const positionals = positionalArgs(a, valueFlags);
|
|
77
|
+
requirePositionalCount(positionals, valueFlags, {
|
|
78
|
+
min: 1, max: 1, command: "run402 claims create <resource>", missing: "<resource>",
|
|
79
|
+
});
|
|
80
|
+
const mode = flagValue(a, "--mode") ?? "exclusive";
|
|
81
|
+
assertAllowedValue(mode, MODES, "--mode");
|
|
82
|
+
const ttl = flagValue(a, "--ttl");
|
|
83
|
+
const room = await resolveRoom({
|
|
84
|
+
org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const created = await withPresenceRetry(room.orgId, room.roomKey, (presenceId) =>
|
|
88
|
+
getSdk().rooms.createClaim(room.orgId, room.roomKey, {
|
|
89
|
+
resource: positionals[0],
|
|
90
|
+
mode,
|
|
91
|
+
ttlSeconds: ttl != null ? parseIntegerFlag("--ttl", ttl, { min: 1, max: 86400 }) : undefined,
|
|
92
|
+
note: flagValue(a, "--note") ?? undefined,
|
|
93
|
+
presenceId: presenceId ?? undefined,
|
|
94
|
+
}));
|
|
95
|
+
console.log(JSON.stringify(created, null, 2));
|
|
96
|
+
const conflicts = Array.isArray(created.conflicts) ? created.conflicts : [];
|
|
97
|
+
if (conflicts.length > 0) {
|
|
98
|
+
console.error(`Granted with ${conflicts.length} conflict(s) — advisory, nothing is blocked. Coordinate via run402 rooms send.`);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
reportSdkError(err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function list(args) {
|
|
106
|
+
const a = normalizeArgv(args);
|
|
107
|
+
assertKnownFlags(a, [...ROOM_FLAGS, "--all", "--help", "-h"], ROOM_FLAGS);
|
|
108
|
+
requirePositionalCount(positionalArgs(a, ROOM_FLAGS), ROOM_FLAGS, {
|
|
109
|
+
min: 0, max: 0, command: "run402 claims list", missing: "",
|
|
110
|
+
});
|
|
111
|
+
const room = await resolveRoom({
|
|
112
|
+
org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const page = await getSdk().rooms.listClaims(room.orgId, room.roomKey, {
|
|
116
|
+
includeInactive: a.includes("--all"),
|
|
117
|
+
});
|
|
118
|
+
console.log(JSON.stringify(page, null, 2));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
reportSdkError(err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function release(args) {
|
|
125
|
+
const a = normalizeArgv(args);
|
|
126
|
+
assertKnownFlags(a, [...ROOM_FLAGS, "--help", "-h"], ROOM_FLAGS);
|
|
127
|
+
const positionals = positionalArgs(a, ROOM_FLAGS);
|
|
128
|
+
requirePositionalCount(positionals, ROOM_FLAGS, {
|
|
129
|
+
min: 1, max: 1, command: "run402 claims release <claim_id>", missing: "<claim_id>",
|
|
130
|
+
});
|
|
131
|
+
const room = await resolveRoom({
|
|
132
|
+
org: flagValue(a, "--org"), room: flagValue(a, "--room"), project: flagValue(a, "--project"),
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
console.log(JSON.stringify(await getSdk().rooms.releaseClaim(room.orgId, room.roomKey, positionals[0]), null, 2));
|
|
136
|
+
} catch (err) {
|
|
137
|
+
reportSdkError(err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function run(sub, args) {
|
|
142
|
+
const argv = Array.isArray(args) ? args : [];
|
|
143
|
+
if (!sub || hasHelp([sub, ...argv])) {
|
|
144
|
+
console.log(HELP);
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
switch (sub) {
|
|
148
|
+
case "create": {
|
|
149
|
+
await create(argv);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "list": {
|
|
153
|
+
await list(argv);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "release": {
|
|
157
|
+
await release(argv);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
failUnknownSubcommand("claims", sub, {
|
|
162
|
+
hint: "Run `run402 claims --help` for usage.",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
package/lib/command-manifest.mjs
CHANGED
|
@@ -200,6 +200,14 @@ export const COMMAND_MANIFEST = [
|
|
|
200
200
|
|
|
201
201
|
// ── events / errors (flat, merged runners) ───────────────────────────────
|
|
202
202
|
{ path: ["events"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
|
|
203
|
+
{ path: ["rooms", "who"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
204
|
+
{ path: ["rooms", "send"], positionals: [p("body")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["hello"], runStyle: "sub" },
|
|
205
|
+
{ path: ["rooms", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
206
|
+
{ path: ["rooms", "get"], positionals: [p("message_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["msg_1"], runStyle: "sub" },
|
|
207
|
+
{ path: ["rooms", "ack"], positionals: [p("message_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["msg_1"], runStyle: "sub" },
|
|
208
|
+
{ path: ["claims", "create"], positionals: [p("resource")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["deploy"], runStyle: "sub" },
|
|
209
|
+
{ path: ["claims", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
|
|
210
|
+
{ path: ["claims", "release"], positionals: [p("claim_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["clm_1"], runStyle: "sub" },
|
|
203
211
|
{ path: ["errors"], positionals: [p("fingerprint_id", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
|
|
204
212
|
|
|
205
213
|
// ── jobs ─────────────────────────────────────────────────────────────────
|
package/lib/credentials.mjs
CHANGED
|
@@ -36,6 +36,7 @@ Subcommands:
|
|
|
36
36
|
status --project <id> Show one cached entry, redacted
|
|
37
37
|
import --project <id> --service-key-stdin
|
|
38
38
|
import --project <id> --service-key-env <env>
|
|
39
|
+
import --project <id> --anon-key-env <env> Rotate only the anon key
|
|
39
40
|
export --project <id> --reveal Print cached keys, including secrets
|
|
40
41
|
remove --project <id> Remove one cached key entry
|
|
41
42
|
|
|
@@ -44,6 +45,9 @@ Notes:
|
|
|
44
45
|
- list/status never reveal full keys.
|
|
45
46
|
- export requires --reveal.
|
|
46
47
|
- import accepts service keys through stdin or an environment variable, not argv.
|
|
48
|
+
- import writes the whole entry, so the FIRST import must supply a service key.
|
|
49
|
+
Afterwards --anon-key-env alone rotates the anon key and keeps the cached
|
|
50
|
+
service key, so an anon rotation never puts a service key through a shell.
|
|
47
51
|
`;
|
|
48
52
|
|
|
49
53
|
function parseProjectKeyFlags(args, extraKnown = [], valueFlagsExtra = []) {
|
|
@@ -143,7 +147,7 @@ async function status(args) {
|
|
|
143
147
|
console.log(JSON.stringify(redactedEntry(id, getProject(id)), null, 2));
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
function readSecretInput(parsed) {
|
|
150
|
+
function readSecretInput(parsed, { projectId, anonEnv, existing } = {}) {
|
|
147
151
|
const fromEnv = flagValue(parsed, "--service-key-env");
|
|
148
152
|
const fromStdin = parsed.includes("--service-key-stdin");
|
|
149
153
|
if (fromEnv && fromStdin) {
|
|
@@ -161,6 +165,23 @@ function readSecretInput(parsed) {
|
|
|
161
165
|
return value.trim();
|
|
162
166
|
}
|
|
163
167
|
if (fromStdin) return readFileSync(0, "utf-8").trim();
|
|
168
|
+
|
|
169
|
+
// Anon-only rotation: the caller passed --anon-key-env and the entry already
|
|
170
|
+
// caches a service key. Reuse it rather than making them round-trip a service
|
|
171
|
+
// key through --reveal and a shell just to change the anon key.
|
|
172
|
+
if (anonEnv && existing?.service_key) return existing.service_key;
|
|
173
|
+
|
|
174
|
+
// Still no service key. Report the flags the caller actually passed — an error
|
|
175
|
+
// that names only the service-key flags reads as "--anon-key-env is not a flag".
|
|
176
|
+
if (anonEnv) {
|
|
177
|
+
fail({
|
|
178
|
+
code: "BAD_USAGE",
|
|
179
|
+
message: `Importing an anon key also requires a service key, because import writes the whole cache entry and no service key is cached for ${projectId} yet.`,
|
|
180
|
+
hint: "Add --service-key-stdin or --service-key-env <env> to this first import. Once an entry exists, --anon-key-env alone rotates the anon key and keeps the cached service key.",
|
|
181
|
+
details: { project_id: projectId, anon_key_env: anonEnv },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
fail({
|
|
165
186
|
code: "BAD_USAGE",
|
|
166
187
|
message: "Import requires --service-key-stdin or --service-key-env <env>.",
|
|
@@ -178,16 +199,19 @@ async function importKey(args) {
|
|
|
178
199
|
fail({ code: "BAD_USAGE", message: `Unexpected argument for project-keys import: ${rest[0]}` });
|
|
179
200
|
}
|
|
180
201
|
const id = requireProjectFlag(projectId, "run402 credentials project-keys import --project <id> --service-key-stdin");
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
202
|
+
// Resolve --anon-key-env before requiring a service key, so a missing service
|
|
203
|
+
// key can report against the flags actually passed and an anon-only rotation
|
|
204
|
+
// can reuse the cached service key.
|
|
185
205
|
const anonEnv = flagValue(parsed, "--anon-key-env");
|
|
186
206
|
const anonKey = anonEnv ? process.env[anonEnv] : undefined;
|
|
187
207
|
if (anonEnv && !anonKey) {
|
|
188
208
|
fail({ code: "BAD_ENV", message: `Environment variable ${anonEnv} is empty or unset.`, details: { env: anonEnv } });
|
|
189
209
|
}
|
|
190
210
|
const existing = getProject(id);
|
|
211
|
+
const serviceKey = readSecretInput(parsed, { projectId: id, anonEnv, existing });
|
|
212
|
+
if (!serviceKey) {
|
|
213
|
+
fail({ code: "BAD_USAGE", message: "Service key input was empty." });
|
|
214
|
+
}
|
|
191
215
|
saveProject(id, {
|
|
192
216
|
anon_key: anonKey ?? existing?.anon_key ?? "",
|
|
193
217
|
service_key: serviceKey,
|
package/lib/pay.mjs
CHANGED
|
@@ -16,11 +16,13 @@ Usage:
|
|
|
16
16
|
|
|
17
17
|
Options:
|
|
18
18
|
--method <M> HTTP method (default: GET)
|
|
19
|
-
--body <json-or-text> Request body
|
|
19
|
+
--body <json-or-text> Request body — the ONLY way to send a payload
|
|
20
|
+
(not valid with GET/HEAD)
|
|
20
21
|
--max-usd <amount> Maximum payment in USD (default: 0.10)
|
|
21
22
|
--idempotency-key <key> Forward a stable Idempotency-Key to the seller
|
|
22
23
|
--require-receipt Require verified merchant evidence
|
|
23
|
-
--json
|
|
24
|
+
--json No-op; pay always prints JSON. Takes no value —
|
|
25
|
+
to send a payload use --body
|
|
24
26
|
--help, -h Show this help
|
|
25
27
|
|
|
26
28
|
Examples:
|
|
@@ -50,10 +52,11 @@ export async function run(args = [], deps = {}) {
|
|
|
50
52
|
);
|
|
51
53
|
const positionals = positionalArgs(parsed, VALUE_FLAGS);
|
|
52
54
|
if (positionals.length !== 1) {
|
|
55
|
+
const stray = positionals[1];
|
|
53
56
|
fail({
|
|
54
57
|
code: "BAD_USAGE",
|
|
55
|
-
message: positionals.length === 0 ? "URL required." : `Unexpected argument: ${
|
|
56
|
-
hint:
|
|
58
|
+
message: positionals.length === 0 ? "URL required." : `Unexpected argument: ${stray}`,
|
|
59
|
+
hint: strayArgumentHint(parsed, stray),
|
|
57
60
|
});
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -113,6 +116,34 @@ export function parseUsdMicros(value) {
|
|
|
113
116
|
return micros;
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
const USAGE_HINT = "run402 pay <url> [--method POST] [--body <value>] [--max-usd 0.10]";
|
|
120
|
+
|
|
121
|
+
// `--json` selects output format and takes no value; `--body` sends the payload.
|
|
122
|
+
// Reaching for `--json '<payload>'` is the predictable confusion, and it lands
|
|
123
|
+
// here as a stray positional. Name the flag the caller actually wanted.
|
|
124
|
+
export function strayArgumentHint(parsed, stray) {
|
|
125
|
+
if (typeof stray !== "string") return USAGE_HINT;
|
|
126
|
+
const jsonIndex = parsed.indexOf("--json");
|
|
127
|
+
if (jsonIndex !== -1 && parsed[jsonIndex + 1] === stray) {
|
|
128
|
+
return `--json only selects JSON output and takes no value; the request body flag is --body. Retry with: --method POST --body '${stray}'`;
|
|
129
|
+
}
|
|
130
|
+
if (looksLikeJson(stray)) {
|
|
131
|
+
return `To send this payload, pass it as a body: --method POST --body '${stray}'`;
|
|
132
|
+
}
|
|
133
|
+
return USAGE_HINT;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function looksLikeJson(value) {
|
|
137
|
+
const trimmed = value.trim();
|
|
138
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
|
|
139
|
+
try {
|
|
140
|
+
JSON.parse(trimmed);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
116
147
|
function validateUrl(value) {
|
|
117
148
|
try {
|
|
118
149
|
const parsed = new URL(value);
|
package/lib/pay.test.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
|
|
4
|
-
import { parseUsdMicros, run } from "./pay.mjs";
|
|
4
|
+
import { parseUsdMicros, run, strayArgumentHint } from "./pay.mjs";
|
|
5
5
|
|
|
6
6
|
describe("run402 pay", () => {
|
|
7
7
|
it("converts decimal USD to micros without floating-point rounding", () => {
|
|
@@ -10,6 +10,28 @@ describe("run402 pay", () => {
|
|
|
10
10
|
assert.equal(parseUsdMicros("0"), 0);
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
// `--json` selects output format, `--body` sends the payload. An agent
|
|
14
|
+
// reaching for `--json '<payload>'` gets a stray positional; the hint has to
|
|
15
|
+
// name --body or the usage error reads as "the payload was accepted".
|
|
16
|
+
it("points --json '<payload>' at --body", () => {
|
|
17
|
+
const payload = '{"kind":"anon"}';
|
|
18
|
+
const hint = strayArgumentHint(["https://s.example/x", "--json", payload], payload);
|
|
19
|
+
assert.match(hint, /--json only selects JSON output and takes no value/);
|
|
20
|
+
assert.match(hint, /--body '\{"kind":"anon"\}'/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("points a bare JSON positional at --body even without --json", () => {
|
|
24
|
+
const payload = '{"kind":"anon"}';
|
|
25
|
+
const hint = strayArgumentHint(["https://s.example/x", payload], payload);
|
|
26
|
+
assert.match(hint, /pass it as a body: --method POST --body/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("falls back to plain usage for a stray non-JSON argument", () => {
|
|
30
|
+
const hint = strayArgumentHint(["https://s.example/x", "stray"], "stray");
|
|
31
|
+
assert.equal(hint, "run402 pay <url> [--method POST] [--body <value>] [--max-usd 0.10]");
|
|
32
|
+
assert.equal(strayArgumentHint(["https://s.example/x"], undefined), hint);
|
|
33
|
+
});
|
|
34
|
+
|
|
13
35
|
it("delegates to SDK pay.fetch and prints the receipt", async () => {
|
|
14
36
|
let captured;
|
|
15
37
|
const output = [];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared room resolution + per-checkout session state for the agent-messaging
|
|
3
|
+
* commands (`run402 messages`, `run402 claims`).
|
|
4
|
+
*
|
|
5
|
+
* Room addressing (precedence):
|
|
6
|
+
* --org <org_id> + --room <key> explicit (named org rooms)
|
|
7
|
+
* RUN402_ROOM=<org_id>/<key> env form of the same
|
|
8
|
+
* (default) the project's DEFAULT room — the room key
|
|
9
|
+
* IS the project id; org resolved via the
|
|
10
|
+
* project overview (`rooms.forProject`).
|
|
11
|
+
*
|
|
12
|
+
* Session state lives at ./.run402/messaging.json (per-checkout — the house
|
|
13
|
+
* discipline of one worktree per agent session makes per-checkout equal
|
|
14
|
+
* per-session). It caches, per room: the session's presence_id + name and the
|
|
15
|
+
* read cursor. Add `.run402/` to .gitignore. Two sessions sharing one checkout
|
|
16
|
+
* share a presence (coherent, just undifferentiated); RUN402_PRESENCE_ID
|
|
17
|
+
* overrides for harnesses that multiplex.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { fail } from "./sdk-errors.mjs";
|
|
22
|
+
import { resolveProjectId } from "./config.mjs";
|
|
23
|
+
import { getSdk } from "./sdk.mjs";
|
|
24
|
+
|
|
25
|
+
export const ROOM_ENV = "RUN402_ROOM";
|
|
26
|
+
export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
|
|
27
|
+
|
|
28
|
+
const STATE_DIR = ".run402";
|
|
29
|
+
const STATE_FILE = "messaging.json";
|
|
30
|
+
|
|
31
|
+
/** Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most). */
|
|
32
|
+
export async function resolveRoom({ org, room, project } = {}) {
|
|
33
|
+
if (room && !org) {
|
|
34
|
+
fail({
|
|
35
|
+
code: "BAD_USAGE",
|
|
36
|
+
message: "--room names an org room and needs --org <org_id> beside it.",
|
|
37
|
+
hint: "For the project's default room, omit both (or pass --project). For a named room: --org <org_id> --room <key>.",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (org && room) return { orgId: org, roomKey: room };
|
|
41
|
+
const envRoom = (process.env[ROOM_ENV] ?? "").trim();
|
|
42
|
+
if (envRoom) {
|
|
43
|
+
const slash = envRoom.indexOf("/");
|
|
44
|
+
if (slash <= 0 || slash === envRoom.length - 1) {
|
|
45
|
+
fail({
|
|
46
|
+
code: "BAD_USAGE",
|
|
47
|
+
message: `${ROOM_ENV} must be "<org_id>/<room_key>".`,
|
|
48
|
+
details: { value: envRoom },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return { orgId: envRoom.slice(0, slash), roomKey: envRoom.slice(slash + 1) };
|
|
52
|
+
}
|
|
53
|
+
const projectId = resolveProjectId(project);
|
|
54
|
+
const scoped = await getSdk().rooms.forProject(projectId);
|
|
55
|
+
return { orgId: scoped.orgId, roomKey: scoped.roomKey };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function statePath() {
|
|
59
|
+
return join(process.cwd(), STATE_DIR, STATE_FILE);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadState() {
|
|
63
|
+
try {
|
|
64
|
+
const raw = readFileSync(statePath(), "utf8");
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
return parsed && typeof parsed === "object" && parsed.rooms && typeof parsed.rooms === "object"
|
|
67
|
+
? parsed
|
|
68
|
+
: { rooms: {} };
|
|
69
|
+
} catch {
|
|
70
|
+
return { rooms: {} };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveState(state) {
|
|
75
|
+
try {
|
|
76
|
+
const dir = join(process.cwd(), STATE_DIR);
|
|
77
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
78
|
+
writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
|
|
79
|
+
} catch {
|
|
80
|
+
// Best-effort cache: an unwritable checkout degrades to stateless calls.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const roomStateKey = (orgId, roomKey) => `${orgId}/${roomKey}`;
|
|
85
|
+
|
|
86
|
+
export function getRoomState(orgId, roomKey) {
|
|
87
|
+
return loadState().rooms[roomStateKey(orgId, roomKey)] ?? {};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function updateRoomState(orgId, roomKey, patch) {
|
|
91
|
+
const state = loadState();
|
|
92
|
+
const key = roomStateKey(orgId, roomKey);
|
|
93
|
+
state.rooms[key] = { ...(state.rooms[key] ?? {}), ...patch };
|
|
94
|
+
saveState(state);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The session's presence id for a room: env override, else the checkout cache. */
|
|
98
|
+
export function cachedPresenceId(orgId, roomKey) {
|
|
99
|
+
const env = (process.env[PRESENCE_ENV] ?? "").trim();
|
|
100
|
+
if (env) return env;
|
|
101
|
+
const cached = getRoomState(orgId, roomKey).presence_id;
|
|
102
|
+
return typeof cached === "string" && cached ? cached : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run a room call that carries the cached presence. On PRESENCE_EXPIRED (410 —
|
|
107
|
+
* this session's presence aged out), drop the cache and retry ONCE without a
|
|
108
|
+
* presence_id so the gateway's resolve-or-create issues a fresh one.
|
|
109
|
+
*/
|
|
110
|
+
export async function withPresenceRetry(orgId, roomKey, call) {
|
|
111
|
+
const presenceId = cachedPresenceId(orgId, roomKey);
|
|
112
|
+
try {
|
|
113
|
+
return await call(presenceId);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const code = err?.body?.code ?? err?.code;
|
|
116
|
+
if (code === "PRESENCE_EXPIRED" && presenceId) {
|
|
117
|
+
updateRoomState(orgId, roomKey, { presence_id: null, name: null });
|
|
118
|
+
return call(null);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Cache the presence a response attributed this session to. */
|
|
125
|
+
export function rememberPresence(orgId, roomKey, presence) {
|
|
126
|
+
if (!presence || typeof presence !== "object") return;
|
|
127
|
+
const id = presence.presence_id;
|
|
128
|
+
const name = presence.name;
|
|
129
|
+
if (typeof id === "string" && id) {
|
|
130
|
+
updateRoomState(orgId, roomKey, { presence_id: id, ...(typeof name === "string" ? { name } : {}) });
|
|
131
|
+
}
|
|
132
|
+
}
|