@programinglive/dev-workflow-mcp-server 1.10.0 → 1.11.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/factory-client.js +84 -2
- package/package.json +1 -1
- package/session-keeper.js +72 -0
- package/tools/handler-modules/factory-handlers.js +20 -8
package/factory-client.js
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* above can be tested without a live Factory.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { createHash } from "crypto";
|
|
23
|
+
import { createHash, randomUUID } from "crypto";
|
|
24
24
|
import os from "os";
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
26
|
+
import path from "path";
|
|
25
27
|
|
|
26
28
|
export const DEFAULT_POCKETBASE_URL = "https://pocketbase.programinglive.com";
|
|
27
29
|
|
|
@@ -89,6 +91,11 @@ export function readFactoryConfig(env = process.env) {
|
|
|
89
91
|
pocketBaseUrl: trimSlash(clean(env.FACTORY_PB_URL) || DEFAULT_POCKETBASE_URL),
|
|
90
92
|
machineId: clean(env.FACTORY_MACHINE_ID),
|
|
91
93
|
deviceId: clean(env.FACTORY_DEVICE_ID),
|
|
94
|
+
// Berkas identitas perangkat yang dipakai bersama dengan CLI Factory.
|
|
95
|
+
// Tanpa ini satu mesin muncul sebagai dua perangkat di papan. Bawaannya
|
|
96
|
+
// jalur yang sama dengan CLI, jadi kedua tool langsung sepakat tanpa
|
|
97
|
+
// konfigurasi tambahan.
|
|
98
|
+
deviceFile: clean(env.FACTORY_DEVICE_FILE) || defaultDeviceFile(),
|
|
92
99
|
agent: clean(env.FACTORY_AGENT) || "dev-workflow-mcp",
|
|
93
100
|
// Sesi editor yang sedang berjalan. Papan Factory memakainya untuk
|
|
94
101
|
// menunjukkan sesi mana yang mengerjakan, bukan hanya perangkatnya.
|
|
@@ -126,11 +133,59 @@ export function deviceIdFor(seed) {
|
|
|
126
133
|
].join("-");
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The device identity file the Factory CLI writes.
|
|
138
|
+
*
|
|
139
|
+
* Keeping this in one place means the CLI and the MCP server cannot drift to
|
|
140
|
+
* different paths, which is what made one machine appear as two devices.
|
|
141
|
+
*/
|
|
142
|
+
export function defaultDeviceFile(env = process.env) {
|
|
143
|
+
return clean(env.FACTORY_DEVICE_FILE) || path.join(os.homedir(), ".config", "programinglive-factory", "device.json");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reads the device id this machine shares with the Factory CLI.
|
|
148
|
+
*
|
|
149
|
+
* The CLI stores a random UUID in `~/.config/programinglive-factory/device.json`.
|
|
150
|
+
* The MCP server used to derive its own id from the machine name, so one
|
|
151
|
+
* machine appeared as two devices on the board and a task held by the other
|
|
152
|
+
* tool looked like another machine's work. Reading and creating the same file
|
|
153
|
+
* keeps one machine to one identity, whichever tool runs first.
|
|
154
|
+
*
|
|
155
|
+
* Returns null when the file cannot be used, so the caller falls back to the
|
|
156
|
+
* name-derived id instead of failing to claim at all.
|
|
157
|
+
*/
|
|
158
|
+
export function sharedDeviceId(deviceFile) {
|
|
159
|
+
if (!deviceFile) return null;
|
|
160
|
+
try {
|
|
161
|
+
const id = JSON.parse(readFileSync(deviceFile, "utf8"))?.id;
|
|
162
|
+
if (typeof id === "string" && UUID_RE.test(id)) return id;
|
|
163
|
+
} catch {
|
|
164
|
+
// Berkas belum ada atau rusak: dibuat di bawah.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const id = randomUUID();
|
|
168
|
+
try {
|
|
169
|
+
mkdirSync(path.dirname(deviceFile), { recursive: true, mode: 0o700 });
|
|
170
|
+
// "wx" gagal kalau proses lain menang balapan; id itu yang dipakai.
|
|
171
|
+
writeFileSync(deviceFile, JSON.stringify({ id }), { flag: "wx", mode: 0o600 });
|
|
172
|
+
return id;
|
|
173
|
+
} catch {
|
|
174
|
+
try {
|
|
175
|
+
const existing = JSON.parse(readFileSync(deviceFile, "utf8"))?.id;
|
|
176
|
+
if (typeof existing === "string" && UUID_RE.test(existing)) return existing;
|
|
177
|
+
} catch {
|
|
178
|
+
// Tetap gagal: biar pemanggil memakai id turunan nama mesin.
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
/** Builds the `device` object Factory expects on every work call. */
|
|
130
185
|
export function deviceFromConfig(config) {
|
|
131
186
|
const name = (config.machineId || os.hostname() || "unknown").slice(0, 100);
|
|
132
187
|
return {
|
|
133
|
-
id: config.deviceId || deviceIdFor(name),
|
|
188
|
+
id: config.deviceId || sharedDeviceId(config.deviceFile) || deviceIdFor(name),
|
|
134
189
|
name,
|
|
135
190
|
platform: String(process.platform).slice(0, 60),
|
|
136
191
|
agent: String(config.agent || "dev-workflow-mcp").slice(0, 40),
|
|
@@ -420,6 +475,33 @@ export async function claimTask(config, { recordId, device, step, takeoverReason
|
|
|
420
475
|
clientSession: clientSessionFromConfig(config),
|
|
421
476
|
});
|
|
422
477
|
const body = await request(config, "/api/work", { method: "POST", body: payload, fetchImpl });
|
|
478
|
+
const session = body.session || null;
|
|
479
|
+
if (!session) return null;
|
|
480
|
+
// Pemanggil perlu tahu kalau ia baru menggeser perangkat lain, supaya bisa
|
|
481
|
+
// melaporkan pengambil alihan itu, bukan menyebutnya klaim biasa.
|
|
482
|
+
return { ...session, tookOver: Boolean(body.tookOver), previousHolder: body.previousHolder || null };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Reports that a work session is still alive, and what it is doing.
|
|
487
|
+
*
|
|
488
|
+
* Factory marks a session stale after 120 seconds without a report, so a
|
|
489
|
+
* claimed task that never reports looks abandoned on the board even while the
|
|
490
|
+
* work continues. `step` is optional: the server keeps the previous value.
|
|
491
|
+
*/
|
|
492
|
+
export async function heartbeatWork(config, { sessionId, deviceId, step, clientSession } = {}, fetchImpl = fetch) {
|
|
493
|
+
if (!sessionId) throw new Error("sessionId is required to report a heartbeat.");
|
|
494
|
+
if (!deviceId) throw new Error("deviceId is required to report a heartbeat.");
|
|
495
|
+
|
|
496
|
+
const text = typeof step === "string" ? step.trim() : "";
|
|
497
|
+
const payload = {
|
|
498
|
+
action: "heartbeat",
|
|
499
|
+
sessionId,
|
|
500
|
+
deviceId,
|
|
501
|
+
...(text ? { step: text.slice(0, 240) } : {}),
|
|
502
|
+
...(clientSession?.id ? { clientSession: { id: String(clientSession.id).slice(0, 120), title: String(clientSession.title || "").slice(0, 200) } } : {}),
|
|
503
|
+
};
|
|
504
|
+
const body = await request(config, "/api/work", { method: "POST", body: payload, fetchImpl });
|
|
423
505
|
return body.session || null;
|
|
424
506
|
}
|
|
425
507
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "MCP server to enforce development workflow discipline",
|
|
5
5
|
"homepage": "https://devworkflow.programinglive.com/",
|
|
6
6
|
"mcpName": "io.github.programinglive/dev-workflow-mcp-server",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps one Factory work session marked alive while this process holds it.
|
|
3
|
+
*
|
|
4
|
+
* Factory shows a session as stale after 120 seconds without a report. The MCP
|
|
5
|
+
* server claims a task once and then goes quiet, so every task it takes looked
|
|
6
|
+
* abandoned on the board almost immediately - not because the work stopped, but
|
|
7
|
+
* because nothing reported it.
|
|
8
|
+
*
|
|
9
|
+
* The keeper is deliberately bounded: it reports only while this process holds
|
|
10
|
+
* a session it started, and it stops the moment the session ends or is taken
|
|
11
|
+
* over. A heartbeat that outlived the claim would lie in the opposite
|
|
12
|
+
* direction, showing "active" for work nobody is doing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { heartbeatWork } from "./factory-client.js";
|
|
16
|
+
|
|
17
|
+
/** How often to report. Comfortably inside the server's 120 second window. */
|
|
18
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
let active = null;
|
|
21
|
+
|
|
22
|
+
async function send(options = {}) {
|
|
23
|
+
if (!active) return null;
|
|
24
|
+
const { config, sessionId, deviceId, step, fetchImpl } = active;
|
|
25
|
+
try {
|
|
26
|
+
return await heartbeatWork(config, { sessionId, deviceId, step }, fetchImpl ?? options.fetchImpl ?? fetch);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
// 409 means the session ended or another device took it over. Retrying
|
|
29
|
+
// would fight the new holder, so stop and let the board show the truth.
|
|
30
|
+
if (error?.status === 409 || error?.status === 404) stopKeeping();
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Starts reporting for a session this process just opened.
|
|
37
|
+
*
|
|
38
|
+
* Replaces any previous session, because one process works on one task at a
|
|
39
|
+
* time; two timers would report a session nobody is holding any more.
|
|
40
|
+
*/
|
|
41
|
+
export function startKeeping(config, session, step, options = {}) {
|
|
42
|
+
stopKeeping();
|
|
43
|
+
const sessionId = session?.id;
|
|
44
|
+
const deviceId = session?.device?.id;
|
|
45
|
+
if (!sessionId || !deviceId) return null;
|
|
46
|
+
|
|
47
|
+
const intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0 ? options.intervalMs : DEFAULT_INTERVAL_MS;
|
|
48
|
+
active = { config, sessionId, deviceId, step: typeof step === "string" ? step : "", fetchImpl: options.fetchImpl, timer: null };
|
|
49
|
+
|
|
50
|
+
const timer = setInterval(() => { void send(); }, intervalMs);
|
|
51
|
+
// Never hold the process open just to report; the session outliving the
|
|
52
|
+
// process is exactly what "stale" is for.
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
active.timer = timer;
|
|
55
|
+
return { sessionId, deviceId };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Stops reporting. Safe to call when nothing is active. */
|
|
59
|
+
export function stopKeeping() {
|
|
60
|
+
if (active?.timer) clearInterval(active.timer);
|
|
61
|
+
active = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The session this process is currently reporting, or null. */
|
|
65
|
+
export function currentSession() {
|
|
66
|
+
return active ? { sessionId: active.sessionId, deviceId: active.deviceId, step: active.step } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Sends one report immediately. Used by tests and after a step change. */
|
|
70
|
+
export async function beatOnce(options = {}) {
|
|
71
|
+
return send(options);
|
|
72
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { textResponse } from "./shared.js";
|
|
2
|
+
import { startKeeping, stopKeeping } from "../../session-keeper.js";
|
|
2
3
|
import {
|
|
3
4
|
readFactoryConfig,
|
|
4
5
|
listTasks,
|
|
@@ -87,18 +88,19 @@ function formatLastSeen(value) {
|
|
|
87
88
|
*/
|
|
88
89
|
function takeoverRequiredMessage(title, holder = {}) {
|
|
89
90
|
const seen = formatLastSeen(holder.lastSeenAt);
|
|
90
|
-
const
|
|
91
|
+
const name = String(holder.deviceName || "").trim();
|
|
92
|
+
const agent = String(holder.agent || "").trim();
|
|
93
|
+
const holderText = name && agent ? `${name} (${agent})` : name || agent;
|
|
91
94
|
return [
|
|
92
|
-
`"${title}"
|
|
95
|
+
`"${title}" is being worked on by another device.`,
|
|
96
|
+
holderText ? `Holder: ${holderText}${seen ? `, last seen ${seen}` : ""}.` : "",
|
|
93
97
|
"",
|
|
94
|
-
|
|
98
|
+
"To take it over, call claim_factory_task again with a takeoverReason of 3-500 characters:",
|
|
99
|
+
' claim_factory_task { "recordId": "<id>", "step": "<step>", "takeoverReason": "<why you are taking over>" }',
|
|
95
100
|
"",
|
|
96
|
-
"
|
|
97
|
-
` claim_factory_task { "recordId": "<id>", "step": "<langkah>", "takeoverReason": "<alasanmu>" }`,
|
|
98
|
-
"",
|
|
99
|
-
"Alasannya dicatat di papan, jadi orang berikutnya tahu kenapa pekerjaan berpindah tangan.",
|
|
101
|
+
"The reason is stored on the board so the next person knows why the work changed hands.",
|
|
100
102
|
]
|
|
101
|
-
.filter((line) => line !==
|
|
103
|
+
.filter((line) => line !== null && line !== undefined)
|
|
102
104
|
.join("\n");
|
|
103
105
|
}
|
|
104
106
|
|
|
@@ -127,6 +129,10 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
|
|
|
127
129
|
const session = await claimTask(config, { recordId, step, takeoverReason });
|
|
128
130
|
if (!session) throw new Error("Factory accepted the claim but returned no work session.");
|
|
129
131
|
|
|
132
|
+
// Tanpa ini sesi jadi "tidak ada kabar" dalam 120 detik padahal sedang
|
|
133
|
+
// dikerjakan, dan papan terlihat penuh task mati.
|
|
134
|
+
startKeeping(config, session, step);
|
|
135
|
+
|
|
130
136
|
if (workflowState) {
|
|
131
137
|
workflowState.reset();
|
|
132
138
|
workflowState.state.currentPhase = "coding";
|
|
@@ -138,6 +144,9 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
|
|
|
138
144
|
return textResponse(
|
|
139
145
|
[
|
|
140
146
|
`Claimed [${task.taskKey || task.id}] ${task.title}`,
|
|
147
|
+
session.tookOver && session.previousHolder
|
|
148
|
+
? `Took over from ${session.previousHolder.deviceName} (${session.previousHolder.agent}). The reason is recorded on the board.`
|
|
149
|
+
: "",
|
|
141
150
|
`Factory status is now running (work session ${session.id}).`,
|
|
142
151
|
task.projectName ? `Project: ${task.projectName}` : "",
|
|
143
152
|
"",
|
|
@@ -186,6 +195,9 @@ export async function handleSyncTaskStatus(args = {}) {
|
|
|
186
195
|
|
|
187
196
|
try {
|
|
188
197
|
const result = await updateTaskStatus(config, { recordId, status, reason, nextAction });
|
|
198
|
+
// Sesi ditutup, jadi berhenti melaporkan: heartbeat setelah ini akan
|
|
199
|
+
// ditolak server dan hanya mengaburkan keadaan papan.
|
|
200
|
+
stopKeeping();
|
|
189
201
|
|
|
190
202
|
return textResponse(
|
|
191
203
|
[
|