pi-factory-gate 1.0.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/AGENTS.md +78 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/package.json +51 -0
- package/src/config.ts +32 -0
- package/src/helpers.ts +114 -0
- package/src/index.ts +45 -0
- package/src/tools/agents.ts +119 -0
- package/src/tools/environments.ts +161 -0
- package/src/tools/events.ts +104 -0
- package/src/tools/jobs.ts +330 -0
- package/src/tools/workers.ts +62 -0
- package/src/tools/workflows.ts +130 -0
- package/src/types.ts +120 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { factoryApi, statusIcon, formatTimestamp } from "../helpers";
|
|
5
|
+
import type { EnvironmentInfo } from "../types";
|
|
6
|
+
|
|
7
|
+
// ─── List Environments ─────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const listEnvironmentsTool = {
|
|
10
|
+
name: "factory_list_environments" as const,
|
|
11
|
+
label: "List Environments",
|
|
12
|
+
description:
|
|
13
|
+
"List all registered environments (workspaces) in the wrok.in factory. Each environment maps a git repo to a named workspace.",
|
|
14
|
+
parameters: Type.Object({}),
|
|
15
|
+
async execute(_id: string, _p: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
16
|
+
const config = loadConfig(ctx.cwd);
|
|
17
|
+
const r = await factoryApi<{ environments: EnvironmentInfo[] }>(config, "/environments");
|
|
18
|
+
|
|
19
|
+
if (!r.ok || !r.data) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: `❌ Failed to list environments: ${r.error || "unknown"}` }],
|
|
22
|
+
isError: true,
|
|
23
|
+
details: {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const envs = r.data.environments || [];
|
|
28
|
+
if (envs.length === 0) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: "No environments registered." }],
|
|
31
|
+
details: { count: 0 },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const lines = [`📁 Factory Environments (${envs.length})`, ""];
|
|
36
|
+
for (const env of envs) {
|
|
37
|
+
const icon = statusIcon(env.status);
|
|
38
|
+
const created = formatTimestamp(env.createdAt);
|
|
39
|
+
lines.push(` ${icon} ${env.name}`);
|
|
40
|
+
lines.push(` repo: ${env.repo}`);
|
|
41
|
+
lines.push(` branch: ${env.baseBranch} | status: ${env.status}`);
|
|
42
|
+
lines.push(` created: ${created}`);
|
|
43
|
+
if (env.description) {
|
|
44
|
+
lines.push(` desc: ${env.description.slice(0, 100)}`);
|
|
45
|
+
}
|
|
46
|
+
if (env.lastCommit) {
|
|
47
|
+
lines.push(` commit: ${env.lastCommit.slice(0, 8)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
53
|
+
details: { count: envs.length },
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ─── Get Environment ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export const getEnvironmentTool = {
|
|
61
|
+
name: "factory_get_environment" as const,
|
|
62
|
+
label: "Get Environment Detail",
|
|
63
|
+
description:
|
|
64
|
+
"Get detailed information about a specific factory environment, including repo, branch, status, and last commit.",
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
name: Type.String({ description: "Environment name" }),
|
|
67
|
+
}),
|
|
68
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
69
|
+
const config = loadConfig(ctx.cwd);
|
|
70
|
+
const r = await factoryApi<EnvironmentInfo>(
|
|
71
|
+
config,
|
|
72
|
+
`/environments/${encodeURIComponent(params.name)}`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!r.ok || !r.data) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: `❌ Environment "${params.name}" not found: ${r.error || "unknown"}` }],
|
|
78
|
+
isError: true,
|
|
79
|
+
details: {},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const env = r.data;
|
|
84
|
+
const icon = statusIcon(env.status);
|
|
85
|
+
const created = formatTimestamp(env.createdAt);
|
|
86
|
+
|
|
87
|
+
const lines = [
|
|
88
|
+
`${icon} Environment: ${env.name}`,
|
|
89
|
+
` Repo: ${env.repo}`,
|
|
90
|
+
` Base Branch: ${env.baseBranch}`,
|
|
91
|
+
` Status: ${env.status}`,
|
|
92
|
+
` Created: ${created}`,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
if (env.currentBranch) lines.push(` Current Branch: ${env.currentBranch}`);
|
|
96
|
+
if (env.featureBranch) lines.push(` Feature Branch: ${env.featureBranch}`);
|
|
97
|
+
if (env.lastCommit) lines.push(` Last Commit: ${env.lastCommit.slice(0, 8)}`);
|
|
98
|
+
if (env.description) lines.push(` Description: ${env.description}`);
|
|
99
|
+
if (env.error) lines.push(` Error: ${env.error}`);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
103
|
+
details: env,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ─── Create Environment ────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export const createEnvironmentTool = {
|
|
111
|
+
name: "factory_create_environment" as const,
|
|
112
|
+
label: "Create Environment",
|
|
113
|
+
description:
|
|
114
|
+
"Register a new environment (workspace) in the factory, cloning a git repo into a named workspace.",
|
|
115
|
+
parameters: Type.Object({
|
|
116
|
+
name: Type.String({ description: "Unique environment name" }),
|
|
117
|
+
repo: Type.String({ description: "GitHub repo URL (e.g., https://github.com/user/repo.git)" }),
|
|
118
|
+
branch: Type.Optional(Type.String({ description: "Base branch (default: main)" })),
|
|
119
|
+
}),
|
|
120
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
121
|
+
const config = loadConfig(ctx.cwd);
|
|
122
|
+
|
|
123
|
+
const body: Record<string, string> = {
|
|
124
|
+
name: params.name,
|
|
125
|
+
repo: params.repo,
|
|
126
|
+
};
|
|
127
|
+
if (params.branch) body.branch = params.branch;
|
|
128
|
+
|
|
129
|
+
const r = await factoryApi<{ name: string; status: string }>(
|
|
130
|
+
config,
|
|
131
|
+
"/environments",
|
|
132
|
+
"POST",
|
|
133
|
+
body,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
return {
|
|
138
|
+
content: [{ type: "text", text: `❌ Failed to create environment: ${r.error || "unknown"}` }],
|
|
139
|
+
isError: true,
|
|
140
|
+
details: {},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { name, status: envStatus } = r.data!;
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: [
|
|
150
|
+
`📁 Environment "${name}" created`,
|
|
151
|
+
` Status: ${envStatus}`,
|
|
152
|
+
` Repo: ${params.repo}`,
|
|
153
|
+
"",
|
|
154
|
+
`Check progress: factory_get_environment(name="${name}")`,
|
|
155
|
+
].join("\n"),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
details: r.data,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { factoryApi, formatTimestamp } from "../helpers";
|
|
5
|
+
import type { SystemEvent, FactoryStatus } from "../types";
|
|
6
|
+
|
|
7
|
+
// ─── Get Events ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const getEventsTool = {
|
|
10
|
+
name: "factory_get_events" as const,
|
|
11
|
+
label: "Get Factory Events",
|
|
12
|
+
description:
|
|
13
|
+
"Get recent system events from the factory, such as job creation, completion, worker state changes, and errors. Useful for monitoring factory activity.",
|
|
14
|
+
parameters: Type.Object({
|
|
15
|
+
since: Type.Optional(Type.Number({ description: "Return events since this Unix timestamp (ms)" })),
|
|
16
|
+
limit: Type.Optional(Type.Number({ description: "Max events to return (default: 50)" })),
|
|
17
|
+
}),
|
|
18
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
19
|
+
const config = loadConfig(ctx.cwd);
|
|
20
|
+
const qs = params.since ? `?since=${params.since}` : "";
|
|
21
|
+
const r = await factoryApi<{ events: SystemEvent[] }>(config, `/events${qs}`);
|
|
22
|
+
|
|
23
|
+
if (!r.ok || !r.data) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: `❌ Failed to fetch events: ${r.error || "unknown"}` }],
|
|
26
|
+
isError: true,
|
|
27
|
+
details: {},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const events = r.data.events || [];
|
|
32
|
+
const limit = params.limit || 50;
|
|
33
|
+
const shown = events.slice(-limit);
|
|
34
|
+
|
|
35
|
+
if (shown.length === 0) {
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: "No recent events." }],
|
|
38
|
+
details: { count: 0 },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines = [`📡 Factory Events (last ${shown.length})`, ""];
|
|
43
|
+
for (const ev of shown) {
|
|
44
|
+
const ts = formatTimestamp(ev.ts);
|
|
45
|
+
const id = ev.id.slice(0, 8);
|
|
46
|
+
lines.push(` [${ts}] ${ev.type}: ${ev.message}${ev.details ? ` — ${ev.details.slice(0, 60)}` : ""}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
51
|
+
details: { count: events.length, shown: shown.length },
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ─── Factory Status ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const statusTool = {
|
|
59
|
+
name: "factory_status" as const,
|
|
60
|
+
label: "Factory Status",
|
|
61
|
+
description:
|
|
62
|
+
"Check if the wrok.in orchestrator is reachable and healthy. Returns the orchestrator ID and status.",
|
|
63
|
+
parameters: Type.Object({}),
|
|
64
|
+
async execute(_id: string, _p: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
65
|
+
const config = loadConfig(ctx.cwd);
|
|
66
|
+
const r = await factoryApi<FactoryStatus>(config, "/");
|
|
67
|
+
|
|
68
|
+
if (!r.ok) {
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: [
|
|
74
|
+
`🔴 Factory is unreachable`,
|
|
75
|
+
` URL: ${config.orchestratorUrl}`,
|
|
76
|
+
` Error: ${r.error || "connection failed"}`,
|
|
77
|
+
"",
|
|
78
|
+
`Check: Is the orchestrator running? Is .factoryrc.yml configured correctly?`,
|
|
79
|
+
].join("\n"),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
isError: true,
|
|
83
|
+
details: { reachable: false },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const status = r.data!;
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
91
|
+
type: "text",
|
|
92
|
+
text: [
|
|
93
|
+
`🟢 Factory is healthy`,
|
|
94
|
+
` Orchestrator: ${status.id}`,
|
|
95
|
+
` Service: ${status.service}`,
|
|
96
|
+
` Status: ${status.status}`,
|
|
97
|
+
` URL: ${config.orchestratorUrl}`,
|
|
98
|
+
].join("\n"),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
details: { ...status, reachable: true },
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { factoryApi, statusIcon, formatTimestamp, formatDuration } from "../helpers";
|
|
5
|
+
import type { JobInfo } from "../types";
|
|
6
|
+
|
|
7
|
+
// ─── Create Job ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const createJobTool = {
|
|
10
|
+
name: "factory_create_job" as const,
|
|
11
|
+
label: "Create Factory Job",
|
|
12
|
+
description:
|
|
13
|
+
"Dispatch a new job to the wrok.in AI factory. Supports single-agent, chain (sequential), and parallel modes. Jobs run in a named environment or against a direct repo.",
|
|
14
|
+
parameters: Type.Object({
|
|
15
|
+
mode: Type.String({ description: "Job mode: 'single', 'chain', or 'parallel'" }),
|
|
16
|
+
agent: Type.Optional(Type.String({ description: "Agent name for single mode" })),
|
|
17
|
+
task: Type.Optional(Type.String({ description: "Task prompt/instruction for single mode" })),
|
|
18
|
+
steps: Type.Optional(
|
|
19
|
+
Type.String({
|
|
20
|
+
description:
|
|
21
|
+
"Chain steps as JSON array: [{\"agent\":\"name\",\"task\":\"prompt\"}, ...]",
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
tasks: Type.Optional(
|
|
25
|
+
Type.String({
|
|
26
|
+
description:
|
|
27
|
+
"Parallel tasks as JSON array: [{\"agent\":\"name\",\"task\":\"prompt\"}, ...]",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
environment: Type.Optional(Type.String({ description: "Named environment to run in" })),
|
|
31
|
+
repo: Type.Optional(Type.String({ description: "Direct GitHub repo URL to clone" })),
|
|
32
|
+
branch: Type.Optional(Type.String({ description: "Base branch (default: main)" })),
|
|
33
|
+
force: Type.Optional(Type.Boolean({ description: "Bypass exclusive-agent deduplication" })),
|
|
34
|
+
}),
|
|
35
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
36
|
+
const config = loadConfig(ctx.cwd);
|
|
37
|
+
|
|
38
|
+
// Build the task payload
|
|
39
|
+
let taskPayload: Record<string, unknown>;
|
|
40
|
+
if (params.mode === "single") {
|
|
41
|
+
if (!params.agent || !params.task) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text", text: "❌ Single mode requires 'agent' and 'task' parameters." }],
|
|
44
|
+
isError: true,
|
|
45
|
+
details: {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
taskPayload = { mode: "single", agent: params.agent, task: params.task };
|
|
49
|
+
} else if (params.mode === "chain") {
|
|
50
|
+
let steps: unknown[];
|
|
51
|
+
try {
|
|
52
|
+
steps = typeof params.steps === "string" ? JSON.parse(params.steps) : params.steps;
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
content: [{ type: "text", text: "❌ Invalid 'steps' JSON. Must be an array of {agent, task} objects." }],
|
|
56
|
+
isError: true,
|
|
57
|
+
details: {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: "❌ Chain mode requires 'steps' — an array of {agent, task} objects." }],
|
|
63
|
+
isError: true,
|
|
64
|
+
details: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
taskPayload = { mode: "chain", steps };
|
|
68
|
+
} else if (params.mode === "parallel") {
|
|
69
|
+
let tasks: unknown[];
|
|
70
|
+
try {
|
|
71
|
+
tasks = typeof params.tasks === "string" ? JSON.parse(params.tasks) : params.tasks;
|
|
72
|
+
} catch {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: "❌ Invalid 'tasks' JSON. Must be an array of {agent, task} objects." }],
|
|
75
|
+
isError: true,
|
|
76
|
+
details: {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: "❌ Parallel mode requires 'tasks' — an array of {agent, task} objects." }],
|
|
82
|
+
isError: true,
|
|
83
|
+
details: {},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
taskPayload = { mode: "parallel", tasks };
|
|
87
|
+
} else {
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: `❌ Invalid mode: "${params.mode}". Use: single, chain, or parallel.` }],
|
|
90
|
+
isError: true,
|
|
91
|
+
details: {},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body: Record<string, unknown> = { task: taskPayload };
|
|
96
|
+
if (params.environment) body.environment = params.environment;
|
|
97
|
+
else if (config.defaultEnvironment) body.environment = config.defaultEnvironment;
|
|
98
|
+
if (params.repo) body.repo = params.repo;
|
|
99
|
+
if (params.branch) body.baseBranch = params.branch;
|
|
100
|
+
if (params.force) body.force = true;
|
|
101
|
+
|
|
102
|
+
const r = await factoryApi<{ jobId: string; status: string; environment?: string }>(
|
|
103
|
+
config,
|
|
104
|
+
"/jobs",
|
|
105
|
+
"POST",
|
|
106
|
+
body,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: "text", text: `❌ Failed to create job: ${r.error || "unknown"}` }],
|
|
112
|
+
isError: true,
|
|
113
|
+
details: {},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { jobId, status: jobStatus, environment } = r.data!;
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
text: [
|
|
123
|
+
`🚀 Job created!`,
|
|
124
|
+
` ID: ${jobId}`,
|
|
125
|
+
` Status: ${jobStatus}`,
|
|
126
|
+
` Environment: ${environment || "(none)"}`,
|
|
127
|
+
"",
|
|
128
|
+
`Track it: factory_get_job(id="${jobId}")`,
|
|
129
|
+
].join("\n"),
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
details: { jobId, status: jobStatus, environment },
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ─── Get Job ───────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export const getJobTool = {
|
|
140
|
+
name: "factory_get_job" as const,
|
|
141
|
+
label: "Get Job Status",
|
|
142
|
+
description:
|
|
143
|
+
"Get the current status and details of a factory job by its ID. Includes results for completed jobs.",
|
|
144
|
+
parameters: Type.Object({
|
|
145
|
+
id: Type.String({ description: "Job ID (returned from factory_create_job)" }),
|
|
146
|
+
}),
|
|
147
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
148
|
+
const config = loadConfig(ctx.cwd);
|
|
149
|
+
const r = await factoryApi<{ job: JobInfo }>(config, `/jobs/${encodeURIComponent(params.id)}`);
|
|
150
|
+
|
|
151
|
+
if (!r.ok || !r.data) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: `❌ Job "${params.id}" not found: ${r.error || "unknown"}` }],
|
|
154
|
+
isError: true,
|
|
155
|
+
details: {},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const job = r.data.job;
|
|
160
|
+
const icon = statusIcon(job.status);
|
|
161
|
+
const created = formatTimestamp(job.createdAt);
|
|
162
|
+
const started = job.startedAt ? formatTimestamp(job.startedAt) : "—";
|
|
163
|
+
const completed = job.completedAt ? formatTimestamp(job.completedAt) : "—";
|
|
164
|
+
const duration = job.startedAt && job.completedAt
|
|
165
|
+
? formatDuration(job.completedAt - job.startedAt)
|
|
166
|
+
: job.startedAt
|
|
167
|
+
? "running..."
|
|
168
|
+
: "—";
|
|
169
|
+
|
|
170
|
+
const lines = [
|
|
171
|
+
`${icon} Job ${job.id.slice(0, 8)}`,
|
|
172
|
+
` Status: ${job.status}`,
|
|
173
|
+
` Mode: ${job.task.mode}`,
|
|
174
|
+
` Environment: ${job.environment || "(none)"}`,
|
|
175
|
+
` Repo: ${job.repo || "(none)"}`,
|
|
176
|
+
` Branch: ${job.branch || "(none)"}`,
|
|
177
|
+
` Created: ${created}`,
|
|
178
|
+
` Started: ${started}`,
|
|
179
|
+
` Completed: ${completed}`,
|
|
180
|
+
` Duration: ${duration}`,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
// Task details
|
|
184
|
+
if (job.task.mode === "single" && "agent" in job.task) {
|
|
185
|
+
lines.push(` Agent: ${job.task.agent}`);
|
|
186
|
+
lines.push(` Task: ${(job.task as any).task?.slice(0, 120) || "—"}`);
|
|
187
|
+
} else if (job.task.mode === "chain" && "steps" in job.task) {
|
|
188
|
+
lines.push(` Steps: ${(job.task as any).steps?.length || 0}`);
|
|
189
|
+
} else if (job.task.mode === "parallel" && "tasks" in job.task) {
|
|
190
|
+
lines.push(` Tasks: ${(job.task as any).tasks?.length || 0}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (job.workerId) lines.push(` Worker: ${job.workerId.slice(0, 8)}`);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
197
|
+
details: job,
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// ─── List Jobs ─────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export const listJobsTool = {
|
|
205
|
+
name: "factory_list_jobs" as const,
|
|
206
|
+
label: "List Factory Jobs",
|
|
207
|
+
description:
|
|
208
|
+
"List all jobs in the factory, including pending, running, completed, and failed jobs.",
|
|
209
|
+
parameters: Type.Object({
|
|
210
|
+
limit: Type.Optional(Type.Number({ description: "Max jobs to return (default: 20)" })),
|
|
211
|
+
}),
|
|
212
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
213
|
+
const config = loadConfig(ctx.cwd);
|
|
214
|
+
const r = await factoryApi<{ jobs: JobInfo[] }>(config, "/jobs");
|
|
215
|
+
|
|
216
|
+
if (!r.ok || !r.data) {
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: "text", text: `❌ Failed to list jobs: ${r.error || "unknown"}` }],
|
|
219
|
+
isError: true,
|
|
220
|
+
details: {},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const jobs = r.data.jobs;
|
|
225
|
+
const limit = params.limit || config.defaultLimit;
|
|
226
|
+
const shown = jobs.slice(-limit);
|
|
227
|
+
|
|
228
|
+
if (shown.length === 0) {
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text: "No jobs in the factory." }],
|
|
231
|
+
details: { count: 0 },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const lines = [`📋 Factory Jobs (${jobs.length} total, showing last ${shown.length})`, ""];
|
|
236
|
+
for (const job of shown) {
|
|
237
|
+
const icon = statusIcon(job.status);
|
|
238
|
+
const shortId = job.id.slice(0, 8);
|
|
239
|
+
const mode = job.task.mode;
|
|
240
|
+
const agent = mode === "single" ? (job.task as any).agent : `${mode}:${mode === "chain" ? (job.task as any).steps?.length : (job.task as any).tasks?.length}`;
|
|
241
|
+
const env = job.environment || "—";
|
|
242
|
+
lines.push(` ${icon} ${shortId} ${job.status.padEnd(9)} ${mode.padEnd(8)} ${String(agent).slice(0, 20)} env=${env}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
247
|
+
details: { count: jobs.length, shown: shown.length },
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// ─── Stream Job ────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export const streamJobTool = {
|
|
255
|
+
name: "factory_stream_job" as const,
|
|
256
|
+
label: "Stream Job Output",
|
|
257
|
+
description:
|
|
258
|
+
"Stream live updates from a running job via SSE. Returns the final state when the job completes. Useful for watching long-running chain/parallel jobs.",
|
|
259
|
+
parameters: Type.Object({
|
|
260
|
+
id: Type.String({ description: "Job ID to stream" }),
|
|
261
|
+
timeout: Type.Optional(Type.Number({ description: "Max seconds to wait (default: 120)" })),
|
|
262
|
+
}),
|
|
263
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
264
|
+
const config = loadConfig(ctx.cwd);
|
|
265
|
+
const jobId = params.id;
|
|
266
|
+
const timeout = (params.timeout || 120) * 1000;
|
|
267
|
+
const pollInterval = 2000;
|
|
268
|
+
|
|
269
|
+
const start = Date.now();
|
|
270
|
+
let lastStatus = "";
|
|
271
|
+
|
|
272
|
+
while (Date.now() - start < timeout) {
|
|
273
|
+
const r = await factoryApi<{ job: JobInfo }>(config, `/jobs/${encodeURIComponent(jobId)}`);
|
|
274
|
+
|
|
275
|
+
if (!r.ok || !r.data) {
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: "text", text: `❌ Job "${jobId}" not found: ${r.error || "unknown"}` }],
|
|
278
|
+
isError: true,
|
|
279
|
+
details: {},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const job = r.data.job;
|
|
284
|
+
const status = job.status;
|
|
285
|
+
|
|
286
|
+
if (status === "completed" || status === "failed" || status === "partial") {
|
|
287
|
+
const icon = statusIcon(status);
|
|
288
|
+
const duration = job.completedAt && job.startedAt
|
|
289
|
+
? formatDuration(job.completedAt - job.startedAt)
|
|
290
|
+
: "unknown";
|
|
291
|
+
|
|
292
|
+
const lines = [
|
|
293
|
+
`${icon} Job ${jobId.slice(0, 8)} finished: ${status}`,
|
|
294
|
+
` Duration: ${duration}`,
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
if (job.result) {
|
|
298
|
+
const resultPreview = JSON.stringify(job.result).slice(0, 500);
|
|
299
|
+
lines.push(` Result: ${resultPreview}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
304
|
+
details: job,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Show progress updates when status changes
|
|
309
|
+
if (status !== lastStatus) {
|
|
310
|
+
lastStatus = status;
|
|
311
|
+
// Don't output intermediate states — just wait for completion
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
content: [
|
|
319
|
+
{
|
|
320
|
+
type: "text",
|
|
321
|
+
text: [
|
|
322
|
+
`⏰ Timed out waiting for job ${jobId.slice(0, 8)} after ${timeout / 1000}s.`,
|
|
323
|
+
` The job may still be running. Check back with factory_get_job(id="${jobId}").`,
|
|
324
|
+
].join("\n"),
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
details: { jobId, timeout: timeout / 1000, status: lastStatus },
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { factoryApi, statusIcon } from "../helpers";
|
|
5
|
+
import type { WorkerInfo } from "../types";
|
|
6
|
+
|
|
7
|
+
// ─── List Workers ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const listWorkersTool = {
|
|
10
|
+
name: "factory_list_workers" as const,
|
|
11
|
+
label: "List Factory Workers",
|
|
12
|
+
description:
|
|
13
|
+
"List all registered workers in the factory, including their status, current jobs, and health.",
|
|
14
|
+
parameters: Type.Object({}),
|
|
15
|
+
async execute(_id: string, _p: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
16
|
+
const config = loadConfig(ctx.cwd);
|
|
17
|
+
const r = await factoryApi<{ workers: WorkerInfo[] }>(config, "/workers");
|
|
18
|
+
|
|
19
|
+
if (!r.ok || !r.data) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: `❌ Failed to list workers: ${r.error || "unknown"}` }],
|
|
22
|
+
isError: true,
|
|
23
|
+
details: {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const workers = r.data.workers || [];
|
|
28
|
+
if (workers.length === 0) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: "No workers registered." }],
|
|
31
|
+
details: { count: 0 },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Summary stats
|
|
36
|
+
const idle = workers.filter(w => w.status === "idle").length;
|
|
37
|
+
const busy = workers.filter(w => w.status === "busy").length;
|
|
38
|
+
const offline = workers.filter(w => w.status === "offline").length;
|
|
39
|
+
const totalCompleted = workers.reduce((s, w) => s + w.completedJobs, 0);
|
|
40
|
+
|
|
41
|
+
const lines = [
|
|
42
|
+
`🖥️ Factory Workers (${workers.length})`,
|
|
43
|
+
` 🟢 idle: ${idle} | 🔵 busy: ${busy} | 🔴 offline: ${offline} | ✅ completed: ${totalCompleted}`,
|
|
44
|
+
"",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const w of workers) {
|
|
48
|
+
const icon = statusIcon(w.status);
|
|
49
|
+
const heartbeat = w.lastHeartbeat ? `${Math.round((Date.now() - w.lastHeartbeat) / 1000)}s ago` : "never";
|
|
50
|
+
lines.push(` ${icon} ${w.id.slice(0, 8)} ${w.host}:${w.port} status=${w.status} jobs=${w.completedJobs}`);
|
|
51
|
+
lines.push(` heartbeat: ${heartbeat}${w.environment ? ` env=${w.environment}` : ""}`);
|
|
52
|
+
if (w.currentJobId) {
|
|
53
|
+
lines.push(` current job: ${w.currentJobId.slice(0, 8)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
59
|
+
details: { count: workers.length, idle, busy, offline, totalCompleted },
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|