opencode-gitlab-duo-agentic 0.1.1 → 0.1.3
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/README.md +31 -17
- package/dist/index.js +518 -202
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# opencode-gitlab-duo-agentic
|
|
2
2
|
|
|
3
|
-
OpenCode plugin
|
|
3
|
+
OpenCode plugin for GitLab Duo Agentic. It registers the provider, discovers models from the GitLab API, and exposes file-reading tools.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
1. Export your GitLab token:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
export GITLAB_TOKEN=glpat-...
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2. Add the plugin to `opencode.json`:
|
|
8
14
|
|
|
9
15
|
```json
|
|
10
16
|
{
|
|
@@ -13,32 +19,40 @@ Add the plugin to your OpenCode config:
|
|
|
13
19
|
}
|
|
14
20
|
```
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
3. Run `opencode`. The provider, models, and tools are registered automatically.
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
`GITLAB_INSTANCE_URL` defaults to `https://gitlab.com`. Set it only for self-managed GitLab.
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```
|
|
26
|
+
## Provider options
|
|
27
|
+
|
|
28
|
+
Override defaults in `opencode.json` under `provider.gitlab-duo-agentic.options`.
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
| Option | Type | Default | Description |
|
|
31
|
+
|--------|------|---------|-------------|
|
|
32
|
+
| `instanceUrl` | string | `GITLAB_INSTANCE_URL` or `https://gitlab.com` | GitLab instance URL |
|
|
33
|
+
| `apiKey` | string | `GITLAB_TOKEN` | Personal access token |
|
|
34
|
+
| `sendSystemContext` | boolean | `true` | Send system context to Duo |
|
|
35
|
+
| `enableMcp` | boolean | `true` | Enable MCP tools |
|
|
36
|
+
| `systemRules` | string | `""` | Inline system rules |
|
|
37
|
+
| `systemRulesPath` | string | `""` | Path to a system rules file |
|
|
26
38
|
|
|
27
|
-
##
|
|
39
|
+
## Model discovery
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
Models are discovered in this order:
|
|
30
42
|
|
|
31
|
-
1.
|
|
32
|
-
2.
|
|
33
|
-
3.
|
|
43
|
+
1. Local cache (TTL: 24h)
|
|
44
|
+
2. Live fetch from GitLab GraphQL API
|
|
45
|
+
3. Stale cache (if live fetch fails)
|
|
46
|
+
4. `models.json` on disk
|
|
47
|
+
5. Default `duo-agentic` model
|
|
34
48
|
|
|
35
|
-
|
|
49
|
+
Cache is stored in `~/.cache/opencode/` (or `XDG_CACHE_HOME`). Override TTL with `GITLAB_DUO_MODELS_CACHE_TTL` (seconds).
|
|
36
50
|
|
|
37
51
|
## Development
|
|
38
52
|
|
|
39
53
|
```bash
|
|
40
54
|
npm install
|
|
41
|
-
npm run typecheck
|
|
42
55
|
npm run build
|
|
56
|
+
npm run typecheck
|
|
43
57
|
npm run pack:check
|
|
44
58
|
```
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/plugin/config.ts
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import path4 from "path";
|
|
3
|
+
import fs4 from "fs";
|
|
4
4
|
|
|
5
5
|
// src/plugin/models.ts
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import path3 from "path";
|
|
7
|
+
import fs3 from "fs";
|
|
8
8
|
|
|
9
9
|
// src/shared/model_entry.ts
|
|
10
10
|
function buildModelEntry(name) {
|
|
@@ -28,12 +28,435 @@ var GITLAB_DUO_DEFAULT_MODEL_NAME = "Duo Agentic";
|
|
|
28
28
|
var GITLAB_DUO_PLUGIN_PACKAGE_NAME = "opencode-gitlab-duo-agentic";
|
|
29
29
|
var GITLAB_DUO_PROVIDER_NPM_ENTRY = GITLAB_DUO_PLUGIN_PACKAGE_NAME;
|
|
30
30
|
|
|
31
|
+
// src/plugin/fetch_models.ts
|
|
32
|
+
import crypto from "crypto";
|
|
33
|
+
import os from "os";
|
|
34
|
+
import path2 from "path";
|
|
35
|
+
import fs2 from "fs/promises";
|
|
36
|
+
|
|
37
|
+
// src/provider/adapters/gitlab_utils.ts
|
|
38
|
+
import fs from "fs/promises";
|
|
39
|
+
import path from "path";
|
|
40
|
+
async function detectProjectPath(cwd, instanceUrl) {
|
|
41
|
+
let current = cwd;
|
|
42
|
+
const instance = new URL(instanceUrl);
|
|
43
|
+
const instanceHost = instance.host;
|
|
44
|
+
const instanceBasePath = instance.pathname.replace(/\/$/, "");
|
|
45
|
+
while (true) {
|
|
46
|
+
try {
|
|
47
|
+
const config = await readGitConfig(current);
|
|
48
|
+
const url = extractGitRemoteUrl(config) || "";
|
|
49
|
+
const remote = parseRemote(url);
|
|
50
|
+
if (!remote) {
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
if (remote.host !== instanceHost) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`GitLab remote host mismatch. Expected ${instanceHost}, got ${remote.host}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return normalizeProjectPath(remote.path, instanceBasePath);
|
|
59
|
+
} catch {
|
|
60
|
+
const parent = path.dirname(current);
|
|
61
|
+
if (parent === current) return void 0;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function extractGitRemoteUrl(config) {
|
|
67
|
+
const lines = config.split("\n");
|
|
68
|
+
let inOrigin = false;
|
|
69
|
+
let originUrl;
|
|
70
|
+
let firstUrl;
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
const sectionMatch = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
|
|
74
|
+
if (sectionMatch) {
|
|
75
|
+
inOrigin = sectionMatch[1] === "origin";
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
|
|
79
|
+
if (urlMatch) {
|
|
80
|
+
const value = urlMatch[1].trim();
|
|
81
|
+
if (!firstUrl) firstUrl = value;
|
|
82
|
+
if (inOrigin) originUrl = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return originUrl ?? firstUrl;
|
|
86
|
+
}
|
|
87
|
+
function parseRemote(remoteUrl) {
|
|
88
|
+
if (!remoteUrl) return void 0;
|
|
89
|
+
if (remoteUrl.startsWith("http")) {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(remoteUrl);
|
|
92
|
+
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
93
|
+
} catch {
|
|
94
|
+
return void 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (remoteUrl.startsWith("git@")) {
|
|
98
|
+
const match = /^git@([^:]+):(.+)$/.exec(remoteUrl);
|
|
99
|
+
if (!match) return void 0;
|
|
100
|
+
return { host: match[1], path: match[2] };
|
|
101
|
+
}
|
|
102
|
+
if (remoteUrl.startsWith("ssh://")) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(remoteUrl);
|
|
105
|
+
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
106
|
+
} catch {
|
|
107
|
+
return void 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
function normalizeProjectPath(remotePath, instanceBasePath) {
|
|
113
|
+
let pathValue = remotePath;
|
|
114
|
+
if (instanceBasePath && instanceBasePath !== "/") {
|
|
115
|
+
const base = instanceBasePath.replace(/^\//, "") + "/";
|
|
116
|
+
if (pathValue.startsWith(base)) {
|
|
117
|
+
pathValue = pathValue.slice(base.length);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const cleaned = stripGitSuffix(pathValue);
|
|
121
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
122
|
+
}
|
|
123
|
+
function stripGitSuffix(pathname) {
|
|
124
|
+
return pathname.endsWith(".git") ? pathname.slice(0, -4) : pathname;
|
|
125
|
+
}
|
|
126
|
+
function buildApiUrl(instanceUrl, apiPath) {
|
|
127
|
+
const base = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`;
|
|
128
|
+
return new URL(apiPath.replace(/^\//, ""), base).toString();
|
|
129
|
+
}
|
|
130
|
+
function buildAuthHeaders(apiKey) {
|
|
131
|
+
return { authorization: `Bearer ${apiKey}` };
|
|
132
|
+
}
|
|
133
|
+
async function fetchProjectDetails(instanceUrl, apiKey, projectPath) {
|
|
134
|
+
const url = buildApiUrl(instanceUrl, `api/v4/projects/${encodeURIComponent(projectPath)}`);
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
headers: buildAuthHeaders(apiKey)
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`Failed to fetch project details: ${response.status}`);
|
|
140
|
+
}
|
|
141
|
+
const data = await response.json();
|
|
142
|
+
return {
|
|
143
|
+
projectId: data.id ? String(data.id) : void 0,
|
|
144
|
+
namespaceId: data.namespace?.id ? String(data.namespace.id) : void 0
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async function fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath) {
|
|
148
|
+
const candidates = getProjectPathCandidates(projectPath);
|
|
149
|
+
for (const candidate of candidates) {
|
|
150
|
+
try {
|
|
151
|
+
return await fetchProjectDetails(instanceUrl, apiKey, candidate);
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const name = projectPath.split("/").pop() || projectPath;
|
|
158
|
+
const searchUrl = new URL(buildApiUrl(instanceUrl, "api/v4/projects"));
|
|
159
|
+
searchUrl.searchParams.set("search", name);
|
|
160
|
+
searchUrl.searchParams.set("simple", "true");
|
|
161
|
+
searchUrl.searchParams.set("per_page", "100");
|
|
162
|
+
searchUrl.searchParams.set("membership", "true");
|
|
163
|
+
const response = await fetch(searchUrl.toString(), {
|
|
164
|
+
headers: buildAuthHeaders(apiKey)
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Failed to search projects: ${response.status}`);
|
|
168
|
+
}
|
|
169
|
+
const data = await response.json();
|
|
170
|
+
const match = data.find((project) => project.path_with_namespace === projectPath);
|
|
171
|
+
if (!match) {
|
|
172
|
+
throw new Error("Project not found via search");
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
projectId: match.id ? String(match.id) : void 0,
|
|
176
|
+
namespaceId: match.namespace?.id ? String(match.namespace.id) : void 0
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error("Project not found via API");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function getProjectPathCandidates(projectPath) {
|
|
183
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
184
|
+
candidates.add(projectPath);
|
|
185
|
+
const parts = projectPath.split("/");
|
|
186
|
+
if (parts.length > 2) {
|
|
187
|
+
const withoutFirst = parts.slice(1).join("/");
|
|
188
|
+
candidates.add(withoutFirst);
|
|
189
|
+
}
|
|
190
|
+
return Array.from(candidates);
|
|
191
|
+
}
|
|
192
|
+
async function readGitConfig(cwd) {
|
|
193
|
+
const gitPath = path.join(cwd, ".git");
|
|
194
|
+
const stat = await fs.stat(gitPath);
|
|
195
|
+
if (stat.isDirectory()) {
|
|
196
|
+
return fs.readFile(path.join(gitPath, "config"), "utf8");
|
|
197
|
+
}
|
|
198
|
+
const file = await fs.readFile(gitPath, "utf8");
|
|
199
|
+
const match = /^gitdir:\s*(.+)$/m.exec(file);
|
|
200
|
+
if (!match) throw new Error("Invalid .git file");
|
|
201
|
+
const gitdir = match[1].trim();
|
|
202
|
+
const resolved = path.isAbsolute(gitdir) ? gitdir : path.join(cwd, gitdir);
|
|
203
|
+
return fs.readFile(path.join(resolved, "config"), "utf8");
|
|
204
|
+
}
|
|
205
|
+
async function resolveRootNamespaceId(instanceUrl, apiKey, namespaceId) {
|
|
206
|
+
let currentId = namespaceId;
|
|
207
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
208
|
+
const url = buildApiUrl(instanceUrl, `api/v4/namespaces/${currentId}`);
|
|
209
|
+
const response = await fetch(url, {
|
|
210
|
+
headers: buildAuthHeaders(apiKey)
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const data = await response.json();
|
|
216
|
+
if (!data.parent_id) {
|
|
217
|
+
currentId = String(data.id ?? currentId);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
currentId = String(data.parent_id);
|
|
221
|
+
}
|
|
222
|
+
return `gid://gitlab/Group/${currentId}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/plugin/fetch_models.ts
|
|
226
|
+
var DEFAULT_MODELS_CACHE_TTL_SECONDS = 60 * 60 * 24;
|
|
227
|
+
var AVAILABLE_MODELS_QUERY = `query lsp_aiChatAvailableModels($rootNamespaceId: GroupID!) {
|
|
228
|
+
metadata {
|
|
229
|
+
featureFlags(names: ["ai_user_model_switching"]) {
|
|
230
|
+
enabled
|
|
231
|
+
name
|
|
232
|
+
}
|
|
233
|
+
version
|
|
234
|
+
}
|
|
235
|
+
aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
|
|
236
|
+
defaultModel { name ref }
|
|
237
|
+
selectableModels { name ref }
|
|
238
|
+
pinnedModel { name ref }
|
|
239
|
+
}
|
|
240
|
+
}`;
|
|
241
|
+
function resolveModelsCacheTtlSeconds() {
|
|
242
|
+
const raw = process.env.GITLAB_DUO_MODELS_CACHE_TTL;
|
|
243
|
+
if (!raw) return DEFAULT_MODELS_CACHE_TTL_SECONDS;
|
|
244
|
+
const parsed = Number.parseInt(raw, 10);
|
|
245
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
246
|
+
return DEFAULT_MODELS_CACHE_TTL_SECONDS;
|
|
247
|
+
}
|
|
248
|
+
return parsed;
|
|
249
|
+
}
|
|
250
|
+
async function loadModelsFromCache(options) {
|
|
251
|
+
const instanceUrl = normalizeInstanceUrl(options.instanceUrl);
|
|
252
|
+
const cwd = options.cwd ?? process.cwd();
|
|
253
|
+
const ttlSeconds = options.ttlSeconds ?? resolveModelsCacheTtlSeconds();
|
|
254
|
+
const allowStale = options.allowStale ?? false;
|
|
255
|
+
const { cachePath, projectPath } = await resolveCacheLocation(instanceUrl, cwd);
|
|
256
|
+
let raw;
|
|
257
|
+
try {
|
|
258
|
+
raw = await fs2.readFile(cachePath, "utf8");
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const fsError = error;
|
|
261
|
+
if (fsError?.code === "ENOENT") return null;
|
|
262
|
+
console.warn(
|
|
263
|
+
`[gitlab-duo] Failed to read models cache at ${cachePath}: ${fsError?.message ?? String(error)}`
|
|
264
|
+
);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(raw);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.warn(
|
|
272
|
+
`[gitlab-duo] Failed to parse models cache at ${cachePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
273
|
+
);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const models = parsed.payload?.models;
|
|
277
|
+
if (!models || Object.keys(models).length === 0) return null;
|
|
278
|
+
const cachedAtMs = Date.parse(parsed.cachedAt);
|
|
279
|
+
const ageSeconds = Number.isFinite(cachedAtMs) ? Math.max(0, (Date.now() - cachedAtMs) / 1e3) : Infinity;
|
|
280
|
+
const stale = ageSeconds > ttlSeconds;
|
|
281
|
+
if (stale && !allowStale) return null;
|
|
282
|
+
return {
|
|
283
|
+
payload: parsed.payload,
|
|
284
|
+
stale,
|
|
285
|
+
cachePath,
|
|
286
|
+
projectPath
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function fetchAndCacheModels(options) {
|
|
290
|
+
const instanceUrl = normalizeInstanceUrl(options.instanceUrl);
|
|
291
|
+
const apiKey = options.apiKey.trim();
|
|
292
|
+
const cwd = options.cwd ?? process.cwd();
|
|
293
|
+
if (!apiKey) {
|
|
294
|
+
throw new Error("GITLAB_TOKEN is required for live model discovery");
|
|
295
|
+
}
|
|
296
|
+
const projectPath = await resolveProjectPath(cwd, instanceUrl);
|
|
297
|
+
if (!projectPath) {
|
|
298
|
+
throw new Error("Could not detect GitLab project from git remote");
|
|
299
|
+
}
|
|
300
|
+
const payload = await fetchModels(instanceUrl, apiKey, projectPath);
|
|
301
|
+
const { cachePath } = await resolveCacheLocation(instanceUrl, cwd, projectPath);
|
|
302
|
+
await writeCache(cachePath, payload);
|
|
303
|
+
return {
|
|
304
|
+
payload,
|
|
305
|
+
stale: false,
|
|
306
|
+
cachePath,
|
|
307
|
+
projectPath
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async function fetchModels(instanceUrl, apiKey, projectPath) {
|
|
311
|
+
const details = await fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath);
|
|
312
|
+
const namespaceId = details.namespaceId;
|
|
313
|
+
if (!namespaceId) {
|
|
314
|
+
throw new Error("Could not determine namespace ID from project");
|
|
315
|
+
}
|
|
316
|
+
const rootNamespaceId = await resolveRootNamespaceId(instanceUrl, apiKey, namespaceId);
|
|
317
|
+
const graphqlUrl = `${instanceUrl}/api/graphql`;
|
|
318
|
+
const response = await fetch(graphqlUrl, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: {
|
|
321
|
+
"content-type": "application/json",
|
|
322
|
+
authorization: `Bearer ${apiKey}`
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
query: AVAILABLE_MODELS_QUERY,
|
|
326
|
+
variables: { rootNamespaceId }
|
|
327
|
+
})
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const text = await response.text();
|
|
331
|
+
throw new Error(`GraphQL request failed (${response.status}): ${text}`);
|
|
332
|
+
}
|
|
333
|
+
const result = await response.json();
|
|
334
|
+
if (result.errors?.length) {
|
|
335
|
+
throw new Error(result.errors.map((error) => error.message).join("; "));
|
|
336
|
+
}
|
|
337
|
+
const metadata = result.data?.metadata;
|
|
338
|
+
const available = result.data?.aiChatAvailableModels;
|
|
339
|
+
const defaultModel = available?.defaultModel ?? null;
|
|
340
|
+
const pinnedModel = available?.pinnedModel ?? null;
|
|
341
|
+
const selectableModels = available?.selectableModels ?? [];
|
|
342
|
+
const featureFlags = {};
|
|
343
|
+
for (const flag of metadata?.featureFlags ?? []) {
|
|
344
|
+
featureFlags[flag.name] = flag.enabled;
|
|
345
|
+
}
|
|
346
|
+
const models = {};
|
|
347
|
+
if (defaultModel?.ref) {
|
|
348
|
+
models[defaultModel.ref] = buildModelEntry(defaultModel.name || defaultModel.ref);
|
|
349
|
+
}
|
|
350
|
+
if (pinnedModel?.ref) {
|
|
351
|
+
models[pinnedModel.ref] = buildModelEntry(pinnedModel.name || pinnedModel.ref);
|
|
352
|
+
}
|
|
353
|
+
for (const model of selectableModels) {
|
|
354
|
+
if (model.ref && !models[model.ref]) {
|
|
355
|
+
models[model.ref] = buildModelEntry(model.name || model.ref);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (Object.keys(models).length === 0) {
|
|
359
|
+
models[GITLAB_DUO_DEFAULT_MODEL_ID] = buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
metadata: {
|
|
363
|
+
instanceUrl,
|
|
364
|
+
rootNamespaceId,
|
|
365
|
+
gitlabVersion: metadata?.version ?? null,
|
|
366
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
367
|
+
featureFlags,
|
|
368
|
+
defaultModel: defaultModel?.ref ?? null,
|
|
369
|
+
pinnedModel: pinnedModel?.ref ?? null
|
|
370
|
+
},
|
|
371
|
+
models,
|
|
372
|
+
available: {
|
|
373
|
+
defaultModel,
|
|
374
|
+
pinnedModel,
|
|
375
|
+
selectableModels
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
async function resolveCacheLocation(instanceUrl, cwd, projectPathHint) {
|
|
380
|
+
const projectPath = projectPathHint ?? await resolveProjectPath(cwd, instanceUrl);
|
|
381
|
+
const cacheDir = resolveCacheDirectory();
|
|
382
|
+
const cacheKey = `${instanceUrl}::${projectPath ?? "no-project"}`;
|
|
383
|
+
const hash = crypto.createHash("sha256").update(cacheKey).digest("hex").slice(0, 12);
|
|
384
|
+
return {
|
|
385
|
+
cachePath: path2.join(cacheDir, `gitlab-duo-models-${hash}.json`),
|
|
386
|
+
projectPath
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function resolveCacheDirectory() {
|
|
390
|
+
const xdg = process.env.XDG_CACHE_HOME?.trim();
|
|
391
|
+
if (xdg) return path2.join(xdg, "opencode");
|
|
392
|
+
return path2.join(os.homedir(), ".cache", "opencode");
|
|
393
|
+
}
|
|
394
|
+
async function resolveProjectPath(cwd, instanceUrl) {
|
|
395
|
+
try {
|
|
396
|
+
return await detectProjectPath(cwd, instanceUrl);
|
|
397
|
+
} catch {
|
|
398
|
+
return void 0;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function writeCache(cachePath, payload) {
|
|
402
|
+
const data = {
|
|
403
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
404
|
+
payload
|
|
405
|
+
};
|
|
406
|
+
await fs2.mkdir(path2.dirname(cachePath), { recursive: true });
|
|
407
|
+
await fs2.writeFile(cachePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
408
|
+
}
|
|
409
|
+
function normalizeInstanceUrl(value) {
|
|
410
|
+
return value.trim().replace(/\/$/, "");
|
|
411
|
+
}
|
|
412
|
+
|
|
31
413
|
// src/plugin/models.ts
|
|
32
414
|
async function loadGitLabModels(options = {}) {
|
|
415
|
+
const instanceUrl = resolveInstanceUrl(options.instanceUrl);
|
|
416
|
+
const apiKey = resolveApiKey(options.apiKey);
|
|
417
|
+
const cacheTtlSeconds = resolveModelsCacheTtlSeconds();
|
|
418
|
+
const cache = await loadModelsFromCache({
|
|
419
|
+
instanceUrl,
|
|
420
|
+
ttlSeconds: cacheTtlSeconds,
|
|
421
|
+
allowStale: true
|
|
422
|
+
});
|
|
423
|
+
if (cache && !cache.stale) {
|
|
424
|
+
console.log(
|
|
425
|
+
`[gitlab-duo] Loaded ${Object.keys(cache.payload.models).length} model(s) from cache ${cache.cachePath}`
|
|
426
|
+
);
|
|
427
|
+
return cache.payload.models;
|
|
428
|
+
}
|
|
429
|
+
if (apiKey) {
|
|
430
|
+
try {
|
|
431
|
+
const fetched = await fetchAndCacheModels({
|
|
432
|
+
instanceUrl,
|
|
433
|
+
apiKey
|
|
434
|
+
});
|
|
435
|
+
console.log(
|
|
436
|
+
`[gitlab-duo] Loaded ${Object.keys(fetched.payload.models).length} model(s) from GitLab API`
|
|
437
|
+
);
|
|
438
|
+
return fetched.payload.models;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.warn(
|
|
441
|
+
`[gitlab-duo] Failed to fetch models from GitLab API: ${error instanceof Error ? error.message : String(error)}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (cache?.stale) {
|
|
446
|
+
console.warn(`[gitlab-duo] Using stale models cache from ${cache.cachePath}`);
|
|
447
|
+
return cache.payload.models;
|
|
448
|
+
}
|
|
33
449
|
const modelsJsonPath = resolveModelsJsonPath(options.modelsPath);
|
|
450
|
+
const modelsFromFile = await loadModelsFromFile(modelsJsonPath);
|
|
451
|
+
if (modelsFromFile) return modelsFromFile;
|
|
452
|
+
return {
|
|
453
|
+
[GITLAB_DUO_DEFAULT_MODEL_ID]: buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME)
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async function loadModelsFromFile(modelsJsonPath) {
|
|
34
457
|
if (modelsJsonPath) {
|
|
35
458
|
try {
|
|
36
|
-
const raw = await
|
|
459
|
+
const raw = await fs3.promises.readFile(modelsJsonPath, "utf8");
|
|
37
460
|
const data = JSON.parse(raw);
|
|
38
461
|
if (data.models && Object.keys(data.models).length > 0) {
|
|
39
462
|
console.log(
|
|
@@ -48,28 +471,36 @@ async function loadGitLabModels(options = {}) {
|
|
|
48
471
|
);
|
|
49
472
|
}
|
|
50
473
|
}
|
|
51
|
-
return
|
|
52
|
-
[GITLAB_DUO_DEFAULT_MODEL_ID]: buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME)
|
|
53
|
-
};
|
|
474
|
+
return null;
|
|
54
475
|
}
|
|
55
476
|
function resolveModelsJsonPath(overridePath) {
|
|
56
477
|
const override = typeof overridePath === "string" && overridePath.trim() ? overridePath.trim() : process.env.GITLAB_DUO_MODELS_PATH;
|
|
57
478
|
if (override) {
|
|
58
|
-
const resolved =
|
|
59
|
-
if (
|
|
479
|
+
const resolved = path3.isAbsolute(override) ? override : path3.resolve(process.cwd(), override);
|
|
480
|
+
if (fs3.existsSync(resolved)) return resolved;
|
|
60
481
|
console.warn(`[gitlab-duo] models.json not found at override path ${resolved}`);
|
|
61
482
|
return null;
|
|
62
483
|
}
|
|
63
484
|
let current = process.cwd();
|
|
64
485
|
while (true) {
|
|
65
|
-
const candidate =
|
|
66
|
-
if (
|
|
67
|
-
const parent =
|
|
486
|
+
const candidate = path3.join(current, "models.json");
|
|
487
|
+
if (fs3.existsSync(candidate)) return candidate;
|
|
488
|
+
const parent = path3.dirname(current);
|
|
68
489
|
if (parent === current) break;
|
|
69
490
|
current = parent;
|
|
70
491
|
}
|
|
71
492
|
return null;
|
|
72
493
|
}
|
|
494
|
+
function resolveInstanceUrl(override) {
|
|
495
|
+
if (typeof override === "string" && override.trim()) return override.trim();
|
|
496
|
+
const fromEnv = process.env.GITLAB_INSTANCE_URL?.trim();
|
|
497
|
+
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
|
498
|
+
return "https://gitlab.com";
|
|
499
|
+
}
|
|
500
|
+
function resolveApiKey(override) {
|
|
501
|
+
if (typeof override === "string" && override.trim()) return override.trim();
|
|
502
|
+
return process.env.GITLAB_TOKEN || "";
|
|
503
|
+
}
|
|
73
504
|
|
|
74
505
|
// src/plugin/config.ts
|
|
75
506
|
async function configHook(input) {
|
|
@@ -101,15 +532,15 @@ async function configHook(input) {
|
|
|
101
532
|
enableMcp,
|
|
102
533
|
systemRules: mergedSystemRules || void 0
|
|
103
534
|
},
|
|
104
|
-
models: await loadGitLabModels({ modelsPath })
|
|
535
|
+
models: await loadGitLabModels({ modelsPath, instanceUrl, apiKey })
|
|
105
536
|
};
|
|
106
537
|
}
|
|
107
538
|
async function mergeSystemRules(rules, rulesPath) {
|
|
108
539
|
const baseRules = rules.trim();
|
|
109
540
|
if (!rulesPath) return baseRules;
|
|
110
|
-
const resolvedPath =
|
|
541
|
+
const resolvedPath = path4.isAbsolute(rulesPath) ? rulesPath : path4.resolve(process.cwd(), rulesPath);
|
|
111
542
|
try {
|
|
112
|
-
const fileRules = (await
|
|
543
|
+
const fileRules = (await fs4.promises.readFile(resolvedPath, "utf8")).trim();
|
|
113
544
|
if (!fileRules) return baseRules;
|
|
114
545
|
return baseRules ? `${baseRules}
|
|
115
546
|
|
|
@@ -122,8 +553,8 @@ ${fileRules}` : fileRules;
|
|
|
122
553
|
|
|
123
554
|
// src/plugin/tools.ts
|
|
124
555
|
import { tool } from "@opencode-ai/plugin";
|
|
125
|
-
import
|
|
126
|
-
import
|
|
556
|
+
import path5 from "path";
|
|
557
|
+
import fs5 from "fs";
|
|
127
558
|
function createReadTools() {
|
|
128
559
|
return {
|
|
129
560
|
read_file: tool({
|
|
@@ -140,7 +571,7 @@ function createReadTools() {
|
|
|
140
571
|
metadata: {}
|
|
141
572
|
});
|
|
142
573
|
try {
|
|
143
|
-
return await
|
|
574
|
+
return await fs5.promises.readFile(resolvedPath, "utf8");
|
|
144
575
|
} catch (error) {
|
|
145
576
|
throw new Error(formatReadError(displayPath, error));
|
|
146
577
|
}
|
|
@@ -165,7 +596,7 @@ function createReadTools() {
|
|
|
165
596
|
const results = await Promise.all(
|
|
166
597
|
targets.map(async (target) => {
|
|
167
598
|
try {
|
|
168
|
-
const content = await
|
|
599
|
+
const content = await fs5.promises.readFile(target.resolvedPath, "utf8");
|
|
169
600
|
return [target.inputPath, { content }];
|
|
170
601
|
} catch (error) {
|
|
171
602
|
return [target.inputPath, { error: formatReadError(target.displayPath, error) }];
|
|
@@ -183,9 +614,9 @@ function createReadTools() {
|
|
|
183
614
|
}
|
|
184
615
|
function resolveReadPath(filePath, ctx) {
|
|
185
616
|
const displayPath = filePath;
|
|
186
|
-
const resolvedPath =
|
|
187
|
-
const worktreePath =
|
|
188
|
-
if (resolvedPath !== worktreePath && !resolvedPath.startsWith(worktreePath +
|
|
617
|
+
const resolvedPath = path5.isAbsolute(filePath) ? filePath : path5.resolve(ctx.worktree, filePath);
|
|
618
|
+
const worktreePath = path5.resolve(ctx.worktree);
|
|
619
|
+
if (resolvedPath !== worktreePath && !resolvedPath.startsWith(worktreePath + path5.sep)) {
|
|
189
620
|
throw new Error(`File is outside the repository: "${displayPath}"`);
|
|
190
621
|
}
|
|
191
622
|
return { resolvedPath, displayPath };
|
|
@@ -794,6 +1225,14 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
794
1225
|
const workflowType = "chat";
|
|
795
1226
|
const promptText = extractLastUserText(options.prompt);
|
|
796
1227
|
const toolResults = extractToolResults(options.prompt);
|
|
1228
|
+
console.warn("[duo-debug] doStream called", {
|
|
1229
|
+
promptText: promptText?.slice(0, 80),
|
|
1230
|
+
toolResultCount: toolResults.length,
|
|
1231
|
+
hasStarted: this.#runtime.hasStarted,
|
|
1232
|
+
lastSentPrompt: this.#lastSentPrompt?.slice(0, 80) ?? null,
|
|
1233
|
+
pendingToolRequests: this.#pendingToolRequests.size,
|
|
1234
|
+
sentToolCallIds: this.#sentToolCallIds.size
|
|
1235
|
+
});
|
|
797
1236
|
this.#runtime.resetMapperState();
|
|
798
1237
|
if (!this.#runtime.hasStarted) {
|
|
799
1238
|
this.#sentToolCallIds.clear();
|
|
@@ -803,14 +1242,18 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
803
1242
|
}
|
|
804
1243
|
}
|
|
805
1244
|
this.#lastSentPrompt = null;
|
|
1245
|
+
console.warn("[duo-debug] hasStarted=false => reset sentToolCallIds, lastSentPrompt=null");
|
|
806
1246
|
}
|
|
807
1247
|
const freshToolResults = toolResults.filter((r) => !this.#sentToolCallIds.has(r.toolCallId));
|
|
1248
|
+
console.warn("[duo-debug] freshToolResults:", freshToolResults.length);
|
|
808
1249
|
const modelRef = this.modelId === GITLAB_DUO_DEFAULT_MODEL_ID ? void 0 : this.modelId;
|
|
809
1250
|
this.#runtime.setSelectedModelIdentifier(modelRef);
|
|
810
1251
|
await this.#runtime.ensureConnected(promptText || "", workflowType);
|
|
1252
|
+
console.warn("[duo-debug] ensureConnected returned OK");
|
|
811
1253
|
const mcpTools = this.#options.enableMcp === false ? [] : buildMcpTools(options);
|
|
812
1254
|
const toolContext = buildToolContext(mcpTools);
|
|
813
1255
|
const isNewUserMessage = promptText != null && promptText !== this.#lastSentPrompt;
|
|
1256
|
+
console.warn("[duo-debug] isNewUserMessage:", isNewUserMessage, "promptText != null:", promptText != null, "promptText !== lastSentPrompt:", promptText !== this.#lastSentPrompt);
|
|
814
1257
|
let sentToolResults = false;
|
|
815
1258
|
if (freshToolResults.length > 0) {
|
|
816
1259
|
for (const result of freshToolResults) {
|
|
@@ -857,6 +1300,7 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
857
1300
|
this.#pendingToolRequests.delete(result.toolCallId);
|
|
858
1301
|
}
|
|
859
1302
|
}
|
|
1303
|
+
console.warn("[duo-debug] startRequest gate: !sentToolResults:", !sentToolResults, "isNewUserMessage:", isNewUserMessage, "=> will send:", !sentToolResults && isNewUserMessage);
|
|
860
1304
|
if (!sentToolResults && isNewUserMessage) {
|
|
861
1305
|
const extraContext = [];
|
|
862
1306
|
if (toolContext) extraContext.push(toolContext);
|
|
@@ -918,6 +1362,7 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
918
1362
|
}
|
|
919
1363
|
});
|
|
920
1364
|
}
|
|
1365
|
+
console.warn("[duo-debug] >>> calling sendStartRequest", { goal: promptText?.slice(0, 80), hasStarted: this.#runtime.hasStarted, extraContextCount: extraContext.length });
|
|
921
1366
|
this.#runtime.sendStartRequest(
|
|
922
1367
|
promptText,
|
|
923
1368
|
workflowType,
|
|
@@ -926,11 +1371,15 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
926
1371
|
extraContext
|
|
927
1372
|
);
|
|
928
1373
|
this.#lastSentPrompt = promptText;
|
|
1374
|
+
console.warn("[duo-debug] >>> sendStartRequest completed, lastSentPrompt set");
|
|
929
1375
|
this.#usageEstimator.addInputChars(promptText);
|
|
930
1376
|
for (const ctx of extraContext) {
|
|
931
1377
|
if (ctx.content) this.#usageEstimator.addInputChars(ctx.content);
|
|
932
1378
|
}
|
|
1379
|
+
} else {
|
|
1380
|
+
console.warn("[duo-debug] SKIPPED sendStartRequest (sentToolResults:", sentToolResults, "isNewUserMessage:", isNewUserMessage, ")");
|
|
933
1381
|
}
|
|
1382
|
+
console.warn("[duo-debug] creating event stream iterator");
|
|
934
1383
|
const iterator = this.#mapEventsToStream(this.#runtime.getEventStream());
|
|
935
1384
|
const stream = asyncIteratorToReadableStream(iterator);
|
|
936
1385
|
return {
|
|
@@ -943,9 +1392,13 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
943
1392
|
async *#mapEventsToStream(events) {
|
|
944
1393
|
const state = { textStarted: false };
|
|
945
1394
|
const estimator = this.#usageEstimator;
|
|
1395
|
+
let eventCount = 0;
|
|
1396
|
+
console.warn("[duo-debug] #mapEventsToStream: starting iteration");
|
|
946
1397
|
yield { type: "stream-start", warnings: [] };
|
|
947
1398
|
try {
|
|
948
1399
|
for await (const event of events) {
|
|
1400
|
+
eventCount++;
|
|
1401
|
+
console.warn("[duo-debug] #mapEventsToStream event #" + eventCount + ":", event.type, event.type === "TEXT_CHUNK" ? "(len=" + event.content.length + ")" : "");
|
|
949
1402
|
if (event.type === "TEXT_CHUNK") {
|
|
950
1403
|
if (event.content.length > 0) {
|
|
951
1404
|
estimator.addOutputChars(event.content);
|
|
@@ -991,9 +1444,11 @@ var GitLabDuoAgenticLanguageModel = class {
|
|
|
991
1444
|
}
|
|
992
1445
|
}
|
|
993
1446
|
} catch (streamErr) {
|
|
1447
|
+
console.warn("[duo-debug] #mapEventsToStream: caught error after", eventCount, "events:", streamErr instanceof Error ? streamErr.message : String(streamErr));
|
|
994
1448
|
yield { type: "error", error: streamErr instanceof Error ? streamErr : new Error(String(streamErr)) };
|
|
995
1449
|
return;
|
|
996
1450
|
}
|
|
1451
|
+
console.warn("[duo-debug] #mapEventsToStream: loop ended normally after", eventCount, "events => yielding finish:stop");
|
|
997
1452
|
yield { type: "finish", finishReason: "stop", usage: this.#currentUsage };
|
|
998
1453
|
}
|
|
999
1454
|
// ---------------------------------------------------------------------------
|
|
@@ -1060,7 +1515,7 @@ function classifyModeReminder2(reminder) {
|
|
|
1060
1515
|
}
|
|
1061
1516
|
|
|
1062
1517
|
// src/provider/application/workflow_event_mapper.ts
|
|
1063
|
-
import
|
|
1518
|
+
import crypto2 from "crypto";
|
|
1064
1519
|
|
|
1065
1520
|
// src/provider/core/ui_chat_log.ts
|
|
1066
1521
|
import { z } from "zod";
|
|
@@ -1201,7 +1656,7 @@ var WorkflowEventMapper = class {
|
|
|
1201
1656
|
break;
|
|
1202
1657
|
}
|
|
1203
1658
|
case "request": {
|
|
1204
|
-
const requestId = latestMessage.correlation_id ||
|
|
1659
|
+
const requestId = latestMessage.correlation_id || crypto2.randomUUID();
|
|
1205
1660
|
events.push({
|
|
1206
1661
|
type: "TOOL_REQUEST",
|
|
1207
1662
|
requestId,
|
|
@@ -1440,14 +1895,26 @@ var GitLabAgenticRuntime = class {
|
|
|
1440
1895
|
// Connection lifecycle
|
|
1441
1896
|
// ---------------------------------------------------------------------------
|
|
1442
1897
|
async ensureConnected(goal, workflowType) {
|
|
1898
|
+
console.warn("[duo-debug] ensureConnected called", {
|
|
1899
|
+
hasStream: !!this.#stream,
|
|
1900
|
+
hasWorkflowId: !!this.#workflowId,
|
|
1901
|
+
workflowId: this.#workflowId?.slice(0, 12),
|
|
1902
|
+
hasQueue: !!this.#queue,
|
|
1903
|
+
startRequestSent: this.#startRequestSent
|
|
1904
|
+
});
|
|
1443
1905
|
if (this.#stream && this.#workflowId && this.#queue) {
|
|
1906
|
+
console.warn("[duo-debug] ensureConnected: short-circuit (already connected)");
|
|
1444
1907
|
return;
|
|
1445
1908
|
}
|
|
1446
1909
|
if (!this.#containerParams) {
|
|
1447
1910
|
this.#containerParams = await this.#resolveContainerParams();
|
|
1448
1911
|
}
|
|
1449
1912
|
if (!this.#workflowId) {
|
|
1913
|
+
console.warn("[duo-debug] ensureConnected: creating new workflow");
|
|
1450
1914
|
this.#workflowId = await this.#createWorkflow(goal, workflowType);
|
|
1915
|
+
console.warn("[duo-debug] ensureConnected: workflow created:", this.#workflowId?.slice(0, 12));
|
|
1916
|
+
} else {
|
|
1917
|
+
console.warn("[duo-debug] ensureConnected: reusing existing workflowId:", this.#workflowId?.slice(0, 12));
|
|
1451
1918
|
}
|
|
1452
1919
|
const token = await this.#dependencies.workflowService.getWorkflowToken(
|
|
1453
1920
|
this.#options.instanceUrl,
|
|
@@ -1455,15 +1922,19 @@ var GitLabAgenticRuntime = class {
|
|
|
1455
1922
|
workflowType
|
|
1456
1923
|
);
|
|
1457
1924
|
this.#workflowToken = token;
|
|
1925
|
+
console.warn("[duo-debug] ensureConnected: got workflow token");
|
|
1458
1926
|
const MAX_LOCK_RETRIES = 3;
|
|
1459
1927
|
const LOCK_RETRY_DELAY_MS = 3e3;
|
|
1460
1928
|
for (let attempt = 1; attempt <= MAX_LOCK_RETRIES; attempt++) {
|
|
1461
1929
|
this.#queue = new AsyncQueue();
|
|
1462
1930
|
try {
|
|
1931
|
+
console.warn("[duo-debug] ensureConnected: connecting WebSocket (attempt", attempt, ")");
|
|
1463
1932
|
await this.#connectWebSocket();
|
|
1933
|
+
console.warn("[duo-debug] ensureConnected: WebSocket connected OK");
|
|
1464
1934
|
return;
|
|
1465
1935
|
} catch (err2) {
|
|
1466
1936
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
1937
|
+
console.warn("[duo-debug] ensureConnected: WebSocket error:", msg);
|
|
1467
1938
|
if ((msg.includes("1013") || msg.includes("lock")) && attempt < MAX_LOCK_RETRIES) {
|
|
1468
1939
|
this.#resetStreamState();
|
|
1469
1940
|
await this.#dependencies.clock.sleep(LOCK_RETRY_DELAY_MS);
|
|
@@ -1508,7 +1979,13 @@ var GitLabAgenticRuntime = class {
|
|
|
1508
1979
|
preapproved_tools: preapprovedTools
|
|
1509
1980
|
}
|
|
1510
1981
|
};
|
|
1511
|
-
|
|
1982
|
+
console.warn("[duo-debug] sendStartRequest: writing to stream", {
|
|
1983
|
+
workflowId: this.#workflowId?.slice(0, 12),
|
|
1984
|
+
goal: goal?.slice(0, 80),
|
|
1985
|
+
contextCount: additionalContext.length
|
|
1986
|
+
});
|
|
1987
|
+
const writeResult = this.#stream.write(startRequest);
|
|
1988
|
+
console.warn("[duo-debug] sendStartRequest: write() returned:", writeResult);
|
|
1512
1989
|
this.#startRequestSent = true;
|
|
1513
1990
|
}
|
|
1514
1991
|
sendToolResponse(requestId, response, responseType) {
|
|
@@ -1597,12 +2074,14 @@ var GitLabAgenticRuntime = class {
|
|
|
1597
2074
|
#bindStream(stream, queue) {
|
|
1598
2075
|
const now = () => this.#dependencies.clock.now();
|
|
1599
2076
|
const closeWithError = (message) => {
|
|
2077
|
+
console.warn("[duo-debug] stream closeWithError:", message);
|
|
1600
2078
|
queue.push({ type: "ERROR", message, timestamp: now() });
|
|
1601
2079
|
queue.close();
|
|
1602
2080
|
this.#resetStreamState();
|
|
1603
2081
|
};
|
|
1604
2082
|
const handleAction = async (action) => {
|
|
1605
2083
|
if (action.newCheckpoint) {
|
|
2084
|
+
console.warn("[duo-debug] stream data: newCheckpoint status=", action.newCheckpoint.status, "goal=", action.newCheckpoint.goal?.slice(0, 40));
|
|
1606
2085
|
const duoEvent = {
|
|
1607
2086
|
checkpoint: action.newCheckpoint.checkpoint,
|
|
1608
2087
|
errors: action.newCheckpoint.errors || [],
|
|
@@ -1610,6 +2089,7 @@ var GitLabAgenticRuntime = class {
|
|
|
1610
2089
|
workflowStatus: action.newCheckpoint.status
|
|
1611
2090
|
};
|
|
1612
2091
|
const events = await this.#mapper.mapWorkflowEvent(duoEvent);
|
|
2092
|
+
console.warn("[duo-debug] mapper produced", events.length, "events:", events.map((e) => e.type));
|
|
1613
2093
|
for (const event of events) {
|
|
1614
2094
|
queue.push(event);
|
|
1615
2095
|
}
|
|
@@ -1617,6 +2097,7 @@ var GitLabAgenticRuntime = class {
|
|
|
1617
2097
|
}
|
|
1618
2098
|
const toolRequest = mapWorkflowActionToToolRequest(action);
|
|
1619
2099
|
if (toolRequest) {
|
|
2100
|
+
console.warn("[duo-debug] stream data: toolRequest", toolRequest.toolName);
|
|
1620
2101
|
queue.push({
|
|
1621
2102
|
type: "TOOL_REQUEST",
|
|
1622
2103
|
...toolRequest,
|
|
@@ -1624,6 +2105,7 @@ var GitLabAgenticRuntime = class {
|
|
|
1624
2105
|
});
|
|
1625
2106
|
return;
|
|
1626
2107
|
}
|
|
2108
|
+
console.warn("[duo-debug] stream data: unhandled action", Object.keys(action));
|
|
1627
2109
|
};
|
|
1628
2110
|
stream.on("data", (action) => {
|
|
1629
2111
|
void handleAction(action).catch((error) => {
|
|
@@ -1632,9 +2114,11 @@ var GitLabAgenticRuntime = class {
|
|
|
1632
2114
|
});
|
|
1633
2115
|
});
|
|
1634
2116
|
stream.on("error", (err2) => {
|
|
2117
|
+
console.warn("[duo-debug] stream error:", err2.message);
|
|
1635
2118
|
closeWithError(err2.message);
|
|
1636
2119
|
});
|
|
1637
2120
|
stream.on("end", () => {
|
|
2121
|
+
console.warn("[duo-debug] stream end => closing queue + resetStreamState");
|
|
1638
2122
|
queue.close();
|
|
1639
2123
|
this.#resetStreamState();
|
|
1640
2124
|
});
|
|
@@ -1656,6 +2140,7 @@ var GitLabAgenticRuntime = class {
|
|
|
1656
2140
|
this.#bindStream(stream, this.#queue);
|
|
1657
2141
|
}
|
|
1658
2142
|
#resetStreamState() {
|
|
2143
|
+
console.warn("[duo-debug] #resetStreamState called", { hadStream: !!this.#stream, hadQueue: !!this.#queue, workflowId: this.#workflowId?.slice(0, 12) });
|
|
1659
2144
|
this.#stream = void 0;
|
|
1660
2145
|
this.#queue = void 0;
|
|
1661
2146
|
this.#startRequestSent = false;
|
|
@@ -1706,175 +2191,6 @@ function parseHttpToolOutput(output) {
|
|
|
1706
2191
|
// src/provider/adapters/default_runtime_dependencies.ts
|
|
1707
2192
|
import { ProxyAgent } from "proxy-agent";
|
|
1708
2193
|
|
|
1709
|
-
// src/provider/adapters/gitlab_utils.ts
|
|
1710
|
-
import fs4 from "fs/promises";
|
|
1711
|
-
import path4 from "path";
|
|
1712
|
-
async function detectProjectPath(cwd, instanceUrl) {
|
|
1713
|
-
let current = cwd;
|
|
1714
|
-
const instance = new URL(instanceUrl);
|
|
1715
|
-
const instanceHost = instance.host;
|
|
1716
|
-
const instanceBasePath = instance.pathname.replace(/\/$/, "");
|
|
1717
|
-
while (true) {
|
|
1718
|
-
try {
|
|
1719
|
-
const config = await readGitConfig(current);
|
|
1720
|
-
const url = extractGitRemoteUrl(config) || "";
|
|
1721
|
-
const remote = parseRemote(url);
|
|
1722
|
-
if (!remote) {
|
|
1723
|
-
return void 0;
|
|
1724
|
-
}
|
|
1725
|
-
if (remote.host !== instanceHost) {
|
|
1726
|
-
throw new Error(
|
|
1727
|
-
`GitLab remote host mismatch. Expected ${instanceHost}, got ${remote.host}.`
|
|
1728
|
-
);
|
|
1729
|
-
}
|
|
1730
|
-
return normalizeProjectPath(remote.path, instanceBasePath);
|
|
1731
|
-
} catch {
|
|
1732
|
-
const parent = path4.dirname(current);
|
|
1733
|
-
if (parent === current) return void 0;
|
|
1734
|
-
current = parent;
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
function extractGitRemoteUrl(config) {
|
|
1739
|
-
const lines = config.split("\n");
|
|
1740
|
-
let inOrigin = false;
|
|
1741
|
-
let originUrl;
|
|
1742
|
-
let firstUrl;
|
|
1743
|
-
for (const line of lines) {
|
|
1744
|
-
const trimmed = line.trim();
|
|
1745
|
-
const sectionMatch = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
|
|
1746
|
-
if (sectionMatch) {
|
|
1747
|
-
inOrigin = sectionMatch[1] === "origin";
|
|
1748
|
-
continue;
|
|
1749
|
-
}
|
|
1750
|
-
const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
|
|
1751
|
-
if (urlMatch) {
|
|
1752
|
-
const value = urlMatch[1].trim();
|
|
1753
|
-
if (!firstUrl) firstUrl = value;
|
|
1754
|
-
if (inOrigin) originUrl = value;
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
return originUrl ?? firstUrl;
|
|
1758
|
-
}
|
|
1759
|
-
function parseRemote(remoteUrl) {
|
|
1760
|
-
if (!remoteUrl) return void 0;
|
|
1761
|
-
if (remoteUrl.startsWith("http")) {
|
|
1762
|
-
try {
|
|
1763
|
-
const url = new URL(remoteUrl);
|
|
1764
|
-
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
1765
|
-
} catch {
|
|
1766
|
-
return void 0;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
if (remoteUrl.startsWith("git@")) {
|
|
1770
|
-
const match = /^git@([^:]+):(.+)$/.exec(remoteUrl);
|
|
1771
|
-
if (!match) return void 0;
|
|
1772
|
-
return { host: match[1], path: match[2] };
|
|
1773
|
-
}
|
|
1774
|
-
if (remoteUrl.startsWith("ssh://")) {
|
|
1775
|
-
try {
|
|
1776
|
-
const url = new URL(remoteUrl);
|
|
1777
|
-
return { host: url.host, path: url.pathname.replace(/^\//, "") };
|
|
1778
|
-
} catch {
|
|
1779
|
-
return void 0;
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
return void 0;
|
|
1783
|
-
}
|
|
1784
|
-
function normalizeProjectPath(remotePath, instanceBasePath) {
|
|
1785
|
-
let pathValue = remotePath;
|
|
1786
|
-
if (instanceBasePath && instanceBasePath !== "/") {
|
|
1787
|
-
const base = instanceBasePath.replace(/^\//, "") + "/";
|
|
1788
|
-
if (pathValue.startsWith(base)) {
|
|
1789
|
-
pathValue = pathValue.slice(base.length);
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
const cleaned = stripGitSuffix(pathValue);
|
|
1793
|
-
return cleaned.length > 0 ? cleaned : void 0;
|
|
1794
|
-
}
|
|
1795
|
-
function stripGitSuffix(pathname) {
|
|
1796
|
-
return pathname.endsWith(".git") ? pathname.slice(0, -4) : pathname;
|
|
1797
|
-
}
|
|
1798
|
-
function buildApiUrl(instanceUrl, apiPath) {
|
|
1799
|
-
const base = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`;
|
|
1800
|
-
return new URL(apiPath.replace(/^\//, ""), base).toString();
|
|
1801
|
-
}
|
|
1802
|
-
function buildAuthHeaders(apiKey) {
|
|
1803
|
-
return { authorization: `Bearer ${apiKey}` };
|
|
1804
|
-
}
|
|
1805
|
-
async function fetchProjectDetails(instanceUrl, apiKey, projectPath) {
|
|
1806
|
-
const url = buildApiUrl(instanceUrl, `api/v4/projects/${encodeURIComponent(projectPath)}`);
|
|
1807
|
-
const response = await fetch(url, {
|
|
1808
|
-
headers: buildAuthHeaders(apiKey)
|
|
1809
|
-
});
|
|
1810
|
-
if (!response.ok) {
|
|
1811
|
-
throw new Error(`Failed to fetch project details: ${response.status}`);
|
|
1812
|
-
}
|
|
1813
|
-
const data = await response.json();
|
|
1814
|
-
return {
|
|
1815
|
-
projectId: data.id ? String(data.id) : void 0,
|
|
1816
|
-
namespaceId: data.namespace?.id ? String(data.namespace.id) : void 0
|
|
1817
|
-
};
|
|
1818
|
-
}
|
|
1819
|
-
async function fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath) {
|
|
1820
|
-
const candidates = getProjectPathCandidates(projectPath);
|
|
1821
|
-
for (const candidate of candidates) {
|
|
1822
|
-
try {
|
|
1823
|
-
return await fetchProjectDetails(instanceUrl, apiKey, candidate);
|
|
1824
|
-
} catch {
|
|
1825
|
-
continue;
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
try {
|
|
1829
|
-
const name = projectPath.split("/").pop() || projectPath;
|
|
1830
|
-
const searchUrl = new URL(buildApiUrl(instanceUrl, "api/v4/projects"));
|
|
1831
|
-
searchUrl.searchParams.set("search", name);
|
|
1832
|
-
searchUrl.searchParams.set("simple", "true");
|
|
1833
|
-
searchUrl.searchParams.set("per_page", "100");
|
|
1834
|
-
searchUrl.searchParams.set("membership", "true");
|
|
1835
|
-
const response = await fetch(searchUrl.toString(), {
|
|
1836
|
-
headers: buildAuthHeaders(apiKey)
|
|
1837
|
-
});
|
|
1838
|
-
if (!response.ok) {
|
|
1839
|
-
throw new Error(`Failed to search projects: ${response.status}`);
|
|
1840
|
-
}
|
|
1841
|
-
const data = await response.json();
|
|
1842
|
-
const match = data.find((project) => project.path_with_namespace === projectPath);
|
|
1843
|
-
if (!match) {
|
|
1844
|
-
throw new Error("Project not found via search");
|
|
1845
|
-
}
|
|
1846
|
-
return {
|
|
1847
|
-
projectId: match.id ? String(match.id) : void 0,
|
|
1848
|
-
namespaceId: match.namespace?.id ? String(match.namespace.id) : void 0
|
|
1849
|
-
};
|
|
1850
|
-
} catch {
|
|
1851
|
-
throw new Error("Project not found via API");
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
function getProjectPathCandidates(projectPath) {
|
|
1855
|
-
const candidates = /* @__PURE__ */ new Set();
|
|
1856
|
-
candidates.add(projectPath);
|
|
1857
|
-
const parts = projectPath.split("/");
|
|
1858
|
-
if (parts.length > 2) {
|
|
1859
|
-
const withoutFirst = parts.slice(1).join("/");
|
|
1860
|
-
candidates.add(withoutFirst);
|
|
1861
|
-
}
|
|
1862
|
-
return Array.from(candidates);
|
|
1863
|
-
}
|
|
1864
|
-
async function readGitConfig(cwd) {
|
|
1865
|
-
const gitPath = path4.join(cwd, ".git");
|
|
1866
|
-
const stat = await fs4.stat(gitPath);
|
|
1867
|
-
if (stat.isDirectory()) {
|
|
1868
|
-
return fs4.readFile(path4.join(gitPath, "config"), "utf8");
|
|
1869
|
-
}
|
|
1870
|
-
const file = await fs4.readFile(gitPath, "utf8");
|
|
1871
|
-
const match = /^gitdir:\s*(.+)$/m.exec(file);
|
|
1872
|
-
if (!match) throw new Error("Invalid .git file");
|
|
1873
|
-
const gitdir = match[1].trim();
|
|
1874
|
-
const resolved = path4.isAbsolute(gitdir) ? gitdir : path4.join(cwd, gitdir);
|
|
1875
|
-
return fs4.readFile(path4.join(resolved, "config"), "utf8");
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
2194
|
// src/provider/adapters/workflow_service.ts
|
|
1879
2195
|
var WorkflowCreateError = class extends Error {
|
|
1880
2196
|
status;
|
|
@@ -2097,10 +2413,10 @@ function buildUserAgent() {
|
|
|
2097
2413
|
}
|
|
2098
2414
|
|
|
2099
2415
|
// src/provider/adapters/system_context.ts
|
|
2100
|
-
import
|
|
2416
|
+
import os2 from "os";
|
|
2101
2417
|
function getSystemContextItems(systemRules) {
|
|
2102
|
-
const platform =
|
|
2103
|
-
const arch =
|
|
2418
|
+
const platform = os2.platform();
|
|
2419
|
+
const arch = os2.arch();
|
|
2104
2420
|
const items = [
|
|
2105
2421
|
{
|
|
2106
2422
|
category: "os_information",
|
|
@@ -2334,7 +2650,7 @@ function assertDependencies() {
|
|
|
2334
2650
|
);
|
|
2335
2651
|
}
|
|
2336
2652
|
}
|
|
2337
|
-
function
|
|
2653
|
+
function resolveInstanceUrl2(value) {
|
|
2338
2654
|
if (typeof value === "string" && value.trim()) {
|
|
2339
2655
|
const normalized = value.trim();
|
|
2340
2656
|
assertInstanceUrl(normalized);
|
|
@@ -2356,15 +2672,15 @@ function assertInstanceUrl(value) {
|
|
|
2356
2672
|
);
|
|
2357
2673
|
}
|
|
2358
2674
|
}
|
|
2359
|
-
function
|
|
2675
|
+
function resolveApiKey2(value) {
|
|
2360
2676
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2361
2677
|
return process.env.GITLAB_TOKEN || "";
|
|
2362
2678
|
}
|
|
2363
2679
|
function createGitLabDuoAgentic(options = {}) {
|
|
2364
2680
|
assertDependencies();
|
|
2365
2681
|
const resolvedOptions = {
|
|
2366
|
-
instanceUrl:
|
|
2367
|
-
apiKey:
|
|
2682
|
+
instanceUrl: resolveInstanceUrl2(options.instanceUrl),
|
|
2683
|
+
apiKey: resolveApiKey2(options.apiKey),
|
|
2368
2684
|
sendSystemContext: typeof options.sendSystemContext === "boolean" ? options.sendSystemContext : true,
|
|
2369
2685
|
enableMcp: typeof options.enableMcp === "boolean" ? options.enableMcp : true,
|
|
2370
2686
|
systemRules: typeof options.systemRules === "string" ? options.systemRules : void 0
|