@possumtech/rummy 0.5.0 → 2.0.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/.env.example +42 -5
- package/PLUGINS.md +389 -194
- package/README.md +25 -8
- package/SPEC.md +934 -373
- package/bin/demo.js +166 -0
- package/bin/rummy.js +9 -3
- package/biome/no-fallbacks.grit +50 -0
- package/lang/en.json +2 -2
- package/migrations/001_initial_schema.sql +88 -37
- package/package.json +13 -11
- package/scriptify/ask_run.js +77 -0
- package/service.js +50 -9
- package/src/agent/AgentLoop.js +476 -335
- package/src/agent/ContextAssembler.js +4 -4
- package/src/agent/Entries.js +676 -0
- package/src/agent/ProjectAgent.js +30 -18
- package/src/agent/TurnExecutor.js +232 -421
- package/src/agent/XmlParser.js +99 -33
- package/src/agent/budget.js +56 -0
- package/src/agent/errors.js +22 -0
- package/src/agent/httpStatus.js +39 -0
- package/src/agent/known_checks.sql +8 -4
- package/src/agent/known_queries.sql +9 -13
- package/src/agent/known_store.sql +280 -125
- package/src/agent/materializeContext.js +104 -0
- package/src/agent/runs.sql +29 -7
- package/src/agent/schemes.sql +14 -3
- package/src/agent/tokens.js +6 -0
- package/src/agent/turns.sql +9 -9
- package/src/hooks/HookRegistry.js +6 -5
- package/src/hooks/Hooks.js +44 -3
- package/src/hooks/PluginContext.js +29 -21
- package/src/{server → hooks}/RpcRegistry.js +2 -1
- package/src/hooks/RummyContext.js +139 -35
- package/src/hooks/ToolRegistry.js +21 -16
- package/src/llm/LlmProvider.js +66 -89
- package/src/llm/errors.js +21 -0
- package/src/llm/retry.js +63 -0
- package/src/plugins/ask_user/README.md +1 -1
- package/src/plugins/ask_user/ask_user.js +37 -12
- package/src/plugins/ask_user/ask_userDoc.js +2 -25
- package/src/plugins/ask_user/ask_userDoc.md +10 -0
- package/src/plugins/budget/README.md +27 -25
- package/src/plugins/budget/budget.js +306 -88
- package/src/plugins/cp/README.md +2 -2
- package/src/plugins/cp/cp.js +29 -11
- package/src/plugins/cp/cpDoc.js +2 -15
- package/src/plugins/cp/cpDoc.md +7 -0
- package/src/plugins/engine/README.md +2 -2
- package/src/plugins/engine/engine.sql +4 -4
- package/src/plugins/engine/turn_context.sql +10 -10
- package/src/plugins/env/README.md +20 -5
- package/src/plugins/env/env.js +45 -6
- package/src/plugins/env/envDoc.js +2 -23
- package/src/plugins/env/envDoc.md +13 -0
- package/src/plugins/error/README.md +16 -0
- package/src/plugins/error/error.js +151 -0
- package/src/plugins/file/README.md +6 -6
- package/src/plugins/file/file.js +15 -2
- package/src/plugins/get/README.md +1 -1
- package/src/plugins/get/get.js +103 -48
- package/src/plugins/get/getDoc.js +2 -32
- package/src/plugins/get/getDoc.md +36 -0
- package/src/plugins/hedberg/README.md +1 -2
- package/src/plugins/hedberg/hedberg.js +8 -4
- package/src/plugins/hedberg/matcher.js +16 -17
- package/src/plugins/hedberg/normalize.js +0 -48
- package/src/plugins/helpers.js +42 -2
- package/src/plugins/index.js +146 -123
- package/src/plugins/instructions/README.md +35 -9
- package/src/plugins/instructions/instructions.js +244 -9
- package/src/plugins/instructions/instructions.md +33 -0
- package/src/plugins/instructions/instructions_104.md +7 -0
- package/src/plugins/instructions/instructions_105.md +38 -0
- package/src/plugins/instructions/instructions_106.md +21 -0
- package/src/plugins/instructions/instructions_107.md +10 -0
- package/src/plugins/instructions/instructions_108.md +0 -0
- package/src/plugins/instructions/protocol.js +12 -0
- package/src/plugins/known/README.md +2 -2
- package/src/plugins/known/known.js +68 -36
- package/src/plugins/known/knownDoc.js +2 -17
- package/src/plugins/known/knownDoc.md +8 -0
- package/src/plugins/log/README.md +48 -0
- package/src/plugins/log/log.js +129 -0
- package/src/plugins/mv/README.md +2 -2
- package/src/plugins/mv/mv.js +55 -22
- package/src/plugins/mv/mvDoc.js +2 -18
- package/src/plugins/mv/mvDoc.md +10 -0
- package/src/plugins/ollama/README.md +15 -0
- package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
- package/src/plugins/openai/README.md +17 -0
- package/src/plugins/openai/openai.js +120 -0
- package/src/plugins/openrouter/README.md +27 -0
- package/src/plugins/openrouter/openrouter.js +121 -0
- package/src/plugins/persona/README.md +20 -0
- package/src/plugins/persona/persona.js +9 -16
- package/src/plugins/policy/README.md +21 -0
- package/src/plugins/policy/policy.js +29 -14
- package/src/plugins/prompt/README.md +1 -1
- package/src/plugins/prompt/prompt.js +64 -16
- package/src/plugins/rm/README.md +1 -1
- package/src/plugins/rm/rm.js +56 -12
- package/src/plugins/rm/rmDoc.js +2 -20
- package/src/plugins/rm/rmDoc.md +13 -0
- package/src/plugins/rpc/README.md +2 -2
- package/src/plugins/rpc/rpc.js +525 -296
- package/src/plugins/set/README.md +1 -1
- package/src/plugins/set/set.js +318 -75
- package/src/plugins/set/setDoc.js +2 -35
- package/src/plugins/set/setDoc.md +22 -0
- package/src/plugins/sh/README.md +28 -5
- package/src/plugins/sh/sh.js +50 -6
- package/src/plugins/sh/shDoc.js +2 -23
- package/src/plugins/sh/shDoc.md +13 -0
- package/src/plugins/skill/README.md +23 -0
- package/src/plugins/skill/skill.js +14 -18
- package/src/plugins/stream/README.md +101 -0
- package/src/plugins/stream/stream.js +290 -0
- package/src/plugins/telemetry/README.md +1 -1
- package/src/plugins/telemetry/telemetry.js +129 -80
- package/src/plugins/think/README.md +1 -1
- package/src/plugins/think/think.js +12 -0
- package/src/plugins/think/thinkDoc.js +2 -15
- package/src/plugins/think/thinkDoc.md +7 -0
- package/src/plugins/unknown/README.md +3 -3
- package/src/plugins/unknown/unknown.js +47 -19
- package/src/plugins/unknown/unknownDoc.js +2 -21
- package/src/plugins/unknown/unknownDoc.md +11 -0
- package/src/plugins/update/README.md +1 -1
- package/src/plugins/update/update.js +83 -5
- package/src/plugins/update/updateDoc.js +2 -30
- package/src/plugins/update/updateDoc.md +8 -0
- package/src/plugins/xai/README.md +23 -0
- package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
- package/src/plugins/yolo/yolo.js +192 -0
- package/src/server/ClientConnection.js +64 -37
- package/src/server/SocketServer.js +23 -10
- package/src/server/protocol.js +11 -0
- package/src/sql/v_model_context.sql +27 -31
- package/src/sql/v_run_log.sql +9 -14
- package/EXCEPTIONS.md +0 -46
- package/FIDELITY_CONTRACT.md +0 -172
- package/src/agent/KnownStore.js +0 -337
- package/src/agent/ResponseHealer.js +0 -241
- package/src/llm/OpenAiClient.js +0 -100
- package/src/llm/OpenRouterClient.js +0 -100
- package/src/plugins/budget/recovery.js +0 -47
- package/src/plugins/instructions/preamble.md +0 -45
- package/src/plugins/performed/README.md +0 -15
- package/src/plugins/performed/performed.js +0 -45
- package/src/plugins/previous/README.md +0 -16
- package/src/plugins/previous/previous.js +0 -56
- package/src/plugins/progress/README.md +0 -16
- package/src/plugins/progress/progress.js +0 -43
- package/src/plugins/summarize/README.md +0 -19
- package/src/plugins/summarize/summarize.js +0 -32
- package/src/plugins/summarize/summarizeDoc.js +0 -27
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## <update status="N">{brief status}</update> - Status report (exactly one per turn, at the end)
|
|
2
|
+
<!-- Header defines position, frequency, and status code requirement. -->
|
|
3
|
+
|
|
4
|
+
REQUIRED: the valid values of N are defined by your current stage instructions.
|
|
5
|
+
<!-- Single source of truth for codes is the current phase instructions block, not this doc. Listing codes here leaks termination knowledge (e.g. 200) that strong models use to short-circuit the protocol. -->
|
|
6
|
+
|
|
7
|
+
REQUIRED: YOU MUST keep <update></update> body to <= 80 characters.
|
|
8
|
+
<!-- Length cap. -->
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# xai
|
|
2
|
+
|
|
3
|
+
xAI (Grok) LLM provider. Handles model aliases prefixed with `xai/`
|
|
4
|
+
(e.g. `xai/grok-2`).
|
|
5
|
+
|
|
6
|
+
## Env
|
|
7
|
+
|
|
8
|
+
- `XAI_BASE_URL` — full responses endpoint (e.g.
|
|
9
|
+
`https://api.x.ai/v1/responses`). Plugin is inert if unset.
|
|
10
|
+
- `XAI_API_KEY` — bearer token.
|
|
11
|
+
|
|
12
|
+
## Response Normalization
|
|
13
|
+
|
|
14
|
+
xAI's response shape differs from OpenAI's. The plugin walks
|
|
15
|
+
`data.output[]`, collecting text from items of type `message` as
|
|
16
|
+
`content` and items of type `reasoning` as `reasoning_content`, then
|
|
17
|
+
emits the common OpenAI-shaped envelope.
|
|
18
|
+
|
|
19
|
+
## Context Size
|
|
20
|
+
|
|
21
|
+
Tries `/models` first for a `context_length` field, then the
|
|
22
|
+
`/language-models/<id>` endpoint as a fallback. Results are cached
|
|
23
|
+
per model for the plugin lifetime.
|
|
@@ -1,24 +1,46 @@
|
|
|
1
|
-
import msg from "
|
|
1
|
+
import msg from "../../agent/messages.js";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
|
|
4
|
+
if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
|
|
5
|
+
|
|
6
|
+
const PROVIDER = "xai";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* xAI (Grok) LLM provider plugin. Registers with hooks.llm.providers if
|
|
10
|
+
* XAI_BASE_URL is set; inert otherwise. Handles model aliases of the
|
|
11
|
+
* form `xai/{modelName}`. Normalizes xAI's distinct response shape
|
|
12
|
+
* into the common OpenAI-shaped envelope.
|
|
13
|
+
*/
|
|
14
|
+
export default class Xai {
|
|
4
15
|
#baseUrl;
|
|
5
16
|
#apiKey;
|
|
6
17
|
#contextCache = new Map();
|
|
7
18
|
|
|
8
|
-
constructor(
|
|
19
|
+
constructor(core) {
|
|
20
|
+
const baseUrl = process.env.XAI_BASE_URL;
|
|
21
|
+
if (!baseUrl) return;
|
|
9
22
|
this.#baseUrl = baseUrl;
|
|
10
|
-
this.#apiKey =
|
|
23
|
+
this.#apiKey = process.env.XAI_API_KEY;
|
|
24
|
+
|
|
25
|
+
const wireModel = (alias) => alias.split("/").slice(1).join("/");
|
|
26
|
+
|
|
27
|
+
core.hooks.llm.providers.push({
|
|
28
|
+
name: PROVIDER,
|
|
29
|
+
matches: (model) => model.split("/")[0] === PROVIDER,
|
|
30
|
+
completion: (messages, model, options) =>
|
|
31
|
+
this.#completion(messages, wireModel(model), options),
|
|
32
|
+
getContextSize: (model) => this.#getContextSize(wireModel(model)),
|
|
33
|
+
});
|
|
11
34
|
}
|
|
12
35
|
|
|
13
|
-
async completion(messages, model, options = {}) {
|
|
36
|
+
async #completion(messages, model, options = {}) {
|
|
14
37
|
if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
|
|
15
38
|
|
|
16
39
|
const body = { model, input: messages };
|
|
17
40
|
if (options.temperature !== undefined)
|
|
18
41
|
body.temperature = options.temperature;
|
|
19
42
|
|
|
20
|
-
const
|
|
21
|
-
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
43
|
+
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
|
|
22
44
|
const signal = options.signal
|
|
23
45
|
? AbortSignal.any([options.signal, timeoutSignal])
|
|
24
46
|
: timeoutSignal;
|
|
@@ -37,29 +59,22 @@ export default class XaiClient {
|
|
|
37
59
|
const error = await response.text();
|
|
38
60
|
if (response.status === 401 || response.status === 403) {
|
|
39
61
|
throw new Error(
|
|
40
|
-
msg("error.xai_auth", {
|
|
41
|
-
status: `${response.status} - ${error}`,
|
|
42
|
-
}),
|
|
62
|
+
msg("error.xai_auth", { status: `${response.status} - ${error}` }),
|
|
43
63
|
);
|
|
44
64
|
}
|
|
45
65
|
throw new Error(
|
|
46
|
-
msg("error.xai_api", {
|
|
47
|
-
status: `${response.status} - ${error}`,
|
|
48
|
-
}),
|
|
66
|
+
msg("error.xai_api", { status: `${response.status} - ${error}` }),
|
|
49
67
|
);
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
return this.#normalize(data);
|
|
70
|
+
return this.#normalize(await response.json());
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
#normalize(data) {
|
|
57
|
-
const output = data.output || [];
|
|
58
|
-
|
|
59
74
|
let content = "";
|
|
60
75
|
let reasoningContent = null;
|
|
61
76
|
|
|
62
|
-
for (const item of output) {
|
|
77
|
+
for (const item of data.output) {
|
|
63
78
|
if (item.type === "reasoning") {
|
|
64
79
|
const text = this.#extractText(item.content);
|
|
65
80
|
if (text)
|
|
@@ -73,9 +88,13 @@ export default class XaiClient {
|
|
|
73
88
|
}
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
const usage = data
|
|
77
|
-
const inputTokens = usage.input_tokens
|
|
78
|
-
const outputTokens = usage.output_tokens
|
|
91
|
+
const { usage } = data;
|
|
92
|
+
const inputTokens = usage.input_tokens;
|
|
93
|
+
const outputTokens = usage.output_tokens;
|
|
94
|
+
// Optional per xAI API; absent on providers that don't surface them.
|
|
95
|
+
const cached = usage.input_tokens_details?.cached_tokens;
|
|
96
|
+
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens;
|
|
97
|
+
const costTicks = usage.cost_in_usd_ticks;
|
|
79
98
|
return {
|
|
80
99
|
choices: [
|
|
81
100
|
{
|
|
@@ -88,11 +107,11 @@ export default class XaiClient {
|
|
|
88
107
|
],
|
|
89
108
|
usage: {
|
|
90
109
|
prompt_tokens: inputTokens,
|
|
91
|
-
cached_tokens:
|
|
110
|
+
cached_tokens: cached === undefined ? 0 : cached,
|
|
92
111
|
completion_tokens: outputTokens,
|
|
93
|
-
reasoning_tokens:
|
|
112
|
+
reasoning_tokens: reasoningTokens === undefined ? 0 : reasoningTokens,
|
|
94
113
|
total_tokens: inputTokens + outputTokens,
|
|
95
|
-
cost:
|
|
114
|
+
cost: costTicks === undefined ? 0 : costTicks / 10_000_000_000,
|
|
96
115
|
},
|
|
97
116
|
};
|
|
98
117
|
}
|
|
@@ -100,29 +119,30 @@ export default class XaiClient {
|
|
|
100
119
|
#extractText(content) {
|
|
101
120
|
if (typeof content === "string") return content;
|
|
102
121
|
if (!Array.isArray(content)) return null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
122
|
+
const joined = content
|
|
123
|
+
.filter((c) => c.type === "text" || c.type === "output_text")
|
|
124
|
+
.map((c) => c.text)
|
|
125
|
+
.join("\n");
|
|
126
|
+
return joined ? joined : null;
|
|
109
127
|
}
|
|
110
128
|
|
|
111
|
-
async getContextSize(model) {
|
|
129
|
+
async #getContextSize(model) {
|
|
112
130
|
if (this.#contextCache.has(model)) return this.#contextCache.get(model);
|
|
113
|
-
|
|
114
131
|
if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
|
|
115
132
|
|
|
116
|
-
// Query xAI models endpoint
|
|
117
133
|
const modelsUrl = this.#baseUrl.replace(/\/responses$/, "/models");
|
|
118
134
|
const res = await fetch(modelsUrl, {
|
|
119
135
|
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
120
136
|
signal: AbortSignal.timeout(5000),
|
|
121
137
|
});
|
|
122
|
-
|
|
123
138
|
if (res.ok) {
|
|
124
139
|
const data = await res.json();
|
|
125
|
-
|
|
140
|
+
// xAI's /models returns either { data: [...] } or { models: [...] }
|
|
141
|
+
// depending on the API version; accept either and crash otherwise.
|
|
142
|
+
let models;
|
|
143
|
+
if (data.data) models = data.data;
|
|
144
|
+
else if (data.models) models = data.models;
|
|
145
|
+
else throw new Error("xAI /models response has neither data nor models");
|
|
126
146
|
const entry = models.find(
|
|
127
147
|
(m) => m.id === model || `${m.id}-latest` === model,
|
|
128
148
|
);
|
|
@@ -132,16 +152,17 @@ export default class XaiClient {
|
|
|
132
152
|
}
|
|
133
153
|
}
|
|
134
154
|
|
|
135
|
-
// Try /v1/language-models for richer metadata
|
|
136
155
|
const langUrl = this.#baseUrl.replace(
|
|
137
156
|
/\/responses$/,
|
|
138
157
|
`/language-models/${model}`,
|
|
139
158
|
);
|
|
159
|
+
// Optional endpoint probe. If the network call fails (404 on older
|
|
160
|
+
// API versions, timeout, etc.) we fall through to the next strategy
|
|
161
|
+
// below; a terminal throw fires if no strategy resolves.
|
|
140
162
|
const langRes = await fetch(langUrl, {
|
|
141
163
|
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
142
164
|
signal: AbortSignal.timeout(5000),
|
|
143
165
|
}).catch(() => null);
|
|
144
|
-
|
|
145
166
|
if (langRes?.ok) {
|
|
146
167
|
const langData = await langRes.json();
|
|
147
168
|
if (langData?.context_length) {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { logPathToDataBase } from "../helpers.js";
|
|
3
|
+
|
|
4
|
+
const SH_PATH_RE = /^log:\/\/turn_\d+\/(sh|env)\//;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* YOLO plugin — for runs started with `yolo: true`, auto-resolves every
|
|
8
|
+
* proposal server-side and spawns sh/env commands locally, streaming
|
|
9
|
+
* output to the same data-channel entries the existing `stream`/
|
|
10
|
+
* `stream/completed` RPC contract uses.
|
|
11
|
+
*
|
|
12
|
+
* Pattern parallel to `noRepo`/`noWeb`/`noInteraction`/`noProposals`:
|
|
13
|
+
* `yolo` is a run attribute plumbed via rpc.js → AgentLoop loop config →
|
|
14
|
+
* RummyContext.yolo. This plugin reads `rummy.yolo` off the proposal
|
|
15
|
+
* payload and engages only when set; non-yolo runs are unaffected.
|
|
16
|
+
*
|
|
17
|
+
* The plugin replicates AgentLoop.resolve()'s accept path inline rather
|
|
18
|
+
* than calling an exposed projectAgent — keeps yolo logic contained in
|
|
19
|
+
* the yolo plugin and out of backbone files.
|
|
20
|
+
*/
|
|
21
|
+
export default class Yolo {
|
|
22
|
+
constructor(core) {
|
|
23
|
+
this.core = core;
|
|
24
|
+
core.hooks.proposal.pending.on(this.#onPending.bind(this));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async #onPending({ run, proposed, rummy }) {
|
|
28
|
+
if (!rummy?.yolo) return;
|
|
29
|
+
for (const p of proposed) {
|
|
30
|
+
// Resolve first — that fires proposal.accepted, which lets the
|
|
31
|
+
// sh/env plugin seed the streaming channel entries. Then spawn
|
|
32
|
+
// into those existing channels. If we spawned first, sh.js's
|
|
33
|
+
// post-accept channel creation would clobber the body we just
|
|
34
|
+
// streamed (sets state=streaming, body="").
|
|
35
|
+
await this.#serverResolve(rummy, p.path);
|
|
36
|
+
if (SH_PATH_RE.test(p.path)) {
|
|
37
|
+
await this.#executeShellProposal(rummy, p.path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replicate AgentLoop.resolve()'s accept path: accepting filter
|
|
44
|
+
* (veto check), content filter (resolved body), set state="resolved",
|
|
45
|
+
* emit proposal.accepted for plugin side effects.
|
|
46
|
+
*/
|
|
47
|
+
async #serverResolve(rummy, path) {
|
|
48
|
+
const runId = rummy.runId;
|
|
49
|
+
const entries = rummy.entries;
|
|
50
|
+
const db = rummy.db;
|
|
51
|
+
const runRow = await db.get_run_by_id.get({ id: runId });
|
|
52
|
+
const project = await db.get_project_by_id.get({ id: runRow.project_id });
|
|
53
|
+
const attrs = await entries.getAttributes(runId, path);
|
|
54
|
+
const ctx = {
|
|
55
|
+
runId,
|
|
56
|
+
runRow,
|
|
57
|
+
projectId: runRow.project_id,
|
|
58
|
+
projectRoot: project?.project_root,
|
|
59
|
+
path,
|
|
60
|
+
attrs,
|
|
61
|
+
output: "",
|
|
62
|
+
db,
|
|
63
|
+
entries,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const veto = await this.core.hooks.proposal.accepting.filter(null, ctx);
|
|
67
|
+
if (veto?.allow === false) {
|
|
68
|
+
await entries.set({
|
|
69
|
+
runId,
|
|
70
|
+
path,
|
|
71
|
+
state: "failed",
|
|
72
|
+
outcome: veto.outcome,
|
|
73
|
+
body: veto.body,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const resolvedBody = await this.core.hooks.proposal.content.filter("", ctx);
|
|
79
|
+
const existing = await entries.getState(runId, path);
|
|
80
|
+
const existingTurn = existing?.turn === undefined ? 0 : existing.turn;
|
|
81
|
+
await entries.set({
|
|
82
|
+
runId,
|
|
83
|
+
turn: existingTurn,
|
|
84
|
+
path,
|
|
85
|
+
state: "resolved",
|
|
86
|
+
body: resolvedBody,
|
|
87
|
+
});
|
|
88
|
+
await this.core.hooks.proposal.accepted.emit({ ...ctx, resolvedBody });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Spawn the sh/env command locally and stream stdout/stderr into
|
|
93
|
+
* `{dataBase}_1` and `{dataBase}_2` data entries. Mirrors the
|
|
94
|
+
* stream/stream-completed RPC contract — same channel layout, same
|
|
95
|
+
* terminal-state transitions on exit. Done inline (no RPC roundtrip)
|
|
96
|
+
* so the run is fully autonomous.
|
|
97
|
+
*/
|
|
98
|
+
async #executeShellProposal(rummy, logPath) {
|
|
99
|
+
const runId = rummy.runId;
|
|
100
|
+
const entries = rummy.entries;
|
|
101
|
+
const db = rummy.db;
|
|
102
|
+
const runRow = await db.get_run_by_id.get({ id: runId });
|
|
103
|
+
const project = await db.get_project_by_id.get({ id: runRow.project_id });
|
|
104
|
+
const projectRoot = project?.project_root;
|
|
105
|
+
if (!projectRoot) return;
|
|
106
|
+
|
|
107
|
+
const attrs = await entries.getAttributes(runId, logPath);
|
|
108
|
+
const command = attrs?.command || attrs?.summary;
|
|
109
|
+
if (!command) return;
|
|
110
|
+
|
|
111
|
+
const dataBase = logPathToDataBase(logPath);
|
|
112
|
+
if (!dataBase) return;
|
|
113
|
+
const stdoutPath = `${dataBase}_1`;
|
|
114
|
+
const stderrPath = `${dataBase}_2`;
|
|
115
|
+
|
|
116
|
+
const start = Date.now();
|
|
117
|
+
const child = spawn("bash", ["-lc", command], {
|
|
118
|
+
cwd: projectRoot,
|
|
119
|
+
env: process.env,
|
|
120
|
+
});
|
|
121
|
+
// Buffer chunks synchronously and write once after exit. Avoids
|
|
122
|
+
// the race where multiple async appends interleave with the
|
|
123
|
+
// terminal-state transition fired on 'close'.
|
|
124
|
+
const stdoutChunks = [];
|
|
125
|
+
const stderrChunks = [];
|
|
126
|
+
child.stdout.on("data", (data) => stdoutChunks.push(data.toString()));
|
|
127
|
+
child.stderr.on("data", (data) => stderrChunks.push(data.toString()));
|
|
128
|
+
|
|
129
|
+
await new Promise((resolve) => {
|
|
130
|
+
child.on("close", async (code) => {
|
|
131
|
+
const stdoutBody = stdoutChunks.join("");
|
|
132
|
+
const stderrBody = stderrChunks.join("");
|
|
133
|
+
if (stdoutBody) {
|
|
134
|
+
try {
|
|
135
|
+
await entries.set({
|
|
136
|
+
runId,
|
|
137
|
+
path: stdoutPath,
|
|
138
|
+
body: stdoutBody,
|
|
139
|
+
append: true,
|
|
140
|
+
});
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
if (stderrBody) {
|
|
144
|
+
try {
|
|
145
|
+
await entries.set({
|
|
146
|
+
runId,
|
|
147
|
+
path: stderrPath,
|
|
148
|
+
body: stderrBody,
|
|
149
|
+
append: true,
|
|
150
|
+
});
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
const exitCode = code === null ? 130 : code;
|
|
154
|
+
const duration = `${Math.round((Date.now() - start) / 1000)}s`;
|
|
155
|
+
const terminalState = exitCode === 0 ? "resolved" : "failed";
|
|
156
|
+
const outcome = exitCode === 0 ? null : `exit:${exitCode}`;
|
|
157
|
+
// Transition state without touching body — getState doesn't
|
|
158
|
+
// return body, and entries.set with body=undefined preserves
|
|
159
|
+
// the streamed content already in place. (`body: ""` would
|
|
160
|
+
// wipe everything we just streamed.)
|
|
161
|
+
for (const path of [stdoutPath, stderrPath]) {
|
|
162
|
+
try {
|
|
163
|
+
await entries.set({
|
|
164
|
+
runId,
|
|
165
|
+
path,
|
|
166
|
+
state: terminalState,
|
|
167
|
+
outcome,
|
|
168
|
+
});
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const channels = await entries.getEntriesByPattern(
|
|
173
|
+
runId,
|
|
174
|
+
`${dataBase}_*`,
|
|
175
|
+
null,
|
|
176
|
+
);
|
|
177
|
+
const summary = channels
|
|
178
|
+
.map((c) => `${c.path} (${c.tokens || 0} tokens)`)
|
|
179
|
+
.join(", ");
|
|
180
|
+
const exitLabel = exitCode === 0 ? "exit=0" : `exit=${exitCode}`;
|
|
181
|
+
await entries.set({
|
|
182
|
+
runId,
|
|
183
|
+
path: logPath,
|
|
184
|
+
state: "resolved",
|
|
185
|
+
body: `ran '${command}', ${exitLabel} (${duration}). Output: ${summary}`,
|
|
186
|
+
});
|
|
187
|
+
} catch {}
|
|
188
|
+
resolve();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -8,6 +8,7 @@ export default class ClientConnection {
|
|
|
8
8
|
#hooks;
|
|
9
9
|
#rpcRegistry;
|
|
10
10
|
#rpcLogPending = new Map();
|
|
11
|
+
#shutdownPromise = null;
|
|
11
12
|
#context = {
|
|
12
13
|
projectId: null,
|
|
13
14
|
projectRoot: null,
|
|
@@ -21,7 +22,13 @@ export default class ClientConnection {
|
|
|
21
22
|
this.#projectAgent = new ProjectAgent(db, hooks);
|
|
22
23
|
|
|
23
24
|
this.#ws.on("message", (data) => this.#handleMessage(data));
|
|
24
|
-
this.#ws.on("close", () =>
|
|
25
|
+
this.#ws.on("close", () => {
|
|
26
|
+
// Fire-and-forget: the Promise is cached by `shutdown()` so
|
|
27
|
+
// server-initiated close can await the same work.
|
|
28
|
+
this.shutdown().catch((err) => {
|
|
29
|
+
console.warn(`[RUMMY] shutdown on ws close failed: ${err.message}`);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
25
32
|
|
|
26
33
|
this.#setupNotifications();
|
|
27
34
|
}
|
|
@@ -63,6 +70,16 @@ export default class ClientConnection {
|
|
|
63
70
|
}
|
|
64
71
|
};
|
|
65
72
|
|
|
73
|
+
#onStreamCancelled = (payload) => {
|
|
74
|
+
if (payload.projectId === this.#context.projectId) {
|
|
75
|
+
this.#sendNotification("stream/cancelled", {
|
|
76
|
+
run: payload.run,
|
|
77
|
+
path: payload.path,
|
|
78
|
+
reason: payload.reason,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
66
83
|
#onState = (payload) => {
|
|
67
84
|
if (payload.projectId === this.#context.projectId) {
|
|
68
85
|
this.#sendNotification("run/state", {
|
|
@@ -79,18 +96,37 @@ export default class ClientConnection {
|
|
|
79
96
|
|
|
80
97
|
#setupNotifications() {
|
|
81
98
|
this.#hooks.run.progress.on(this.#onProgress);
|
|
82
|
-
this.#hooks.
|
|
99
|
+
this.#hooks.proposal.pending.on(this.#onProposal);
|
|
83
100
|
this.#hooks.ui.render.on(this.#onRender);
|
|
84
101
|
this.#hooks.ui.notify.on(this.#onNotify);
|
|
85
102
|
this.#hooks.run.state.on(this.#onState);
|
|
103
|
+
this.#hooks.stream.cancelled.on(this.#onStreamCancelled);
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
#teardown() {
|
|
89
107
|
this.#hooks.run.progress.off(this.#onProgress);
|
|
90
|
-
this.#hooks.
|
|
108
|
+
this.#hooks.proposal.pending.off(this.#onProposal);
|
|
91
109
|
this.#hooks.ui.render.off(this.#onRender);
|
|
92
110
|
this.#hooks.ui.notify.off(this.#onNotify);
|
|
93
111
|
this.#hooks.run.state.off(this.#onState);
|
|
112
|
+
this.#hooks.stream.cancelled.off(this.#onStreamCancelled);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Abort in-flight runs on this connection and wait for them to
|
|
117
|
+
* settle. Idempotent: `ws.on("close")` and server-initiated close
|
|
118
|
+
* both call this; the cached Promise guarantees the work happens
|
|
119
|
+
* exactly once and both callers observe the same completion.
|
|
120
|
+
*/
|
|
121
|
+
shutdown() {
|
|
122
|
+
if (!this.#shutdownPromise) {
|
|
123
|
+
this.#shutdownPromise = (async () => {
|
|
124
|
+
await this.#projectAgent.shutdown();
|
|
125
|
+
this.#teardown();
|
|
126
|
+
if (this.#ws.readyState === 1) this.#ws.terminate();
|
|
127
|
+
})();
|
|
128
|
+
}
|
|
129
|
+
return this.#shutdownPromise;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
#buildHandlerContext() {
|
|
@@ -113,11 +149,8 @@ export default class ClientConnection {
|
|
|
113
149
|
|
|
114
150
|
async #handleMessage(data) {
|
|
115
151
|
let id = null;
|
|
116
|
-
const debug = process.env.RUMMY_DEBUG === "true";
|
|
117
152
|
try {
|
|
118
153
|
const rawMessage = await this.#hooks.socket.message.raw.filter(data);
|
|
119
|
-
if (debug) console.log(`[SOCKET] IN: ${rawMessage.toString()}`);
|
|
120
|
-
|
|
121
154
|
const message = JSON.parse(rawMessage.toString());
|
|
122
155
|
|
|
123
156
|
const filteredRequest = await this.#hooks.rpc.request.filter(message);
|
|
@@ -131,15 +164,13 @@ export default class ClientConnection {
|
|
|
131
164
|
projectId: this.#context.projectId,
|
|
132
165
|
});
|
|
133
166
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (logRow) this.#rpcLogPending.set(id, logRow.id);
|
|
142
|
-
} catch {}
|
|
167
|
+
const logRow = await this.#db.log_rpc_call.get({
|
|
168
|
+
project_id: this.#context.projectId,
|
|
169
|
+
method,
|
|
170
|
+
rpc_id: id,
|
|
171
|
+
params: params ? JSON.stringify(params) : null,
|
|
172
|
+
});
|
|
173
|
+
if (logRow) this.#rpcLogPending.set(id, logRow.id);
|
|
143
174
|
|
|
144
175
|
const resolvedMethod = method === "rpc/discover" ? "discover" : method;
|
|
145
176
|
const registration = this.#rpcRegistry.get(resolvedMethod);
|
|
@@ -150,17 +181,19 @@ export default class ClientConnection {
|
|
|
150
181
|
throw new Error(msg("error.not_initialized"));
|
|
151
182
|
}
|
|
152
183
|
|
|
184
|
+
// JSON-RPC requests may omit `params` entirely.
|
|
185
|
+
const handlerParams = params === undefined ? {} : params;
|
|
153
186
|
let result;
|
|
154
187
|
if (registration.longRunning) {
|
|
155
188
|
result = await registration.handler(
|
|
156
|
-
|
|
189
|
+
handlerParams,
|
|
157
190
|
this.#buildHandlerContext(),
|
|
158
191
|
);
|
|
159
192
|
} else {
|
|
160
|
-
const timeout = Number(process.env.RUMMY_RPC_TIMEOUT)
|
|
193
|
+
const timeout = Number(process.env.RUMMY_RPC_TIMEOUT);
|
|
161
194
|
let timer;
|
|
162
195
|
result = await Promise.race([
|
|
163
|
-
registration.handler(
|
|
196
|
+
registration.handler(handlerParams, this.#buildHandlerContext()),
|
|
164
197
|
new Promise((_, reject) => {
|
|
165
198
|
timer = setTimeout(
|
|
166
199
|
() =>
|
|
@@ -198,43 +231,37 @@ export default class ClientConnection {
|
|
|
198
231
|
const logId = this.#rpcLogPending.get(id);
|
|
199
232
|
if (logId) {
|
|
200
233
|
this.#rpcLogPending.delete(id);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
208
|
-
} catch {}
|
|
234
|
+
await this.#db.log_rpc_result.run({
|
|
235
|
+
id: logId,
|
|
236
|
+
result: finalResult
|
|
237
|
+
? JSON.stringify(finalResult).slice(0, 4096)
|
|
238
|
+
: null,
|
|
239
|
+
});
|
|
209
240
|
}
|
|
210
241
|
} catch (error) {
|
|
211
242
|
console.error(`[RUMMY] RPC Error: ${error.message}`);
|
|
212
243
|
console.error(`[RUMMY] Stack: ${error.stack}`);
|
|
244
|
+
// JSON-RPC: error responses for malformed requests with no id
|
|
245
|
+
// MUST carry null per the spec.
|
|
213
246
|
this.#send({
|
|
214
247
|
jsonrpc: "2.0",
|
|
215
248
|
error: { code: -32603, message: error.message },
|
|
216
|
-
id: id
|
|
249
|
+
id: id === undefined ? null : id,
|
|
217
250
|
});
|
|
218
251
|
await this.#hooks.rpc.error.emit({ id, error });
|
|
219
252
|
|
|
220
253
|
const errLogId = this.#rpcLogPending.get(id);
|
|
221
254
|
if (errLogId) {
|
|
222
255
|
this.#rpcLogPending.delete(id);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
228
|
-
} catch {}
|
|
256
|
+
await this.#db.log_rpc_error.run({
|
|
257
|
+
id: errLogId,
|
|
258
|
+
error: error.message,
|
|
259
|
+
});
|
|
229
260
|
}
|
|
230
261
|
}
|
|
231
262
|
}
|
|
232
263
|
|
|
233
264
|
#send(payload) {
|
|
234
|
-
const debug = process.env.RUMMY_DEBUG === "true";
|
|
235
|
-
if (debug) {
|
|
236
|
-
console.log(`[SOCKET] OUT: ${JSON.stringify(payload, null, 2)}`);
|
|
237
|
-
}
|
|
238
265
|
if (this.#ws.readyState === 1) {
|
|
239
266
|
this.#ws.send(JSON.stringify(payload));
|
|
240
267
|
}
|
|
@@ -5,17 +5,23 @@ export default class SocketServer {
|
|
|
5
5
|
#db;
|
|
6
6
|
#wss;
|
|
7
7
|
#hooks;
|
|
8
|
+
#connections = new Set();
|
|
8
9
|
|
|
9
10
|
constructor(db, options) {
|
|
10
11
|
this.#db = db;
|
|
11
12
|
this.#hooks = options.hooks;
|
|
12
13
|
this.#wss = new WebSocketServer(options);
|
|
13
14
|
|
|
14
|
-
this.#wss.on("connection", (ws,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
this.#wss.on("connection", (ws, _req) => {
|
|
16
|
+
const conn = new ClientConnection(ws, this.#db, this.#hooks);
|
|
17
|
+
this.#connections.add(conn);
|
|
18
|
+
// Remove from the tracking set only after the connection's
|
|
19
|
+
// shutdown drain has fully settled — not on raw ws-close —
|
|
20
|
+
// so server close() can still find and await an in-progress
|
|
21
|
+
// shutdown kicked off by a client-initiated disconnect.
|
|
22
|
+
ws.on("close", () => {
|
|
23
|
+
conn.shutdown().finally(() => this.#connections.delete(conn));
|
|
24
|
+
});
|
|
19
25
|
});
|
|
20
26
|
|
|
21
27
|
this.#wss.on("error", (_err) => {
|
|
@@ -31,12 +37,19 @@ export default class SocketServer {
|
|
|
31
37
|
this.#wss.on(event, handler);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
close() {
|
|
35
|
-
|
|
40
|
+
async close() {
|
|
41
|
+
// Drain in-flight runs on each connection before closing the
|
|
42
|
+
// socket — otherwise detached kickoff Promises keep the Node
|
|
43
|
+
// event loop alive past server shutdown.
|
|
44
|
+
const shutdowns = [];
|
|
45
|
+
for (const conn of this.#connections) {
|
|
46
|
+
shutdowns.push(conn.shutdown().catch(() => {}));
|
|
47
|
+
}
|
|
48
|
+
await Promise.all(shutdowns);
|
|
49
|
+
this.#connections.clear();
|
|
50
|
+
|
|
51
|
+
await new Promise((resolve) => {
|
|
36
52
|
if (!this.#wss) return resolve();
|
|
37
|
-
for (const client of this.#wss.clients) {
|
|
38
|
-
client.terminate();
|
|
39
|
-
}
|
|
40
53
|
this.#wss.close(resolve);
|
|
41
54
|
});
|
|
42
55
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server↔client wire-protocol version. Bumped whenever the RPC shape
|
|
3
|
+
* or notification payload shape changes in a way that breaks existing
|
|
4
|
+
* clients. Clients pass their own version in `rummy/hello`; server
|
|
5
|
+
* rejects MAJOR mismatch. Git commit log is the changelog.
|
|
6
|
+
*
|
|
7
|
+
* MAJOR — breaking change (removed/renamed method, shape change)
|
|
8
|
+
* MINOR — additive change (new method, new optional field)
|
|
9
|
+
* PATCH — internal fix visible to the wire shape
|
|
10
|
+
*/
|
|
11
|
+
export const RUMMY_PROTOCOL_VERSION = "2.0.0";
|