@hegemonart/get-design-done 1.20.0 → 1.22.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/.claude-plugin/marketplace.json +9 -12
- package/.claude-plugin/plugin.json +8 -31
- package/CHANGELOG.md +200 -0
- package/README.md +48 -7
- package/bin/gdd-sdk +55 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +19 -47
- package/reference/codex-tools.md +53 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +14 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +31 -1
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// scripts/lib/cli/parse-args.ts — Plan 21-09 Task 1 (SDK-21).
|
|
2
|
+
//
|
|
3
|
+
// Hand-rolled argv parser used by the `gdd-sdk` CLI. No external
|
|
4
|
+
// dependency (no yargs / commander / minimist). Supports the exact
|
|
5
|
+
// subset documented in PLAN.md:
|
|
6
|
+
//
|
|
7
|
+
// * Long flags: `--name value` or `--name=value`
|
|
8
|
+
// * Short flags: `-h`, `-v` only (help / version)
|
|
9
|
+
// * Boolean toggles: `--headless` (no value — present == true)
|
|
10
|
+
// * End-of-flags: `--` (everything after goes into `passthrough`)
|
|
11
|
+
//
|
|
12
|
+
// The parser itself does NOT validate that the first positional is a
|
|
13
|
+
// known subcommand — routing lives in `index.ts`. `coerceFlags()` is
|
|
14
|
+
// where type conversion + spec-driven validation happens.
|
|
15
|
+
//
|
|
16
|
+
// Contract:
|
|
17
|
+
// * Pure + deterministic. No I/O, no process reads. Safe to unit-test.
|
|
18
|
+
// * Never throws from `parseArgs()` — all failure surfaces land on the
|
|
19
|
+
// returned shape (unknown flags are captured as strings, not rejected).
|
|
20
|
+
// * `coerceFlags()` DOES throw `ValidationError` for malformed specs
|
|
21
|
+
// (e.g., non-numeric input for a numeric flag). Callers ideally catch
|
|
22
|
+
// and route to exit code 3.
|
|
23
|
+
|
|
24
|
+
import { ValidationError } from '../gdd-errors/index.ts';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public types.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Result of `parseArgs()`. All fields are frozen so downstream code
|
|
32
|
+
* cannot accidentally mutate the parser output.
|
|
33
|
+
*
|
|
34
|
+
* * `subcommand` — the first non-flag token. `null` when argv is empty
|
|
35
|
+
* or starts with a flag.
|
|
36
|
+
* * `positionals` — every non-flag token AFTER the subcommand (but
|
|
37
|
+
* before `--`).
|
|
38
|
+
* * `flags` — every `--name[=value]` / `-h` token keyed by name with
|
|
39
|
+
* value `true` (boolean toggle) or string (explicit value). No type
|
|
40
|
+
* coercion happens here.
|
|
41
|
+
* * `passthrough` — everything after the sentinel `--`, in order.
|
|
42
|
+
*/
|
|
43
|
+
export interface ParsedArgs {
|
|
44
|
+
readonly subcommand: string | null;
|
|
45
|
+
readonly positionals: readonly string[];
|
|
46
|
+
readonly flags: Readonly<Record<string, string | boolean>>;
|
|
47
|
+
readonly passthrough: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Flag type a spec can declare. */
|
|
51
|
+
export type FlagType = 'string' | 'number' | 'boolean';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declarative spec for one flag. Aliases let short names map to a
|
|
55
|
+
* canonical long name (e.g., `-h` → `help`). `default` is returned from
|
|
56
|
+
* `coerceFlags()` when the flag is absent.
|
|
57
|
+
*/
|
|
58
|
+
export interface FlagSpec {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly type: FlagType;
|
|
61
|
+
readonly default?: unknown;
|
|
62
|
+
readonly aliases?: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// parseArgs — pure tokenization pass.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse `argv` into typed `ParsedArgs`. See module header for grammar.
|
|
71
|
+
*
|
|
72
|
+
* The function is tolerant by design: unknown flags still appear in
|
|
73
|
+
* `flags` (caller may warn or error as desired via `coerceFlags`). Bad
|
|
74
|
+
* token order (e.g., two consecutive `--` sentinels) simply folds into
|
|
75
|
+
* passthrough.
|
|
76
|
+
*
|
|
77
|
+
* @param argv Argument tokens, e.g., `process.argv.slice(2)`.
|
|
78
|
+
* @param _specs Optional (reserved for parity with `coerceFlags`). Not
|
|
79
|
+
* used today — kept to match PLAN.md signature.
|
|
80
|
+
*/
|
|
81
|
+
export function parseArgs(
|
|
82
|
+
argv: readonly string[],
|
|
83
|
+
_specs?: readonly FlagSpec[],
|
|
84
|
+
): ParsedArgs {
|
|
85
|
+
const flags: Record<string, string | boolean> = {};
|
|
86
|
+
const positionals: string[] = [];
|
|
87
|
+
const passthrough: string[] = [];
|
|
88
|
+
let subcommand: string | null = null;
|
|
89
|
+
|
|
90
|
+
// State: have we crossed the `--` sentinel? Everything after goes to
|
|
91
|
+
// `passthrough` verbatim.
|
|
92
|
+
let afterDoubleDash = false;
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < argv.length; i++) {
|
|
95
|
+
const token: string | undefined = argv[i];
|
|
96
|
+
if (token === undefined) continue;
|
|
97
|
+
|
|
98
|
+
if (afterDoubleDash) {
|
|
99
|
+
passthrough.push(token);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (token === '--') {
|
|
104
|
+
afterDoubleDash = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Long flag: `--name` or `--name=value`.
|
|
109
|
+
if (token.startsWith('--')) {
|
|
110
|
+
const body = token.slice(2);
|
|
111
|
+
if (body.length === 0) {
|
|
112
|
+
// Bare `--` handled above; defensive fallthrough.
|
|
113
|
+
afterDoubleDash = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const eq = body.indexOf('=');
|
|
117
|
+
if (eq >= 0) {
|
|
118
|
+
const name = body.slice(0, eq);
|
|
119
|
+
const value = body.slice(eq + 1);
|
|
120
|
+
if (name.length > 0) {
|
|
121
|
+
flags[name] = value;
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// No `=`. Peek the next token — if it exists and is NOT another
|
|
126
|
+
// flag, consume as the value. Otherwise treat as boolean toggle.
|
|
127
|
+
const next: string | undefined = argv[i + 1];
|
|
128
|
+
if (
|
|
129
|
+
next !== undefined &&
|
|
130
|
+
!next.startsWith('-') &&
|
|
131
|
+
!isLikelyBoolFlag(body)
|
|
132
|
+
) {
|
|
133
|
+
flags[body] = next;
|
|
134
|
+
i += 1;
|
|
135
|
+
} else {
|
|
136
|
+
flags[body] = true;
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Short flag: single letter after a single dash.
|
|
142
|
+
if (token.startsWith('-') && token.length >= 2) {
|
|
143
|
+
const rest = token.slice(1);
|
|
144
|
+
// Accept only 1-letter short flags per PLAN.md ("Only 1-letter
|
|
145
|
+
// shorts: `-h`, `-v`"). Anything longer we treat as-is and let the
|
|
146
|
+
// consumer decide — we record it under the first letter.
|
|
147
|
+
if (rest.length === 1) {
|
|
148
|
+
flags[rest] = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Multi-char short (e.g., `-abc`) — treat as an unknown flag
|
|
152
|
+
// literal. Record under the whole body so callers can detect.
|
|
153
|
+
flags[rest] = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Positional. First positional is the subcommand.
|
|
158
|
+
if (subcommand === null) {
|
|
159
|
+
subcommand = token;
|
|
160
|
+
} else {
|
|
161
|
+
positionals.push(token);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
subcommand,
|
|
167
|
+
positionals: Object.freeze(positionals),
|
|
168
|
+
flags: Object.freeze(flags),
|
|
169
|
+
passthrough: Object.freeze(passthrough),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Known boolean-toggle flag names. When the parser encounters one of
|
|
175
|
+
* these WITHOUT an `=value` it should NOT consume the next token even
|
|
176
|
+
* if that token looks like a value — the next token is a positional
|
|
177
|
+
* arg. Keeps `gdd-sdk stage discuss --parallel plan` parsing correctly:
|
|
178
|
+
* `--parallel` is a bool, `plan` stays in positionals.
|
|
179
|
+
*
|
|
180
|
+
* The list is conservative (only flags the CLI declares as boolean in
|
|
181
|
+
* its specs); unknown bool flags fall through to the generic peek-value
|
|
182
|
+
* heuristic which is safe for the CLI's other flags because every
|
|
183
|
+
* value-carrying flag is numeric or string (never ambiguous).
|
|
184
|
+
*/
|
|
185
|
+
const BOOL_FLAG_NAMES: ReadonlySet<string> = new Set([
|
|
186
|
+
'help',
|
|
187
|
+
'h',
|
|
188
|
+
'version',
|
|
189
|
+
'v',
|
|
190
|
+
'headless',
|
|
191
|
+
'interactive',
|
|
192
|
+
'json',
|
|
193
|
+
'text',
|
|
194
|
+
'force',
|
|
195
|
+
'parallel',
|
|
196
|
+
'dry-run',
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
function isLikelyBoolFlag(name: string): boolean {
|
|
200
|
+
return BOOL_FLAG_NAMES.has(name);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// coerceFlags — spec-driven type conversion + defaults.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Apply type coercion + defaults based on `specs`. Aliases let
|
|
209
|
+
* `-h` (parsed as `flags.h = true`) resolve to the canonical `help`
|
|
210
|
+
* key in the returned map.
|
|
211
|
+
*
|
|
212
|
+
* Throws `ValidationError` when a flag was supplied with a value that
|
|
213
|
+
* cannot coerce to the declared type (e.g., `--budget-usd abc`). Flags
|
|
214
|
+
* not declared in `specs` pass through unchanged (as their raw
|
|
215
|
+
* string|boolean value) so callers can still see them.
|
|
216
|
+
*/
|
|
217
|
+
export function coerceFlags(
|
|
218
|
+
parsed: ParsedArgs,
|
|
219
|
+
specs: readonly FlagSpec[],
|
|
220
|
+
): Record<string, unknown> {
|
|
221
|
+
const out: Record<string, unknown> = {};
|
|
222
|
+
|
|
223
|
+
// Collect every alias → canonical map.
|
|
224
|
+
const canonical = new Map<string, FlagSpec>();
|
|
225
|
+
for (const spec of specs) {
|
|
226
|
+
canonical.set(spec.name, spec);
|
|
227
|
+
for (const alias of spec.aliases ?? []) {
|
|
228
|
+
canonical.set(alias, spec);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build a reverse-lookup of values present on `parsed.flags` keyed by
|
|
233
|
+
// their canonical name (so `-h` and `--help` both land on `help`).
|
|
234
|
+
const resolved: Record<string, string | boolean> = {};
|
|
235
|
+
for (const [key, value] of Object.entries(parsed.flags)) {
|
|
236
|
+
const spec = canonical.get(key);
|
|
237
|
+
const target = spec !== undefined ? spec.name : key;
|
|
238
|
+
// Last-write-wins — operators rarely specify a flag twice; when they
|
|
239
|
+
// do, the final value prevails.
|
|
240
|
+
resolved[target] = value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Apply defaults + coerce.
|
|
244
|
+
for (const spec of specs) {
|
|
245
|
+
const raw = Object.prototype.hasOwnProperty.call(resolved, spec.name)
|
|
246
|
+
? resolved[spec.name]
|
|
247
|
+
: undefined;
|
|
248
|
+
if (raw === undefined) {
|
|
249
|
+
if (spec.default !== undefined) {
|
|
250
|
+
out[spec.name] = spec.default;
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
out[spec.name] = coerceValue(spec, raw);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Pass-through any flags not declared in specs (so `query get --tail 5`
|
|
258
|
+
// keeps `tail` visible even if `tail` isn't in the common-flag spec list).
|
|
259
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
260
|
+
if (!Object.prototype.hasOwnProperty.call(out, key)) {
|
|
261
|
+
out[key] = value;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Coerce a single raw value against its spec. Throws `ValidationError`
|
|
270
|
+
* on malformed input so the caller can exit with code 3.
|
|
271
|
+
*/
|
|
272
|
+
function coerceValue(spec: FlagSpec, raw: string | boolean): unknown {
|
|
273
|
+
if (spec.type === 'boolean') {
|
|
274
|
+
if (raw === true || raw === false) return raw;
|
|
275
|
+
// String values `"true"` / `"false"` (from `--flag=true`) are honored.
|
|
276
|
+
if (raw === 'true' || raw === '1') return true;
|
|
277
|
+
if (raw === 'false' || raw === '0') return false;
|
|
278
|
+
throw new ValidationError(
|
|
279
|
+
`flag --${spec.name} expects a boolean (true/false/1/0), got "${String(raw)}"`,
|
|
280
|
+
'INVALID_FLAG_VALUE',
|
|
281
|
+
{ flag: spec.name, value: raw },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (spec.type === 'number') {
|
|
285
|
+
if (typeof raw === 'boolean') {
|
|
286
|
+
throw new ValidationError(
|
|
287
|
+
`flag --${spec.name} requires a numeric value`,
|
|
288
|
+
'INVALID_FLAG_VALUE',
|
|
289
|
+
{ flag: spec.name },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const n = Number(raw);
|
|
293
|
+
if (!Number.isFinite(n)) {
|
|
294
|
+
throw new ValidationError(
|
|
295
|
+
`flag --${spec.name} expects a number, got "${raw}"`,
|
|
296
|
+
'INVALID_FLAG_VALUE',
|
|
297
|
+
{ flag: spec.name, value: raw },
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return n;
|
|
301
|
+
}
|
|
302
|
+
// string
|
|
303
|
+
if (typeof raw === 'boolean') {
|
|
304
|
+
throw new ValidationError(
|
|
305
|
+
`flag --${spec.name} requires a string value`,
|
|
306
|
+
'INVALID_FLAG_VALUE',
|
|
307
|
+
{ flag: spec.name },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return raw;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Common-flag specs used by multiple subcommands.
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Common flags shared across every subcommand. Individual commands may
|
|
319
|
+
* extend this list with their own flags. `default` values follow
|
|
320
|
+
* PLAN.md's recommendations.
|
|
321
|
+
*/
|
|
322
|
+
export const COMMON_FLAGS: readonly FlagSpec[] = Object.freeze([
|
|
323
|
+
Object.freeze({ name: 'help', type: 'boolean', default: false, aliases: ['h'] }),
|
|
324
|
+
Object.freeze({ name: 'version', type: 'boolean', default: false, aliases: ['v'] }),
|
|
325
|
+
Object.freeze({ name: 'cwd', type: 'string' }),
|
|
326
|
+
Object.freeze({ name: 'log-level', type: 'string', default: 'info' }),
|
|
327
|
+
Object.freeze({ name: 'headless', type: 'boolean', default: false }),
|
|
328
|
+
Object.freeze({ name: 'interactive', type: 'boolean', default: false }),
|
|
329
|
+
Object.freeze({ name: 'json', type: 'boolean', default: false }),
|
|
330
|
+
Object.freeze({ name: 'text', type: 'boolean', default: false }),
|
|
331
|
+
Object.freeze({ name: 'budget-usd', type: 'number' }),
|
|
332
|
+
Object.freeze({ name: 'budget-input-tokens', type: 'number', default: 200_000 }),
|
|
333
|
+
Object.freeze({ name: 'budget-output-tokens', type: 'number', default: 50_000 }),
|
|
334
|
+
Object.freeze({ name: 'max-turns', type: 'number', default: 40 }),
|
|
335
|
+
Object.freeze({ name: 'concurrency', type: 'number', default: 4 }),
|
|
336
|
+
]);
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connection-probe/index.cjs — code-level connection liveness probe
|
|
3
|
+
* (Plan 22-08).
|
|
4
|
+
*
|
|
5
|
+
* Replaces today's per-connection ad-hoc probe bash snippets in
|
|
6
|
+
* `connections/` with one typed primitive. Used by Phase 21
|
|
7
|
+
* pipeline-runner and Phase 22 reflector.
|
|
8
|
+
*
|
|
9
|
+
* Contract:
|
|
10
|
+
* probe({
|
|
11
|
+
* name: 'figma' | 'pinterest' | … // free-form connection id
|
|
12
|
+
* cmd: async () => boolean | Truthy // probe action
|
|
13
|
+
* timeout: number ms // default 5000
|
|
14
|
+
* retries: number // default 3 attempts total
|
|
15
|
+
* fallback: async () => unknown // optional degraded path
|
|
16
|
+
* }) → {
|
|
17
|
+
* status: 'ok' | 'degraded' | 'down'
|
|
18
|
+
* latency_ms: number
|
|
19
|
+
* attempts: number
|
|
20
|
+
* fallback_used: boolean
|
|
21
|
+
* error?: string // last error message if any
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* State persistence:
|
|
25
|
+
* * `.design/telemetry/connection-state.json` records `{name → status}`
|
|
26
|
+
* across runs.
|
|
27
|
+
* * On every probe, if the new status differs from cached, emit a
|
|
28
|
+
* `connection.status_change` event via the event-stream bus and
|
|
29
|
+
* overwrite the cached value atomically (write to .tmp + rename).
|
|
30
|
+
*
|
|
31
|
+
* Backoff:
|
|
32
|
+
* * Uses `jittered-backoff.cjs` — `delayMs(attempt)` between retries.
|
|
33
|
+
*
|
|
34
|
+
* The probe `cmd` is awaited with a Promise.race against a timeout. On
|
|
35
|
+
* fulfilment with truthy → ok. On rejection or falsy → fail-this-attempt;
|
|
36
|
+
* retry until exhausted. After full-fail, if `fallback` is supplied,
|
|
37
|
+
* runs it and reports `degraded` + `fallback_used: true`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } = require('node:fs');
|
|
43
|
+
const { dirname, isAbsolute, resolve, join } = require('node:path');
|
|
44
|
+
|
|
45
|
+
const { delayMs } = require('../jittered-backoff.cjs');
|
|
46
|
+
|
|
47
|
+
const DEFAULT_STATE_PATH = '.design/telemetry/connection-state.json';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the connection-state file path against a base dir.
|
|
51
|
+
* @param {{baseDir?: string, statePath?: string}} [opts]
|
|
52
|
+
*/
|
|
53
|
+
function statePathFor(opts = {}) {
|
|
54
|
+
const raw = opts.statePath ?? DEFAULT_STATE_PATH;
|
|
55
|
+
if (isAbsolute(raw)) return raw;
|
|
56
|
+
return resolve(opts.baseDir ?? process.cwd(), raw);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load + return the cached state object (or `{}` if absent / corrupt).
|
|
61
|
+
* @param {string} path
|
|
62
|
+
* @returns {Record<string, string>}
|
|
63
|
+
*/
|
|
64
|
+
function loadState(path) {
|
|
65
|
+
if (!existsSync(path)) return {};
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Atomic state write: write to `.tmp` sibling, rename. Renames are
|
|
75
|
+
* atomic on POSIX and at least crash-safe on Windows for same-volume
|
|
76
|
+
* targets.
|
|
77
|
+
* @param {string} path
|
|
78
|
+
* @param {Record<string, string>} state
|
|
79
|
+
*/
|
|
80
|
+
function saveState(path, state) {
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
const tmp = path + '.tmp';
|
|
84
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
85
|
+
renameSync(tmp, path);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
try {
|
|
88
|
+
process.stderr.write(
|
|
89
|
+
`[connection-probe] state write failed: ${err && err.message ? err.message : String(err)}\n`,
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
/* swallow */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Race `promise` against a timeout. Rejects with `TimeoutError` after
|
|
99
|
+
* `ms` if the promise hasn't settled.
|
|
100
|
+
*
|
|
101
|
+
* @template T
|
|
102
|
+
* @param {Promise<T>} promise
|
|
103
|
+
* @param {number} ms
|
|
104
|
+
* @returns {Promise<T>}
|
|
105
|
+
*/
|
|
106
|
+
function withTimeout(promise, ms) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
const err = new Error(`probe timed out after ${ms}ms`);
|
|
110
|
+
err.code = 'PROBE_TIMEOUT';
|
|
111
|
+
reject(err);
|
|
112
|
+
}, ms);
|
|
113
|
+
promise.then(
|
|
114
|
+
(v) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolve(v);
|
|
117
|
+
},
|
|
118
|
+
(e) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
reject(e);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run the probe with retries + optional fallback. Resolves to a
|
|
128
|
+
* structured outcome; never rejects.
|
|
129
|
+
*
|
|
130
|
+
* @param {{
|
|
131
|
+
* name: string,
|
|
132
|
+
* cmd: () => Promise<unknown>,
|
|
133
|
+
* timeout?: number,
|
|
134
|
+
* retries?: number,
|
|
135
|
+
* fallback?: () => Promise<unknown>,
|
|
136
|
+
* baseDir?: string,
|
|
137
|
+
* statePath?: string,
|
|
138
|
+
* emit?: (ev: unknown) => void,
|
|
139
|
+
* }} opts
|
|
140
|
+
* @returns {Promise<{
|
|
141
|
+
* status: 'ok' | 'degraded' | 'down',
|
|
142
|
+
* latency_ms: number,
|
|
143
|
+
* attempts: number,
|
|
144
|
+
* fallback_used: boolean,
|
|
145
|
+
* error?: string,
|
|
146
|
+
* }>}
|
|
147
|
+
*/
|
|
148
|
+
async function probe(opts) {
|
|
149
|
+
if (!opts || typeof opts.name !== 'string' || opts.name.length === 0) {
|
|
150
|
+
throw new TypeError('probe: name (string) required');
|
|
151
|
+
}
|
|
152
|
+
if (typeof opts.cmd !== 'function') {
|
|
153
|
+
throw new TypeError('probe: cmd (async fn) required');
|
|
154
|
+
}
|
|
155
|
+
const timeout = opts.timeout ?? 5000;
|
|
156
|
+
const retries = Math.max(1, opts.retries ?? 3);
|
|
157
|
+
const path = statePathFor(opts);
|
|
158
|
+
|
|
159
|
+
const start = Date.now();
|
|
160
|
+
let attempts = 0;
|
|
161
|
+
let lastError;
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < retries; i++) {
|
|
164
|
+
attempts += 1;
|
|
165
|
+
try {
|
|
166
|
+
const result = await withTimeout(Promise.resolve(opts.cmd()), timeout);
|
|
167
|
+
if (result) {
|
|
168
|
+
const outcome = {
|
|
169
|
+
status: /** @type {'ok'} */ ('ok'),
|
|
170
|
+
latency_ms: Date.now() - start,
|
|
171
|
+
attempts,
|
|
172
|
+
fallback_used: false,
|
|
173
|
+
};
|
|
174
|
+
await recordTransition(opts.name, outcome.status, path, opts.emit);
|
|
175
|
+
return outcome;
|
|
176
|
+
}
|
|
177
|
+
// falsy = soft-fail; retry
|
|
178
|
+
lastError = new Error('probe returned falsy');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
lastError = err;
|
|
181
|
+
}
|
|
182
|
+
if (i < retries - 1) {
|
|
183
|
+
await sleep(delayMs(i));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// All retries failed. Try fallback if supplied.
|
|
188
|
+
if (typeof opts.fallback === 'function') {
|
|
189
|
+
try {
|
|
190
|
+
await opts.fallback();
|
|
191
|
+
const outcome = {
|
|
192
|
+
status: /** @type {'degraded'} */ ('degraded'),
|
|
193
|
+
latency_ms: Date.now() - start,
|
|
194
|
+
attempts,
|
|
195
|
+
fallback_used: true,
|
|
196
|
+
error: lastError && lastError.message ? lastError.message : String(lastError),
|
|
197
|
+
};
|
|
198
|
+
await recordTransition(opts.name, outcome.status, path, opts.emit);
|
|
199
|
+
return outcome;
|
|
200
|
+
} catch {
|
|
201
|
+
/* fall through to down */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const outcome = {
|
|
206
|
+
status: /** @type {'down'} */ ('down'),
|
|
207
|
+
latency_ms: Date.now() - start,
|
|
208
|
+
attempts,
|
|
209
|
+
fallback_used: false,
|
|
210
|
+
error: lastError && lastError.message ? lastError.message : String(lastError),
|
|
211
|
+
};
|
|
212
|
+
await recordTransition(opts.name, outcome.status, path, opts.emit);
|
|
213
|
+
return outcome;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Sleep for `ms` milliseconds. */
|
|
217
|
+
function sleep(ms) {
|
|
218
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Compare against cached state. If status differs, emit a
|
|
223
|
+
* `connection.status_change` event (when an `emit` callback is supplied)
|
|
224
|
+
* and overwrite the cached value atomically.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} name
|
|
227
|
+
* @param {string} status
|
|
228
|
+
* @param {string} statePath
|
|
229
|
+
* @param {undefined | ((ev: unknown) => void)} emit
|
|
230
|
+
*/
|
|
231
|
+
async function recordTransition(name, status, statePath, emit) {
|
|
232
|
+
const state = loadState(statePath);
|
|
233
|
+
const previous = state[name];
|
|
234
|
+
if (previous === status) return; // no transition
|
|
235
|
+
state[name] = status;
|
|
236
|
+
saveState(statePath, state);
|
|
237
|
+
if (typeof emit === 'function') {
|
|
238
|
+
try {
|
|
239
|
+
emit({
|
|
240
|
+
type: 'connection.status_change',
|
|
241
|
+
timestamp: new Date().toISOString(),
|
|
242
|
+
sessionId: process.env.GDD_SESSION_ID || 'unknown',
|
|
243
|
+
payload: { name, from: previous ?? 'unknown', to: status },
|
|
244
|
+
});
|
|
245
|
+
} catch (err) {
|
|
246
|
+
try {
|
|
247
|
+
process.stderr.write(
|
|
248
|
+
`[connection-probe] emit failed: ${err && err.message ? err.message : String(err)}\n`,
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
/* swallow */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
probe,
|
|
259
|
+
statePathFor,
|
|
260
|
+
loadState,
|
|
261
|
+
saveState,
|
|
262
|
+
DEFAULT_STATE_PATH,
|
|
263
|
+
};
|