deepdebug-local-agent 1.0.23 → 1.1.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/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +69 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +69 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +43 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +30 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +320 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { IncidentForwarder } from '../src/siblings/incident-forwarder.js';
|
|
4
|
+
|
|
5
|
+
const NOOP_LOGGER = { info() {}, warn() {}, error() {}, debug() {} };
|
|
6
|
+
// Disable burst suppression where a test intentionally sends identical events.
|
|
7
|
+
const NO_SUPPRESS = { shouldSuppress: () => false };
|
|
8
|
+
|
|
9
|
+
function validEvent(over = {}) {
|
|
10
|
+
return {
|
|
11
|
+
adapter: 'sap',
|
|
12
|
+
errorCode: 'DUMP',
|
|
13
|
+
file: '/logs/dev_w0',
|
|
14
|
+
message: 'ABAP runtime error',
|
|
15
|
+
source: 'sap-nw-01',
|
|
16
|
+
timestamp: '2026-07-28T10:00:00Z',
|
|
17
|
+
...over,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('forwards a valid 6-key event to the gateway', async () => {
|
|
22
|
+
const calls = [];
|
|
23
|
+
const fetchImpl = async (url, opts) => {
|
|
24
|
+
calls.push({ url, body: JSON.parse(opts.body) });
|
|
25
|
+
return { ok: true, status: 200 };
|
|
26
|
+
};
|
|
27
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
|
|
28
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', tenantId: 't1', apiKey: 'k', fetchImpl });
|
|
29
|
+
|
|
30
|
+
fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
|
|
31
|
+
await fwd.flush();
|
|
32
|
+
|
|
33
|
+
assert.equal(fwd.stats.forwarded, 1);
|
|
34
|
+
assert.equal(fwd.stats.failed, 0);
|
|
35
|
+
assert.equal(calls.length, 1);
|
|
36
|
+
assert.equal(calls[0].url, 'https://gw.example/api/v1/internal/incident/create');
|
|
37
|
+
assert.equal(calls[0].body.service, 'sap-nw-01');
|
|
38
|
+
assert.equal(calls[0].body.detectedBy, 'local-agent-adapter');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('rejects a malformed event (missing key) without forwarding', async () => {
|
|
42
|
+
let fetched = false;
|
|
43
|
+
const fetchImpl = async () => { fetched = true; return { ok: true, status: 200 }; };
|
|
44
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
|
|
45
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
46
|
+
|
|
47
|
+
const bad = validEvent();
|
|
48
|
+
delete bad.source; // drop a required key
|
|
49
|
+
const outcome = fwd.handleAdapterEvent(bad, 'SapAdapter');
|
|
50
|
+
await fwd.flush();
|
|
51
|
+
|
|
52
|
+
assert.equal(outcome, 'malformed');
|
|
53
|
+
assert.equal(fetched, false);
|
|
54
|
+
assert.equal(fwd.stats.malformed, 1);
|
|
55
|
+
assert.equal(fwd.stats.forwarded, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('empty message is treated as malformed', async () => {
|
|
59
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
|
|
60
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => ({ ok: true, status: 200 }) });
|
|
61
|
+
fwd.handleAdapterEvent(validEvent({ message: ' ' }), 'SapAdapter');
|
|
62
|
+
await fwd.flush();
|
|
63
|
+
assert.equal(fwd.stats.malformed, 1);
|
|
64
|
+
assert.equal(fwd.stats.forwarded, 0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('gateway unreachable is retried then counted, never thrown', async () => {
|
|
68
|
+
let attempts = 0;
|
|
69
|
+
const fetchImpl = async () => { attempts++; throw new Error('ECONNREFUSED'); };
|
|
70
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 2 });
|
|
71
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
72
|
+
|
|
73
|
+
assert.doesNotThrow(() => fwd.handleAdapterEvent(validEvent(), 'SapAdapter'));
|
|
74
|
+
await fwd.flush();
|
|
75
|
+
|
|
76
|
+
assert.equal(attempts, 3); // 1 + 2 retries
|
|
77
|
+
assert.equal(fwd.stats.failed, 1);
|
|
78
|
+
assert.equal(fwd.stats.forwarded, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('non-2xx response is a failure', async () => {
|
|
82
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
|
|
83
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => ({ ok: false, status: 503 }) });
|
|
84
|
+
fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
|
|
85
|
+
await fwd.flush();
|
|
86
|
+
assert.equal(fwd.stats.failed, 1);
|
|
87
|
+
assert.equal(fwd.stats.forwarded, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('events received before configure() are buffered, not lost', async () => {
|
|
91
|
+
const calls = [];
|
|
92
|
+
const fetchImpl = async () => { calls.push(1); return { ok: true, status: 200 }; };
|
|
93
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0, suppressor: NO_SUPPRESS });
|
|
94
|
+
|
|
95
|
+
// Not configured yet -> should buffer.
|
|
96
|
+
fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
|
|
97
|
+
fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
|
|
98
|
+
assert.equal(calls.length, 0);
|
|
99
|
+
assert.equal(fwd.queue.length, 2);
|
|
100
|
+
|
|
101
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
102
|
+
await fwd.flush();
|
|
103
|
+
assert.equal(fwd.stats.forwarded, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('contract error (401) is NOT retried and is recorded loudly', async () => {
|
|
107
|
+
let calls = 0;
|
|
108
|
+
const fetchImpl = async () => {
|
|
109
|
+
calls++;
|
|
110
|
+
return { ok: false, status: 401, text: async () => 'unauthorized' };
|
|
111
|
+
};
|
|
112
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 3 });
|
|
113
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
114
|
+
fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
|
|
115
|
+
await fwd.flush();
|
|
116
|
+
|
|
117
|
+
assert.equal(calls, 1); // no retries on a contract error
|
|
118
|
+
assert.equal(fwd.stats.contract, 1);
|
|
119
|
+
assert.equal(fwd.stats.forwarded, 0);
|
|
120
|
+
assert.ok(fwd.contractError);
|
|
121
|
+
assert.equal(fwd.contractError.status, 401);
|
|
122
|
+
assert.equal(fwd.contractError.body, 'unauthorized');
|
|
123
|
+
assert.equal(fwd.getStatus().contract_ok, false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('403 and 404 are also contract errors (no retry)', async () => {
|
|
127
|
+
for (const status of [403, 404]) {
|
|
128
|
+
let calls = 0;
|
|
129
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 5 });
|
|
130
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => { calls++; return { ok: false, status, text: async () => '' }; } });
|
|
131
|
+
fwd.handleAdapterEvent(validEvent(), 'a');
|
|
132
|
+
await fwd.flush();
|
|
133
|
+
assert.equal(calls, 1, `status ${status} must not retry`);
|
|
134
|
+
assert.equal(fwd.stats.contract, 1);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('5xx is transport-class and IS retried', async () => {
|
|
139
|
+
let calls = 0;
|
|
140
|
+
const fetchImpl = async () => { calls++; return { ok: false, status: 503, text: async () => 'busy' }; };
|
|
141
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 2 });
|
|
142
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
143
|
+
fwd.handleAdapterEvent(validEvent(), 'a');
|
|
144
|
+
await fwd.flush();
|
|
145
|
+
assert.equal(calls, 3); // 1 + 2 retries
|
|
146
|
+
assert.equal(fwd.stats.contract, 0);
|
|
147
|
+
assert.equal(fwd.stats.failed, 1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('429 is rate limiting -> TRANSPORT class, keeps retry/backoff', async () => {
|
|
151
|
+
let calls = 0;
|
|
152
|
+
const fetchImpl = async () => { calls++; return { ok: false, status: 429, text: async () => 'slow down' }; };
|
|
153
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 2 });
|
|
154
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
155
|
+
fwd.handleAdapterEvent(validEvent(), 'a');
|
|
156
|
+
await fwd.flush();
|
|
157
|
+
assert.equal(calls, 3); // retried, not treated as a contract error
|
|
158
|
+
assert.equal(fwd.stats.contract, 0);
|
|
159
|
+
assert.equal(fwd.contractError, null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('failure metric reason is class-based (contract vs transport) with a code', async () => {
|
|
163
|
+
const seen = [];
|
|
164
|
+
const metrics = {
|
|
165
|
+
incReceived() {}, incForwarded() {}, incDropped() {},
|
|
166
|
+
incFailure: (reason, code) => seen.push({ reason, code }),
|
|
167
|
+
};
|
|
168
|
+
// contract
|
|
169
|
+
const c = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 3, metrics });
|
|
170
|
+
c.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => ({ ok: false, status: 401, text: async () => '' }) });
|
|
171
|
+
c.handleAdapterEvent(validEvent(), 'a'); await c.flush();
|
|
172
|
+
// transport
|
|
173
|
+
const t = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0, metrics });
|
|
174
|
+
t.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => { throw new Error('down'); } });
|
|
175
|
+
t.handleAdapterEvent(validEvent(), 'a'); await t.flush();
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(seen.find((s) => s.reason === 'contract'), { reason: 'contract', code: '401' });
|
|
178
|
+
assert.ok(seen.some((s) => s.reason === 'transport'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('contract preflight maps status -> verdict without creating data', async () => {
|
|
182
|
+
const cases = [
|
|
183
|
+
[400, true, 'reachable_auth_ok'],
|
|
184
|
+
[401, false, 'auth_rejected'],
|
|
185
|
+
[404, false, 'path_not_found'],
|
|
186
|
+
[503, false, 'transport_or_5xx'],
|
|
187
|
+
];
|
|
188
|
+
for (const [status, ok, reason] of cases) {
|
|
189
|
+
const sent = [];
|
|
190
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER });
|
|
191
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async (_u, o) => { sent.push(JSON.parse(o.body)); return { ok: status < 400, status, text: async () => '' }; } });
|
|
192
|
+
const r = await fwd.contractPreflight();
|
|
193
|
+
assert.equal(r.ok, ok, `status ${status}`);
|
|
194
|
+
assert.equal(r.reason, reason);
|
|
195
|
+
// The probe must NOT carry a tenantId (so it can never create an incident).
|
|
196
|
+
assert.equal(sent[0].tenantId, undefined);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('contract preflight when unreachable is not-ok and never throws', async () => {
|
|
201
|
+
const fwd = new IncidentForwarder({ logger: NOOP_LOGGER });
|
|
202
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
|
|
203
|
+
const r = await fwd.contractPreflight();
|
|
204
|
+
assert.equal(r.ok, false);
|
|
205
|
+
assert.equal(r.reason, 'unreachable');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('backpressure evicts oldest and counts the drop (no silent drop)', async () => {
|
|
209
|
+
// Gate fetch so in-flight stays busy and the queue fills past maxQueue.
|
|
210
|
+
let release;
|
|
211
|
+
const gate = new Promise((r) => { release = r; });
|
|
212
|
+
const fetchImpl = async () => { await gate; return { ok: true, status: 200 }; };
|
|
213
|
+
const fwd = new IncidentForwarder({
|
|
214
|
+
logger: NOOP_LOGGER, maxRetries: 0, maxInflight: 1, maxQueue: 2, suppressor: NO_SUPPRESS,
|
|
215
|
+
});
|
|
216
|
+
fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
|
|
217
|
+
|
|
218
|
+
fwd.handleAdapterEvent(validEvent(), 'a'); // -> in-flight (gated)
|
|
219
|
+
fwd.handleAdapterEvent(validEvent(), 'a'); // -> queue [1]
|
|
220
|
+
fwd.handleAdapterEvent(validEvent(), 'a'); // -> queue [1,2] (== maxQueue)
|
|
221
|
+
const outcome = fwd.handleAdapterEvent(validEvent(), 'a'); // -> evict oldest
|
|
222
|
+
|
|
223
|
+
assert.equal(outcome, 'dropped');
|
|
224
|
+
assert.equal(fwd.stats.dropped, 1);
|
|
225
|
+
|
|
226
|
+
release();
|
|
227
|
+
await fwd.flush();
|
|
228
|
+
// 1 in-flight + 2 remaining in queue were forwarded; 1 was dropped.
|
|
229
|
+
assert.equal(fwd.stats.forwarded, 3);
|
|
230
|
+
assert.equal(fwd.stats.dropped, 1);
|
|
231
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// test/multi-repo-open.test.js
|
|
2
|
+
// MULTI-REPO OPEN partial tolerance (QA 2026-09-22): POST /workspace/open
|
|
3
|
+
// with repos[] used ONE try/catch around a sequential clone loop, so the
|
|
4
|
+
// first failed clone ("https://github.com/williambella/visionhammer-vault.git
|
|
5
|
+
// -> Repository not found") aborted the loop and answered 500, discarding the
|
|
6
|
+
// 9 repos already cloned. The hydration is now per-repo tolerant: >=1 cloned
|
|
7
|
+
// -> 200 {partial:true, failed:[...masked]}; ALL failed -> 500;
|
|
8
|
+
// MULTI_REPO_ALLOW_PARTIAL=false -> first failure aborts (old behavior).
|
|
9
|
+
|
|
10
|
+
import { test, describe } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
hydrateRepos,
|
|
15
|
+
multiRepoAllowPartial,
|
|
16
|
+
planRepos,
|
|
17
|
+
repoDirName,
|
|
18
|
+
} from '../src/utils/multi-repo-open.js';
|
|
19
|
+
import { confineToRoot } from '../src/utils/sandbox.js';
|
|
20
|
+
|
|
21
|
+
const WS = '/tmp/workspaces/ws-multi-qa';
|
|
22
|
+
const TOKEN = 'ghp_SECRET_TOKEN_123';
|
|
23
|
+
const BAD_REPO = 'https://github.com/williambella/visionhammer-vault.git';
|
|
24
|
+
const GOOD_REPOS = [
|
|
25
|
+
'https://github.com/VISIONHAMMER/deepdebug-gateway.git',
|
|
26
|
+
'https://github.com/VISIONHAMMER/visionhammer-agents.git',
|
|
27
|
+
'https://github.com/VISIONHAMMER/deepdebug-local-agent.git',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const maskUrlCreds = (s) => String(s).replace(/(https?:\/\/)[^\s@]+@/gi, '$1***@');
|
|
31
|
+
const quiet = { log() {}, warn() {} };
|
|
32
|
+
|
|
33
|
+
/** Fake git: fails for repo URLs in `failing` with the QA-shaped message. */
|
|
34
|
+
function fakeCloneRepo(failing) {
|
|
35
|
+
const calls = [];
|
|
36
|
+
const cloneRepo = async ({ repoUrl, token, destDir }) => {
|
|
37
|
+
calls.push({ repoUrl, destDir });
|
|
38
|
+
if (failing.has(repoUrl)) {
|
|
39
|
+
const authed = repoUrl.replace('https://', `https://x-access-token:${token}@`);
|
|
40
|
+
throw new Error(`Command failed: git clone --branch main --single-branch --depth 50 "${authed}" "${destDir}"\nremote: Repository not found.\nfatal: repository '${authed}' not found`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
return { cloneRepo, calls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deps(overrides = {}) {
|
|
47
|
+
return {
|
|
48
|
+
detectProject: async () => ({ language: 'java', buildTool: 'maven' }),
|
|
49
|
+
mkdir: async () => {},
|
|
50
|
+
maskUrlCreds,
|
|
51
|
+
token: TOKEN,
|
|
52
|
+
branch: 'main',
|
|
53
|
+
allowPartial: true,
|
|
54
|
+
wsId: 'ws-multi-qa',
|
|
55
|
+
logger: quiet,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('multiRepoAllowPartial (env flag)', () => {
|
|
61
|
+
test('defaults ON; only the literal "false" turns it off', () => {
|
|
62
|
+
assert.equal(multiRepoAllowPartial({}), true);
|
|
63
|
+
assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: 'true' }), true);
|
|
64
|
+
assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: '' }), true);
|
|
65
|
+
assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: 'false' }), false);
|
|
66
|
+
assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: ' FALSE ' }), false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('planRepos (validation before any clone)', () => {
|
|
71
|
+
test('resolves per-repo dirs inside the workspace dir', () => {
|
|
72
|
+
const plan = planRepos(
|
|
73
|
+
[{ repoUrl: GOOD_REPOS[0] }, { repoUrl: GOOD_REPOS[1], dir: 'agents' }],
|
|
74
|
+
{ workspaceDir: WS, confineToRoot },
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(plan.map((p) => p.dir), ['VISIONHAMMER_deepdebug-gateway', 'agents']);
|
|
77
|
+
// confineToRoot realpaths the base (macOS: /tmp -> /private/tmp); compare the tail
|
|
78
|
+
assert.ok(plan[1].destDir.endsWith(path.join('ws-multi-qa', 'agents')), plan[1].destDir);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('missing repoUrl / escaping dir -> tagged 400, nothing planned', () => {
|
|
82
|
+
assert.throws(() => planRepos([{ repoUrl: GOOD_REPOS[0] }, {}], { workspaceDir: WS, confineToRoot }),
|
|
83
|
+
(e) => e.code === 'BAD_REQUEST' && e.httpStatus === 400);
|
|
84
|
+
assert.throws(() => planRepos([{ repoUrl: GOOD_REPOS[0], dir: '../escape' }], { workspaceDir: WS, confineToRoot: () => { throw new Error('escape'); } }),
|
|
85
|
+
(e) => e.code === 'BAD_REQUEST' && /invalid repo dir/.test(e.message));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('repoDirName: org_repo, .git stripped', () => {
|
|
89
|
+
assert.equal(repoDirName(BAD_REPO), 'williambella_visionhammer-vault');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('hydrateRepos - partial tolerance', () => {
|
|
94
|
+
test('QA failure mode: ONE bad repo among good ones -> the good ones survive, failed[] is masked', async () => {
|
|
95
|
+
const repos = [...GOOD_REPOS.slice(0, 2), BAD_REPO, GOOD_REPOS[2]].map((repoUrl) => ({ repoUrl }));
|
|
96
|
+
const plan = planRepos(repos, { workspaceDir: WS, confineToRoot });
|
|
97
|
+
const { cloneRepo, calls } = fakeCloneRepo(new Set([BAD_REPO]));
|
|
98
|
+
|
|
99
|
+
const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
|
|
100
|
+
|
|
101
|
+
assert.equal(calls.length, 4, 'the loop must NOT stop at the failing repo');
|
|
102
|
+
assert.equal(cloned.length, 3);
|
|
103
|
+
assert.equal(failed.length, 1);
|
|
104
|
+
assert.equal(failed[0].dir, 'williambella_visionhammer-vault');
|
|
105
|
+
assert.equal(failed[0].repoUrl, BAD_REPO);
|
|
106
|
+
assert.match(failed[0].error, /Repository not found/);
|
|
107
|
+
// SEG-1: the token never leaks through the response/log shape
|
|
108
|
+
assert.doesNotMatch(JSON.stringify({ cloned, failed }), new RegExp(TOKEN));
|
|
109
|
+
assert.match(failed[0].error, /x-access-token:\*\*\*@|\/\/\*\*\*@/);
|
|
110
|
+
assert.ok(firstError instanceof Error);
|
|
111
|
+
// the server maps this to 200 {ok:true, partial:true}
|
|
112
|
+
assert.equal(cloned.length > 0 && failed.length > 0, true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('ALL repos bad -> nothing cloned, every failure listed (server answers 500)', async () => {
|
|
116
|
+
const plan = planRepos([{ repoUrl: BAD_REPO }, { repoUrl: GOOD_REPOS[0] }], { workspaceDir: WS, confineToRoot });
|
|
117
|
+
const { cloneRepo } = fakeCloneRepo(new Set([BAD_REPO, GOOD_REPOS[0]]));
|
|
118
|
+
|
|
119
|
+
const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
|
|
120
|
+
|
|
121
|
+
assert.equal(cloned.length, 0);
|
|
122
|
+
assert.equal(failed.length, 2);
|
|
123
|
+
assert.match(firstError.message, /visionhammer-vault/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('MULTI_REPO_ALLOW_PARTIAL=false -> first failure aborts the loop (pre-fix behavior)', async () => {
|
|
127
|
+
const repos = [GOOD_REPOS[0], BAD_REPO, GOOD_REPOS[1]].map((repoUrl) => ({ repoUrl }));
|
|
128
|
+
const plan = planRepos(repos, { workspaceDir: WS, confineToRoot });
|
|
129
|
+
const { cloneRepo, calls } = fakeCloneRepo(new Set([BAD_REPO]));
|
|
130
|
+
|
|
131
|
+
await assert.rejects(
|
|
132
|
+
hydrateRepos(plan, deps({ cloneRepo, allowPartial: multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: 'false' }) })),
|
|
133
|
+
/Repository not found/,
|
|
134
|
+
);
|
|
135
|
+
assert.equal(calls.length, 2, 'third repo never attempted');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('all good -> partial:false shape (failed empty, no firstError)', async () => {
|
|
139
|
+
const plan = planRepos(GOOD_REPOS.map((repoUrl) => ({ repoUrl })), { workspaceDir: WS, confineToRoot });
|
|
140
|
+
const { cloneRepo } = fakeCloneRepo(new Set());
|
|
141
|
+
const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
|
|
142
|
+
assert.equal(cloned.length, 3);
|
|
143
|
+
assert.deepEqual(failed, []);
|
|
144
|
+
assert.equal(firstError, null);
|
|
145
|
+
assert.deepEqual(cloned.map((c) => c.repoUrl), GOOD_REPOS);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('per-repo token/branch override the workspace-level ones; tagged git errors keep their code', async () => {
|
|
149
|
+
const seen = [];
|
|
150
|
+
const cloneRepo = async (args) => {
|
|
151
|
+
seen.push(args);
|
|
152
|
+
if (args.branch === 'nope') { const e = new Error('branch nope not found'); e.code = 'BRANCH_NOT_FOUND'; e.httpStatus = 404; throw e; }
|
|
153
|
+
};
|
|
154
|
+
const plan = planRepos([
|
|
155
|
+
{ repoUrl: GOOD_REPOS[0], token: 'per-repo-token', branch: 'develop' },
|
|
156
|
+
{ repoUrl: GOOD_REPOS[1], branch: 'nope' },
|
|
157
|
+
], { workspaceDir: WS, confineToRoot });
|
|
158
|
+
const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
|
|
159
|
+
assert.equal(seen[0].token, 'per-repo-token');
|
|
160
|
+
assert.equal(seen[0].branch, 'develop');
|
|
161
|
+
assert.equal(seen[1].token, TOKEN);
|
|
162
|
+
assert.equal(cloned.length, 1);
|
|
163
|
+
assert.equal(failed.length, 1);
|
|
164
|
+
assert.equal(firstError.code, 'BRANCH_NOT_FOUND');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the partial log line names counts and masked reasons', async () => {
|
|
168
|
+
const lines = [];
|
|
169
|
+
const logger = { log() {}, warn: (m) => lines.push(m) };
|
|
170
|
+
const plan = planRepos([{ repoUrl: GOOD_REPOS[0] }, { repoUrl: BAD_REPO }], { workspaceDir: WS, confineToRoot });
|
|
171
|
+
const { cloneRepo } = fakeCloneRepo(new Set([BAD_REPO]));
|
|
172
|
+
const cleaned = [];
|
|
173
|
+
await hydrateRepos(plan, deps({ cloneRepo, logger, cleanupFailed: async (d) => { cleaned.push(d); } }));
|
|
174
|
+
const partial = lines.find((l) => l.startsWith('[multi-repo] partial:'));
|
|
175
|
+
assert.match(partial, /1 cloned, 1 failed/);
|
|
176
|
+
assert.deepEqual(cleaned, [path.join(plan[1].destDir)], 'failed per-repo dir is cleaned up');
|
|
177
|
+
assert.match(partial, /williambella_visionhammer-vault \(.*Repository not found/s);
|
|
178
|
+
assert.doesNotMatch(lines.join('\n'), new RegExp(TOKEN));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
multiRepoPrEnabled,
|
|
9
|
+
listRepoSubdirs,
|
|
10
|
+
isMultiRepoRoot,
|
|
11
|
+
parseAuthedRemote,
|
|
12
|
+
stripRemoteCreds,
|
|
13
|
+
buildCompareUrl,
|
|
14
|
+
buildCrossLinkNote,
|
|
15
|
+
buildMultiRepoPrBody,
|
|
16
|
+
} from "../src/git/multi-repo-pr.js";
|
|
17
|
+
|
|
18
|
+
function mkTmp() {
|
|
19
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "mrpr-"));
|
|
20
|
+
}
|
|
21
|
+
function mkRepo(root, name) {
|
|
22
|
+
const p = path.join(root, name);
|
|
23
|
+
fs.mkdirSync(path.join(p, ".git"), { recursive: true });
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("multiRepoPrEnabled defaults on, false disables", () => {
|
|
28
|
+
assert.equal(multiRepoPrEnabled({}), true);
|
|
29
|
+
assert.equal(multiRepoPrEnabled({ MULTI_REPO_PR_ENABLED: "true" }), true);
|
|
30
|
+
assert.equal(multiRepoPrEnabled({ MULTI_REPO_PR_ENABLED: "false" }), false);
|
|
31
|
+
assert.equal(multiRepoPrEnabled({ MULTI_REPO_PR_ENABLED: "FALSE" }), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("listRepoSubdirs finds only git sub-dirs, sorted", () => {
|
|
35
|
+
const root = mkTmp();
|
|
36
|
+
mkRepo(root, "beta");
|
|
37
|
+
mkRepo(root, "alpha");
|
|
38
|
+
fs.mkdirSync(path.join(root, "not-a-repo"), { recursive: true });
|
|
39
|
+
const dirs = listRepoSubdirs(root).map((r) => r.dir);
|
|
40
|
+
assert.deepEqual(dirs, ["alpha", "beta"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("isMultiRepoRoot: parent with git sub-repos = true", () => {
|
|
44
|
+
const root = mkTmp();
|
|
45
|
+
mkRepo(root, "svc-a");
|
|
46
|
+
mkRepo(root, "svc-b");
|
|
47
|
+
assert.equal(isMultiRepoRoot(root), true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("isMultiRepoRoot: single-repo root (root itself a git repo) = false", () => {
|
|
51
|
+
const root = mkTmp();
|
|
52
|
+
fs.mkdirSync(path.join(root, ".git"), { recursive: true });
|
|
53
|
+
mkRepo(root, "nested"); // even with a nested repo, root being a repo wins
|
|
54
|
+
assert.equal(isMultiRepoRoot(root), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("isMultiRepoRoot: empty/plain dir = false", () => {
|
|
58
|
+
const root = mkTmp();
|
|
59
|
+
fs.mkdirSync(path.join(root, "plain"), { recursive: true });
|
|
60
|
+
assert.equal(isMultiRepoRoot(root), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parseAuthedRemote: GitHub x-access-token", () => {
|
|
64
|
+
const r = parseAuthedRemote("https://x-access-token:ghp_ABC@github.com/acme/web.git");
|
|
65
|
+
assert.equal(r.provider, "github");
|
|
66
|
+
assert.equal(r.host, "github.com");
|
|
67
|
+
assert.equal(r.ownerRepo, "acme/web");
|
|
68
|
+
assert.equal(r.apiToken, "ghp_ABC");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("parseAuthedRemote: GitLab oauth2 + no .git suffix", () => {
|
|
72
|
+
const r = parseAuthedRemote("https://oauth2:glpat_X@gitlab.com/group/sub/repo");
|
|
73
|
+
assert.equal(r.provider, "gitlab");
|
|
74
|
+
assert.equal(r.ownerRepo, "group/sub/repo");
|
|
75
|
+
assert.equal(r.apiToken, "glpat_X");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("parseAuthedRemote: no credentials", () => {
|
|
79
|
+
const r = parseAuthedRemote("https://github.com/acme/web");
|
|
80
|
+
assert.equal(r.provider, "github");
|
|
81
|
+
assert.equal(r.ownerRepo, "acme/web");
|
|
82
|
+
assert.equal(r.apiToken, "");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("stripRemoteCreds removes userinfo and .git", () => {
|
|
86
|
+
assert.equal(
|
|
87
|
+
stripRemoteCreds("https://x-access-token:TOK@github.com/acme/web.git"),
|
|
88
|
+
"https://github.com/acme/web"
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("buildCompareUrl per provider", () => {
|
|
93
|
+
assert.match(buildCompareUrl("https://github.com/a/b", "github", "vh/x", "main"),
|
|
94
|
+
/\/compare\/main\.\.\.vh\/x\?expand=1$/);
|
|
95
|
+
assert.match(buildCompareUrl("https://gitlab.com/a/b", "gitlab", "vh/x", "main"),
|
|
96
|
+
/merge_requests\/new/);
|
|
97
|
+
assert.match(buildCompareUrl("https://bitbucket.org/a/b", "bitbucket", "vh/x", "main"),
|
|
98
|
+
/pull-requests\/new/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("buildCrossLinkNote links only siblings", () => {
|
|
102
|
+
const prs = [
|
|
103
|
+
{ dir: "web", prUrl: "https://github.com/a/web/pull/1" },
|
|
104
|
+
{ dir: "api", prUrl: "https://github.com/a/api/pull/2" },
|
|
105
|
+
{ dir: "libs", prUrl: "" }, // no url -> excluded
|
|
106
|
+
];
|
|
107
|
+
const note = buildCrossLinkNote("web", prs);
|
|
108
|
+
assert.ok(note.includes("api: https://github.com/a/api/pull/2"));
|
|
109
|
+
assert.ok(!note.includes("web/pull/1")); // not self
|
|
110
|
+
assert.ok(!note.includes("libs:"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("buildCrossLinkNote: no siblings -> empty", () => {
|
|
114
|
+
assert.equal(buildCrossLinkNote("only", [{ dir: "only", prUrl: "u" }]), "");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("buildMultiRepoPrBody is ASCII and carries dir/branch + trailer", () => {
|
|
118
|
+
const body = buildMultiRepoPrBody({
|
|
119
|
+
dir: "web", branch: "vh/abc123", commitMessage: "feat: x",
|
|
120
|
+
filesChanged: 3, commitSha: "deadbeefcafe",
|
|
121
|
+
});
|
|
122
|
+
assert.ok(body.includes("`web`"));
|
|
123
|
+
assert.ok(body.includes("vh/abc123"));
|
|
124
|
+
assert.ok(body.includes("deadbeef"));
|
|
125
|
+
assert.ok(body.includes("Generated by VisionHammer Agent"));
|
|
126
|
+
assert.ok(!/claude/i.test(body));
|
|
127
|
+
// ASCII-only guard
|
|
128
|
+
assert.ok(/^[\x00-\x7F]*$/.test(body), "PR body must be ASCII-only");
|
|
129
|
+
});
|