@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 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.10.0",
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 holderText = [holder.deviceName, holder.agent].filter(Boolean).join(" (") + (holder.agent ? ")" : "");
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}" sedang dikerjakan oleh perangkat lain.`,
95
+ `"${title}" is being worked on by another device.`,
96
+ holderText ? `Holder: ${holderText}${seen ? `, last seen ${seen}` : ""}.` : "",
93
97
  "",
94
- holderText ? `Pemegang sesi: ${holderText}${seen ? `, terakhir terlihat ${seen}` : ""}.` : "",
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
- "Ambil alih dengan menyertakan alasan (3-500 karakter):",
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
  [