@weiyentan/opencode-plugin-awx 0.2.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/LICENSE +21 -0
- package/README.md +262 -0
- package/dist/auth.d.ts +112 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +180 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +148 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +334 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts/job-detail.d.ts +141 -0
- package/dist/contracts/job-detail.d.ts.map +1 -0
- package/dist/contracts/job-detail.js +98 -0
- package/dist/contracts/job-detail.js.map +1 -0
- package/dist/contracts/sync-project.d.ts +31 -0
- package/dist/contracts/sync-project.d.ts.map +1 -0
- package/dist/contracts/sync-project.js +30 -0
- package/dist/contracts/sync-project.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +754 -0
- package/dist/index.js.map +1 -0
- package/dist/job-status.d.ts +116 -0
- package/dist/job-status.d.ts.map +1 -0
- package/dist/job-status.js +168 -0
- package/dist/job-status.js.map +1 -0
- package/dist/launch.d.ts +76 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +133 -0
- package/dist/launch.js.map +1 -0
- package/dist/list-projects.d.ts +63 -0
- package/dist/list-projects.d.ts.map +1 -0
- package/dist/list-projects.js +84 -0
- package/dist/list-projects.js.map +1 -0
- package/dist/list-templates.d.ts +60 -0
- package/dist/list-templates.d.ts.map +1 -0
- package/dist/list-templates.js +120 -0
- package/dist/list-templates.js.map +1 -0
- package/dist/metrics.d.ts +174 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +275 -0
- package/dist/metrics.js.map +1 -0
- package/dist/transforms.d.ts +52 -0
- package/dist/transforms.d.ts.map +1 -0
- package/dist/transforms.js +108 -0
- package/dist/transforms.js.map +1 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWX Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Provides native tool access to AWX / Ansible Automation Platform
|
|
5
|
+
* for job templates, projects, and job lifecycle operations.
|
|
6
|
+
*
|
|
7
|
+
* ## Plugin Lifecycle
|
|
8
|
+
*
|
|
9
|
+
* 1. On load, the plugin registers its auth hook (type: "api" bearer token).
|
|
10
|
+
* 2. If a PAT was previously stored, init-time validation calls GET /api/v2/me/
|
|
11
|
+
* to verify the token is still active.
|
|
12
|
+
* 3. Tools consume the validated token for all AWX API requests.
|
|
13
|
+
*
|
|
14
|
+
* ## Configuration
|
|
15
|
+
*
|
|
16
|
+
* The plugin reads `baseUrl` from its plugin options in opencode.jsonc:
|
|
17
|
+
* ```jsonc
|
|
18
|
+
* { "plugin": [["./packages/awx", { "baseUrl": "https://example.com" }]] }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { tool } from "@opencode-ai/plugin";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { createAwxAuthHook, validateToken } from "./auth.js";
|
|
24
|
+
import { MetricsStore, setupMetricsPersistence } from "./metrics.js";
|
|
25
|
+
import { createClient, createTimeoutSignal } from "./client.js";
|
|
26
|
+
import { listTemplates } from "./list-templates.js";
|
|
27
|
+
import { listProjects } from "./list-projects.js";
|
|
28
|
+
import { launchJob } from "./launch.js";
|
|
29
|
+
import { fetchJobStatus } from "./job-status.js";
|
|
30
|
+
/**
|
|
31
|
+
* Format a user-facing error message for HTTP error responses.
|
|
32
|
+
*
|
|
33
|
+
* Maps HTTP status codes to meaningful error messages the agent
|
|
34
|
+
* can act on (e.g., "not found", "not authorized").
|
|
35
|
+
*/
|
|
36
|
+
function formatErrorResponse(projectId, status) {
|
|
37
|
+
switch (status) {
|
|
38
|
+
case 404:
|
|
39
|
+
return `Project ${projectId} not found. Verify the project ID and try again.`;
|
|
40
|
+
case 401:
|
|
41
|
+
case 403:
|
|
42
|
+
return (`Not authorized to sync project ${projectId}. ` +
|
|
43
|
+
"Check your Personal Access Token permissions.");
|
|
44
|
+
default:
|
|
45
|
+
return (`Failed to sync project ${projectId}. ` +
|
|
46
|
+
`AWX API returned HTTP ${status}.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Plugin server function — the single entry point.
|
|
51
|
+
*
|
|
52
|
+
* Receives PluginInput (client, project, directory, worktree, serverUrl, $)
|
|
53
|
+
* and optional plugin options from opencode.jsonc configuration.
|
|
54
|
+
*
|
|
55
|
+
* Returns Hooks including:
|
|
56
|
+
* - Auth hook (type: "api" for bearer token / PAT)
|
|
57
|
+
* Plugins register tools (awx-list-templates, awx-launch-job, awx-job-status, etc.)
|
|
58
|
+
* and auth hooks for AWX API interaction.
|
|
59
|
+
*/
|
|
60
|
+
async function server(input, options) {
|
|
61
|
+
const { serverUrl } = input;
|
|
62
|
+
const baseUrl = options?.baseUrl;
|
|
63
|
+
/* ── Auth hook ────────────────────────────────────────────── */
|
|
64
|
+
const authHook = createAwxAuthHook();
|
|
65
|
+
/* ── Metrics lifecycle ────────────────────────────────────── */
|
|
66
|
+
// Create the shared MetricsStore early, before the AWX client,
|
|
67
|
+
// so the middleware pipeline can record metrics through it.
|
|
68
|
+
// Restore persisted counters from disk and set up periodic persistence
|
|
69
|
+
// so that in-memory counters survive plugin reloads. The dispose hook
|
|
70
|
+
// (returned via Hooks.dispose) will stop the interval and flush counters.
|
|
71
|
+
const metricsStore = new MetricsStore();
|
|
72
|
+
try {
|
|
73
|
+
await metricsStore.load();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// load failures (e.g. corrupt file) are non-fatal — counters start fresh
|
|
77
|
+
void input.client.app.log({
|
|
78
|
+
body: {
|
|
79
|
+
service: "plugin-awx",
|
|
80
|
+
level: "error",
|
|
81
|
+
message: "Failed to load persisted metrics; starting fresh",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const persistence = setupMetricsPersistence(metricsStore, 30_000, (err) => {
|
|
86
|
+
try {
|
|
87
|
+
input.client.app?.log?.({
|
|
88
|
+
body: {
|
|
89
|
+
service: "plugin-awx",
|
|
90
|
+
level: "error",
|
|
91
|
+
message: `Metrics persistence failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Swallow logging errors (e.g. during dispose after test teardown)
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
/* ── AWX HTTP client — lazy resolver, created on first tool call ── */
|
|
100
|
+
let cachedClient;
|
|
101
|
+
let cachedToken;
|
|
102
|
+
async function getAwxClient() {
|
|
103
|
+
if (!baseUrl)
|
|
104
|
+
return undefined;
|
|
105
|
+
const token = await input.client.getSecret?.("awx");
|
|
106
|
+
if (!token)
|
|
107
|
+
return undefined;
|
|
108
|
+
const tokenString = String(token);
|
|
109
|
+
if (!cachedClient || cachedToken !== tokenString) {
|
|
110
|
+
cachedToken = tokenString;
|
|
111
|
+
cachedClient = createClient(baseUrl, tokenString, { metricsStore });
|
|
112
|
+
}
|
|
113
|
+
return cachedClient;
|
|
114
|
+
}
|
|
115
|
+
/* ── Init-time validation ─────────────────────────────────── */
|
|
116
|
+
// If a baseUrl is configured, attempt to validate the connection.
|
|
117
|
+
// Token validation depends on whether the user has already stored a PAT.
|
|
118
|
+
// If no baseUrl is configured, skip — the user will configure it later.
|
|
119
|
+
if (baseUrl) {
|
|
120
|
+
try {
|
|
121
|
+
const storedKey = await input.client.getSecret?.("awx");
|
|
122
|
+
if (storedKey) {
|
|
123
|
+
const { signal, clear } = createTimeoutSignal(10_000);
|
|
124
|
+
try {
|
|
125
|
+
const result = await validateToken(baseUrl, String(storedKey), signal);
|
|
126
|
+
if (!result.valid) {
|
|
127
|
+
void input.client.app.log({
|
|
128
|
+
body: {
|
|
129
|
+
service: "plugin-awx",
|
|
130
|
+
level: "error",
|
|
131
|
+
message: `Init-time token validation failed: ${result.error}`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
void input.client.app.log({
|
|
137
|
+
body: {
|
|
138
|
+
service: "plugin-awx",
|
|
139
|
+
level: "info",
|
|
140
|
+
message: `Token validated successfully against ${baseUrl}`,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
clear();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
void input.client.app.log({
|
|
152
|
+
body: {
|
|
153
|
+
service: "plugin-awx",
|
|
154
|
+
level: "info",
|
|
155
|
+
message: "No stored token found. Auth will be configured on first use.",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/* ── Hooks ────────────────────────────────────────────────── */
|
|
161
|
+
return {
|
|
162
|
+
auth: authHook,
|
|
163
|
+
dispose: async () => {
|
|
164
|
+
await persistence.clear();
|
|
165
|
+
},
|
|
166
|
+
tool: {
|
|
167
|
+
/**
|
|
168
|
+
* Hello-world tool — Phase 0 scaffolding tracer.
|
|
169
|
+
*
|
|
170
|
+
* Verifies that tools can be registered, invoked, and hot-reloaded
|
|
171
|
+
* by the OpenCode plugin server. This tool exercises the full plugin
|
|
172
|
+
* lifecycle: import, register, execute, return.
|
|
173
|
+
*/
|
|
174
|
+
hello: tool({
|
|
175
|
+
description: [
|
|
176
|
+
"Returns a hello world greeting. Sanity-check tool that verifies",
|
|
177
|
+
"plugin load, tool registration, and hot-reload behavior on the",
|
|
178
|
+
`AWX plugin server (connected to ${serverUrl.href}).`,
|
|
179
|
+
].join(" "),
|
|
180
|
+
args: {
|
|
181
|
+
name: z
|
|
182
|
+
.string()
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("Name to greet. Defaults to 'world'."),
|
|
185
|
+
},
|
|
186
|
+
async execute(args, context) {
|
|
187
|
+
// Respect the abort signal
|
|
188
|
+
if (context.abort?.aborted) {
|
|
189
|
+
return { output: "Request was aborted." };
|
|
190
|
+
}
|
|
191
|
+
const name = args.name ?? "world";
|
|
192
|
+
return { output: `Hello, ${name}! 👋` };
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
/**
|
|
196
|
+
* Trigger an SCM sync on an AWX project.
|
|
197
|
+
*
|
|
198
|
+
* Accepts a project_id, fetches the project details, and triggers
|
|
199
|
+
* an SCM update via POST /api/v2/projects/<id>/update/.
|
|
200
|
+
* Returns the project_update_id, status, and project metadata.
|
|
201
|
+
* The sync is async on AAP — the agent can poll the project update
|
|
202
|
+
* status using the returned project_update_id.
|
|
203
|
+
*/
|
|
204
|
+
"awx-sync-project": tool({
|
|
205
|
+
description: [
|
|
206
|
+
"Trigger an SCM sync on an AWX project by project ID.",
|
|
207
|
+
"Fetches project details, triggers the update, and returns",
|
|
208
|
+
"the project update ID, status, and project metadata.",
|
|
209
|
+
"Sync is async — poll the project update status separately.",
|
|
210
|
+
].join(" "),
|
|
211
|
+
args: {
|
|
212
|
+
project_id: z
|
|
213
|
+
.number()
|
|
214
|
+
.int()
|
|
215
|
+
.positive()
|
|
216
|
+
.describe("The numeric ID of the AWX project to sync."),
|
|
217
|
+
},
|
|
218
|
+
async execute(args, context) {
|
|
219
|
+
// Respect the abort signal
|
|
220
|
+
if (context.abort?.aborted) {
|
|
221
|
+
return { output: "Request was aborted." };
|
|
222
|
+
}
|
|
223
|
+
const awxClient = await getAwxClient();
|
|
224
|
+
if (!awxClient) {
|
|
225
|
+
return {
|
|
226
|
+
output: "[awx-sync-project] AWX client not available. " +
|
|
227
|
+
"Configure a baseUrl in opencode.jsonc and store your " +
|
|
228
|
+
"Personal Access Token via the plugin auth prompt.",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const toolName = "awx-sync-project";
|
|
232
|
+
const { project_id } = args;
|
|
233
|
+
try {
|
|
234
|
+
// Step 1: Fetch project details
|
|
235
|
+
const projectRes = await awxClient.request(toolName, `/api/v2/projects/${project_id}/`, { method: "GET" }, context.abort);
|
|
236
|
+
if (!projectRes.ok) {
|
|
237
|
+
return { output: formatErrorResponse(project_id, projectRes.status) };
|
|
238
|
+
}
|
|
239
|
+
const project = (await projectRes.json());
|
|
240
|
+
// Step 2: Trigger SCM update
|
|
241
|
+
const updateRes = await awxClient.request(toolName, `/api/v2/projects/${project_id}/update/`, { method: "POST" }, context.abort);
|
|
242
|
+
if (!updateRes.ok) {
|
|
243
|
+
return { output: formatErrorResponse(project_id, updateRes.status) };
|
|
244
|
+
}
|
|
245
|
+
const projectUpdate = (await updateRes.json());
|
|
246
|
+
// Step 3: Return structured output
|
|
247
|
+
const projectName = project.name ?? "";
|
|
248
|
+
const status = projectUpdate.status;
|
|
249
|
+
return {
|
|
250
|
+
output: [
|
|
251
|
+
`SCM sync triggered for project "${projectName}" (ID ${project_id}).`,
|
|
252
|
+
`Project update ID: ${projectUpdate.id}, status: ${status}.`,
|
|
253
|
+
].join(" "),
|
|
254
|
+
metadata: {
|
|
255
|
+
project_update_id: projectUpdate.id,
|
|
256
|
+
status,
|
|
257
|
+
project_name: projectName,
|
|
258
|
+
project_id,
|
|
259
|
+
url: project.url ?? "",
|
|
260
|
+
scm_type: project.scm_type ?? "",
|
|
261
|
+
last_updated: project.last_updated ?? "",
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
267
|
+
return { output: "Request was aborted." };
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
output: `[awx-sync-project] Unexpected error syncing project ${project_id}: ` +
|
|
271
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
/**
|
|
277
|
+
* List AWX job templates.
|
|
278
|
+
*
|
|
279
|
+
* Fetches job templates from the AWX /api/v2/job_templates/ endpoint,
|
|
280
|
+
* consolidating results across pages up to a configurable page cap.
|
|
281
|
+
* Results are sorted by name. Supports per-page size override and
|
|
282
|
+
* returns a warning when the page cap limits results.
|
|
283
|
+
*
|
|
284
|
+
* The per-page timeout budget is derived from the tool-level timeout
|
|
285
|
+
* divided by (maxPages + 1).
|
|
286
|
+
*/
|
|
287
|
+
"awx-list-templates": tool({
|
|
288
|
+
description: [
|
|
289
|
+
"List AWX job templates with pagination. Fetches templates from",
|
|
290
|
+
"/api/v2/job_templates/, consolidating across pages up to a",
|
|
291
|
+
"configurable cap. Results sorted by name. Supports page size",
|
|
292
|
+
"override. Returns warning when page cap limits results.",
|
|
293
|
+
].join(" "),
|
|
294
|
+
args: {
|
|
295
|
+
pageSize: z
|
|
296
|
+
.number()
|
|
297
|
+
.int()
|
|
298
|
+
.min(1)
|
|
299
|
+
.max(200)
|
|
300
|
+
.optional()
|
|
301
|
+
.describe("Items per page (1-200, default: 50)"),
|
|
302
|
+
maxPages: z
|
|
303
|
+
.number()
|
|
304
|
+
.int()
|
|
305
|
+
.min(0)
|
|
306
|
+
.optional()
|
|
307
|
+
.describe("Maximum pages to fetch (0 = no cap, default: 5)"),
|
|
308
|
+
},
|
|
309
|
+
async execute(args, context) {
|
|
310
|
+
// Respect the abort signal
|
|
311
|
+
if (context.abort?.aborted) {
|
|
312
|
+
return { output: "Request was aborted." };
|
|
313
|
+
}
|
|
314
|
+
const awxClient = await getAwxClient();
|
|
315
|
+
if (!awxClient) {
|
|
316
|
+
return {
|
|
317
|
+
output: "AWX client not available. Configure a baseUrl in " +
|
|
318
|
+
"opencode.jsonc and store your Personal Access Token " +
|
|
319
|
+
"via the plugin auth prompt.",
|
|
320
|
+
metadata: {
|
|
321
|
+
count: 0,
|
|
322
|
+
results: [],
|
|
323
|
+
warning: "AWX client not available. Configure a baseUrl in " +
|
|
324
|
+
"opencode.jsonc and store your Personal Access Token " +
|
|
325
|
+
"via the plugin auth prompt.",
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const result = await listTemplates(awxClient, 30_000, {
|
|
331
|
+
pageSize: args.pageSize,
|
|
332
|
+
maxPages: args.maxPages,
|
|
333
|
+
}, context.abort);
|
|
334
|
+
const output = `Found ${result.count} template(s).`;
|
|
335
|
+
return {
|
|
336
|
+
output: result.warning ? `${output} Warning: ${result.warning}` : output,
|
|
337
|
+
metadata: result,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
342
|
+
return {
|
|
343
|
+
output: `Failed to fetch templates: ${message}`,
|
|
344
|
+
metadata: {
|
|
345
|
+
count: 0,
|
|
346
|
+
results: [],
|
|
347
|
+
warning: `Failed to fetch templates: ${message}`,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
/**
|
|
354
|
+
* List AWX projects with pagination.
|
|
355
|
+
*
|
|
356
|
+
* Fetches projects from the AWX /api/v2/projects/ endpoint,
|
|
357
|
+
* consolidating results across multiple pages up to a configurable
|
|
358
|
+
* page cap. Results are sorted alphabetically by name.
|
|
359
|
+
*
|
|
360
|
+
* Pagination behavior:
|
|
361
|
+
* - Default: up to 5 pages × 50 items/page = 250 items max
|
|
362
|
+
* - If more pages exist beyond the cap, returns a warning field
|
|
363
|
+
* - Per-page timeout: total tool timeout / (maxPages + 1)
|
|
364
|
+
*/
|
|
365
|
+
"awx-list-projects": tool({
|
|
366
|
+
description: [
|
|
367
|
+
"List AWX projects with pagination. Fetches projects from",
|
|
368
|
+
"the AWX /api/v2/projects/ endpoint, consolidating results",
|
|
369
|
+
"across multiple pages up to a configurable page cap.",
|
|
370
|
+
"Results are sorted alphabetically by name.",
|
|
371
|
+
].join(" "),
|
|
372
|
+
args: {
|
|
373
|
+
maxPages: z
|
|
374
|
+
.number()
|
|
375
|
+
.int()
|
|
376
|
+
.min(1)
|
|
377
|
+
.max(100)
|
|
378
|
+
.optional()
|
|
379
|
+
.describe("Maximum pages to fetch (default: 5, max: 100)."),
|
|
380
|
+
pageSize: z
|
|
381
|
+
.number()
|
|
382
|
+
.int()
|
|
383
|
+
.min(1)
|
|
384
|
+
.max(200)
|
|
385
|
+
.optional()
|
|
386
|
+
.describe("Items per page (default: 50, max: 200)."),
|
|
387
|
+
timeout: z
|
|
388
|
+
.number()
|
|
389
|
+
.int()
|
|
390
|
+
.min(1_000)
|
|
391
|
+
.optional()
|
|
392
|
+
.describe("Total tool timeout in milliseconds (default: 30000)."),
|
|
393
|
+
},
|
|
394
|
+
async execute(args, context) {
|
|
395
|
+
if (context.abort?.aborted) {
|
|
396
|
+
return { output: "Request was aborted." };
|
|
397
|
+
}
|
|
398
|
+
const awxClient = await getAwxClient();
|
|
399
|
+
if (!awxClient) {
|
|
400
|
+
return {
|
|
401
|
+
output: "[stub] list-projects: AWX client not available. " +
|
|
402
|
+
"Configure a baseUrl in opencode.jsonc and store your " +
|
|
403
|
+
"Personal Access Token via the plugin auth prompt.",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const result = await listProjects(awxClient, {
|
|
408
|
+
maxPages: args.maxPages,
|
|
409
|
+
pageSize: args.pageSize,
|
|
410
|
+
timeout: args.timeout,
|
|
411
|
+
abortSignal: context.abort,
|
|
412
|
+
});
|
|
413
|
+
return {
|
|
414
|
+
output: `Found ${result.count} project(s).`,
|
|
415
|
+
metadata: result,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
420
|
+
return {
|
|
421
|
+
output: `Failed to list projects: ${message}`,
|
|
422
|
+
metadata: { error: message },
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
/**
|
|
428
|
+
* Launch an AWX job template with extra-vars transforms.
|
|
429
|
+
*
|
|
430
|
+
* Runs the transforms pipeline (SCM URL normalization, git branch
|
|
431
|
+
* inference, required vars validation) before calling the AWX launch
|
|
432
|
+
* API. If any transform fails, the launch is aborted with actionable
|
|
433
|
+
* error messages.
|
|
434
|
+
*
|
|
435
|
+
* Returns a JSON string with:
|
|
436
|
+
* - jobId: The AWX job ID (0 if transforms failed)
|
|
437
|
+
* - jobStatus: The AWX job status ("failed" if transforms failed)
|
|
438
|
+
* - warnings: Non-fatal transforms warnings
|
|
439
|
+
* - errors: Fatal transforms errors (empty on success)
|
|
440
|
+
*/
|
|
441
|
+
"awx-launch-job": tool({
|
|
442
|
+
description: [
|
|
443
|
+
"Launch an AWX job template by ID with extra-vars transforms.",
|
|
444
|
+
"Transforms SCM URLs (SSH→HTTPS), infers git branches from",
|
|
445
|
+
"refs/heads/ refs, and validates required variables before",
|
|
446
|
+
"calling the AWX launch API. If any transform fails, the",
|
|
447
|
+
"launch is aborted and an error is returned.",
|
|
448
|
+
].join(" "),
|
|
449
|
+
args: {
|
|
450
|
+
template_id: z
|
|
451
|
+
.number()
|
|
452
|
+
.int()
|
|
453
|
+
.positive()
|
|
454
|
+
.describe("The AWX job template ID to launch."),
|
|
455
|
+
extra_vars: z
|
|
456
|
+
.record(z.string(), z.unknown())
|
|
457
|
+
.optional()
|
|
458
|
+
.describe("Extra variables to pass to the job template. Transforms:" +
|
|
459
|
+
" scm_url (SSH→HTTPS), scm_branch (refs/heads/→short name)," +
|
|
460
|
+
" plus required vars validation (inventory, scm_url, scm_branch)."),
|
|
461
|
+
},
|
|
462
|
+
async execute(args, context) {
|
|
463
|
+
// Respect the abort signal
|
|
464
|
+
if (context.abort?.aborted) {
|
|
465
|
+
return { output: "Request was aborted." };
|
|
466
|
+
}
|
|
467
|
+
const awxClient = await getAwxClient();
|
|
468
|
+
if (!awxClient) {
|
|
469
|
+
return {
|
|
470
|
+
output: "AWX client not available. Configure a baseUrl in" +
|
|
471
|
+
" opencode.jsonc and store your Personal Access Token" +
|
|
472
|
+
" via the plugin auth prompt.",
|
|
473
|
+
metadata: {
|
|
474
|
+
jobId: 0,
|
|
475
|
+
jobStatus: "failed",
|
|
476
|
+
warnings: [],
|
|
477
|
+
errors: [
|
|
478
|
+
"AWX client not available. Configure a baseUrl in" +
|
|
479
|
+
" opencode.jsonc and store your Personal Access Token" +
|
|
480
|
+
" via the plugin auth prompt.",
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const result = await launchJob(awxClient, args.template_id, args.extra_vars, { abortSignal: context.abort });
|
|
487
|
+
const output = result.jobId > 0
|
|
488
|
+
? `Job ${result.jobId} launched (${result.jobStatus}).`
|
|
489
|
+
: "Launch aborted due to transform errors.";
|
|
490
|
+
return {
|
|
491
|
+
output,
|
|
492
|
+
metadata: result,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
497
|
+
return {
|
|
498
|
+
output: `Failed to launch job: ${message}`,
|
|
499
|
+
metadata: {
|
|
500
|
+
jobId: 0,
|
|
501
|
+
jobStatus: "failed",
|
|
502
|
+
warnings: [],
|
|
503
|
+
errors: [message],
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
}),
|
|
509
|
+
/**
|
|
510
|
+
* Fetch job status from AWX.
|
|
511
|
+
*
|
|
512
|
+
* Retrieves detailed job information from /api/v2/jobs/<id>/
|
|
513
|
+
* and returns it formatted according to the JobDetailOutput v1.0
|
|
514
|
+
* contract. Optionally includes full job stdout.
|
|
515
|
+
*/
|
|
516
|
+
"awx-job-status": tool({
|
|
517
|
+
description: [
|
|
518
|
+
"Fetch detailed status of an AWX job by job ID.",
|
|
519
|
+
"Returns structured output matching the JobDetailOutput v1.0",
|
|
520
|
+
"contract: job metadata, resolved related resource names,",
|
|
521
|
+
"host status counts, derived boolean flags, warnings, and errors.",
|
|
522
|
+
"Supports optional --include-stdout to include the full job",
|
|
523
|
+
"console output as a string.",
|
|
524
|
+
].join(" "),
|
|
525
|
+
args: {
|
|
526
|
+
job_id: z
|
|
527
|
+
.number()
|
|
528
|
+
.int()
|
|
529
|
+
.positive()
|
|
530
|
+
.describe("The numeric ID of the AWX job to check."),
|
|
531
|
+
include_stdout: z
|
|
532
|
+
.boolean()
|
|
533
|
+
.optional()
|
|
534
|
+
.describe("If true, fetch and include the full job stdout text."),
|
|
535
|
+
},
|
|
536
|
+
async execute(args, context) {
|
|
537
|
+
// Respect the abort signal
|
|
538
|
+
if (context.abort?.aborted) {
|
|
539
|
+
return { output: "Request was aborted." };
|
|
540
|
+
}
|
|
541
|
+
const awxClient = await getAwxClient();
|
|
542
|
+
if (!awxClient) {
|
|
543
|
+
return {
|
|
544
|
+
output: "awx-job-status: AWX client not available. " +
|
|
545
|
+
"Configure a baseUrl in opencode.jsonc and store your " +
|
|
546
|
+
"Personal Access Token via the plugin auth prompt.",
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
const result = await fetchJobStatus(awxClient, args.job_id, args.include_stdout, context.abort);
|
|
551
|
+
const status = result?.job?.status ?? "unknown";
|
|
552
|
+
return {
|
|
553
|
+
output: `Job ${args.job_id} status: ${status}`,
|
|
554
|
+
metadata: result,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
559
|
+
return {
|
|
560
|
+
output: `awx-job-status error: ${message}`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
}),
|
|
565
|
+
/**
|
|
566
|
+
* Returns the current status of an AWX job by job ID.
|
|
567
|
+
*
|
|
568
|
+
* This is a NON-BLOCKING tool — it returns immediately with the current
|
|
569
|
+
* job status without waiting for job completion. It calls the AWX API
|
|
570
|
+
* GET /api/v2/jobs/<id>/ to verify the job exists and returns its
|
|
571
|
+
* current status.
|
|
572
|
+
*
|
|
573
|
+
* ## Agent-Side Polling Pattern
|
|
574
|
+
*
|
|
575
|
+
* To wait for job completion, the agent should call `awx-job-status`
|
|
576
|
+
* in a loop, checking for a terminal status (successful, failed, etc.).
|
|
577
|
+
*
|
|
578
|
+
* ## Orphaned Job Warning
|
|
579
|
+
*
|
|
580
|
+
* If the agent session is interrupted, the launched job continues
|
|
581
|
+
* running on AAP. Skills using this tool should set
|
|
582
|
+
* max_poll_attempts and recommend a job timeout to avoid orphaned
|
|
583
|
+
* jobs consuming cluster resources indefinitely.
|
|
584
|
+
*/
|
|
585
|
+
"awx-wait-job": tool({
|
|
586
|
+
description: [
|
|
587
|
+
"Returns the current status of an AWX job by job ID.",
|
|
588
|
+
"",
|
|
589
|
+
"NON-BLOCKING: This tool returns immediately without polling.",
|
|
590
|
+
"The agent should call awx-job-status in a loop to wait for completion.",
|
|
591
|
+
"",
|
|
592
|
+
"ORPHANED JOB WARNING: If the agent session is interrupted,",
|
|
593
|
+
"the job continues running on AAP. Skills should set",
|
|
594
|
+
"max_poll_attempts and recommend job timeout.",
|
|
595
|
+
].join("\n"),
|
|
596
|
+
args: {
|
|
597
|
+
job_id: z
|
|
598
|
+
.number()
|
|
599
|
+
.int()
|
|
600
|
+
.positive()
|
|
601
|
+
.describe("The AWX job ID to check status for"),
|
|
602
|
+
},
|
|
603
|
+
async execute(args, context) {
|
|
604
|
+
// Respect the abort signal
|
|
605
|
+
if (context.abort?.aborted) {
|
|
606
|
+
return { output: "Request was aborted." };
|
|
607
|
+
}
|
|
608
|
+
const awxClient = await getAwxClient();
|
|
609
|
+
if (!awxClient) {
|
|
610
|
+
return {
|
|
611
|
+
output: "awx-wait-job: AWX client not available. " +
|
|
612
|
+
"Configure a baseUrl in opencode.jsonc and store your " +
|
|
613
|
+
"Personal Access Token via the plugin auth prompt.",
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const result = await fetchJobStatus(awxClient, args.job_id, false, context.abort, "awx-wait-job");
|
|
618
|
+
return {
|
|
619
|
+
output: `Job ${args.job_id} status: ${result.job.status}`,
|
|
620
|
+
metadata: result,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
625
|
+
return { output: `awx-wait-job error: ${message}` };
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
/**
|
|
630
|
+
* Get job events from an AWX job.
|
|
631
|
+
*
|
|
632
|
+
* Retrieves job events from the AWX API at
|
|
633
|
+
* `/api/v2/jobs/<job_id>/job_events/`. Supports optional
|
|
634
|
+
* filtering by event type and pagination for jobs with
|
|
635
|
+
* 500+ events.
|
|
636
|
+
*
|
|
637
|
+
* Returns structured JSON with `count`, `results`, and
|
|
638
|
+
* optional `next_page`.
|
|
639
|
+
*/
|
|
640
|
+
"awx-get-job-events": tool({
|
|
641
|
+
description: [
|
|
642
|
+
"Get job events from an AWX job. Retrieves events from",
|
|
643
|
+
"`/api/v2/jobs/<job_id>/job_events/`. Supports optional",
|
|
644
|
+
"filtering by event type (e.g., `playbook_on_task_start`)",
|
|
645
|
+
"and pagination via the `page` parameter.",
|
|
646
|
+
].join(" "),
|
|
647
|
+
args: {
|
|
648
|
+
job_id: z
|
|
649
|
+
.number()
|
|
650
|
+
.int()
|
|
651
|
+
.positive()
|
|
652
|
+
.describe("AWX job ID to retrieve events for"),
|
|
653
|
+
event_filter: z
|
|
654
|
+
.string()
|
|
655
|
+
.optional()
|
|
656
|
+
.describe("Optional event type filter (e.g., 'playbook_on_task_start', 'runner_on_ok')"),
|
|
657
|
+
page: z
|
|
658
|
+
.number()
|
|
659
|
+
.int()
|
|
660
|
+
.positive()
|
|
661
|
+
.optional()
|
|
662
|
+
.describe("Page number for paginated results"),
|
|
663
|
+
},
|
|
664
|
+
async execute(args, context) {
|
|
665
|
+
// Respect the abort signal
|
|
666
|
+
if (context.abort?.aborted) {
|
|
667
|
+
return { output: "Request was aborted." };
|
|
668
|
+
}
|
|
669
|
+
const awxClient = await getAwxClient();
|
|
670
|
+
if (!awxClient) {
|
|
671
|
+
return {
|
|
672
|
+
output: "AWX client not available. Configure a baseUrl in " +
|
|
673
|
+
"opencode.jsonc and store your Personal Access Token " +
|
|
674
|
+
"via the plugin auth prompt.",
|
|
675
|
+
metadata: {
|
|
676
|
+
count: 0,
|
|
677
|
+
results: [],
|
|
678
|
+
next_page: null,
|
|
679
|
+
error: "AWX client not available. Configure a baseUrl in " +
|
|
680
|
+
"opencode.jsonc and store your Personal Access Token " +
|
|
681
|
+
"via the plugin auth prompt.",
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
// Build query parameters
|
|
687
|
+
const params = new URLSearchParams();
|
|
688
|
+
if (args.event_filter) {
|
|
689
|
+
params.set("event", args.event_filter);
|
|
690
|
+
}
|
|
691
|
+
if (args.page) {
|
|
692
|
+
params.set("page", String(args.page));
|
|
693
|
+
}
|
|
694
|
+
const queryString = params.toString();
|
|
695
|
+
const path = `/api/v2/jobs/${args.job_id}/job_events/${queryString ? `?${queryString}` : ""}`;
|
|
696
|
+
const response = await awxClient.request("awx-get-job-events", path, undefined, context.abort);
|
|
697
|
+
if (!response.ok) {
|
|
698
|
+
return {
|
|
699
|
+
output: `AWX API returned status ${response.status}: ${response.statusText}`,
|
|
700
|
+
metadata: {
|
|
701
|
+
count: 0,
|
|
702
|
+
results: [],
|
|
703
|
+
next_page: null,
|
|
704
|
+
error: `AWX API returned status ${response.status}: ${response.statusText}`,
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const data = (await response.json());
|
|
709
|
+
// Extract next_page from the `next` URL if present
|
|
710
|
+
let nextPage = null;
|
|
711
|
+
if (data.next) {
|
|
712
|
+
const nextUrl = new URL(data.next);
|
|
713
|
+
const pageParam = nextUrl.searchParams.get("page");
|
|
714
|
+
if (pageParam) {
|
|
715
|
+
nextPage = Number.parseInt(pageParam, 10);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
output: `Found ${data.count ?? 0} event(s).`,
|
|
720
|
+
metadata: {
|
|
721
|
+
count: data.count ?? 0,
|
|
722
|
+
results: data.results ?? [],
|
|
723
|
+
next_page: nextPage,
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
const message = err instanceof Error
|
|
729
|
+
? err.message
|
|
730
|
+
: "Unknown error fetching job events";
|
|
731
|
+
return {
|
|
732
|
+
output: `Failed to get job events: ${message}`,
|
|
733
|
+
metadata: {
|
|
734
|
+
count: 0,
|
|
735
|
+
results: [],
|
|
736
|
+
next_page: null,
|
|
737
|
+
error: message,
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
}),
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Plugin module — the default export consumed by the OpenCode plugin server.
|
|
748
|
+
*/
|
|
749
|
+
const pluginModule = {
|
|
750
|
+
id: "awx",
|
|
751
|
+
server,
|
|
752
|
+
};
|
|
753
|
+
export default pluginModule;
|
|
754
|
+
//# sourceMappingURL=index.js.map
|