@programinglive/dev-workflow-mcp-server 1.10.0 → 1.12.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.12.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
  [
package/workflow-state.js CHANGED
@@ -408,6 +408,55 @@ async function ensureCompatibilityLinks(primaryPath) {
408
408
  }
409
409
  }
410
410
 
411
+ /**
412
+ * Identitas sesi agen ini.
413
+ *
414
+ * MENGAPA ADA
415
+ * -----------
416
+ * Berkas state dulu disimpan per USER, jadi empat proses MCP berbagi satu
417
+ * `workflow-state.json`. Akibatnya `start_task` di satu sesi mengganti task
418
+ * sesi lain di tengah jalan, dan gerbang kualitas (ready check, tests, docs)
419
+ * ikut dibagi sehingga sesi B bisa memakai hasil sesi A untuk commit.
420
+ *
421
+ * Sekarang task dan gerbangnya milik SESI; `history` tetap milik PROYEK.
422
+ *
423
+ * Urutannya sengaja: env eksplisit dulu (untuk tes dan pemanggil lain), lalu
424
+ * id sesi editor, lalu proses. Jatuh ke proses membuat identitas tetap stabil
425
+ * walau modul di-reload dalam proses yang sama.
426
+ */
427
+ export function resolveSessionKey(env = process.env) {
428
+ for (const candidate of [env.DEV_WORKFLOW_SESSION_ID, env.OPENCODE_SESSION_ID, env.CLIENT_SESSION_ID]) {
429
+ const value = typeof candidate === "string" ? candidate.trim() : "";
430
+ if (value) return sanitizeSessionKey(value);
431
+ }
432
+ return `pid-${process.pid}`;
433
+ }
434
+
435
+ function sanitizeSessionKey(value) {
436
+ const cleaned = String(value).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
437
+ return (cleaned || "session").slice(0, 64);
438
+ }
439
+
440
+ /** Nama berkas state untuk sesi ini, di direktori user yang sama. */
441
+ function sessionStateFilename(env = process.env) {
442
+ return `workflow-state.${resolveSessionKey(env)}.json`;
443
+ }
444
+
445
+ /**
446
+ * Berkas tempat `history` bersama disimpan.
447
+ *
448
+ * Dalam tata letak nyata (`.state/users/<user>/`) history dipisahkan dari state
449
+ * sesi supaya catatan proyek tidak terpecah per sesi. Pemanggil yang memberi
450
+ * path eksplisit di luar tata letak itu tetap memakai satu berkas, persis
451
+ * seperti sebelumnya.
452
+ */
453
+ export function resolveHistoryFile(stateFilePath) {
454
+ const resolved = path.resolve(stateFilePath);
455
+ const dir = path.dirname(resolved);
456
+ const inUserTree = path.basename(path.dirname(dir)) === "users";
457
+ return inUserTree ? path.join(dir, STATE_FILENAME) : resolved;
458
+ }
459
+
411
460
  export function resolveStateFile() {
412
461
  const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
413
462
  if (customPath) {
@@ -438,12 +487,15 @@ export function resolveStateFile() {
438
487
 
439
488
  const stateDir = path.join(projectRoot, STATE_DIR);
440
489
  const userDir = getUserScopedDir(stateDir);
441
- return path.join(userDir, STATE_FILENAME);
490
+ return path.join(userDir, sessionStateFilename());
442
491
  }
443
492
 
444
493
  export class WorkflowState {
445
494
  constructor(stateFilePath = resolveStateFile()) {
446
495
  this.stateFile = stateFilePath;
496
+ // History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
497
+ // berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
498
+ this.historyFile = resolveHistoryFile(stateFilePath);
447
499
  this.state = {
448
500
  currentPhase: "idle",
449
501
  taskDescription: "",
@@ -482,6 +534,7 @@ export class WorkflowState {
482
534
  // Ensure compatibility mirrors exist whenever the primary file is present
483
535
  // Run this as non-blocking to speed up startup
484
536
  ensureCompatibilityLinks(this.stateFile).catch(() => { });
537
+ await this.mergeSharedHistory();
485
538
  if (typeof this.state.testsCreated !== "boolean") {
486
539
  this.state.testsCreated = false;
487
540
  }
@@ -530,9 +583,61 @@ export class WorkflowState {
530
583
  }
531
584
  // Missing file is fine; bootstrap fresh artifacts for downstream consumers.
532
585
  await this.ensurePrimaryFile();
586
+ // Sesi baru tetap harus melihat riwayat proyek yang sudah ada.
587
+ await this.mergeSharedHistory();
533
588
  }
534
589
  }
535
590
 
591
+ /**
592
+ * Menyatukan `history` sesi ini dengan `history` bersama.
593
+ *
594
+ * History adalah catatan proyek: task yang selesai di satu sesi harus
595
+ * terlihat dari sesi lain, dan entri yang sama tidak boleh berganda walau
596
+ * berkali-kali disimpan.
597
+ */
598
+ async mergeSharedHistory() {
599
+ if (this.historyFile === this.stateFile) {
600
+ return 0;
601
+ }
602
+
603
+ const shared = await this.readHistoryFile();
604
+ const merged = this.mergeDiskHistory(shared);
605
+ if (merged > 0) {
606
+ await this.writeHistoryFile();
607
+ }
608
+ return merged;
609
+ }
610
+
611
+ async readHistoryFile() {
612
+ try {
613
+ const data = await fs.readFile(this.historyFile, "utf-8");
614
+ return JSON.parse(data);
615
+ } catch {
616
+ return null;
617
+ }
618
+ }
619
+
620
+ /** Menulis hanya `history` ke berkas bersama, tanpa task milik sesi ini. */
621
+ async writeHistoryFile() {
622
+ if (this.historyFile === this.stateFile) {
623
+ return;
624
+ }
625
+
626
+ const shared = (await this.readHistoryFile()) || {};
627
+ const merged = Array.isArray(shared.history) ? shared.history.slice() : [];
628
+ const seen = new Set(merged.map((entry) => this.historyKey(entry)));
629
+ for (const entry of this.state.history || []) {
630
+ const key = this.historyKey(entry);
631
+ if (!seen.has(key)) {
632
+ merged.push(entry);
633
+ seen.add(key);
634
+ }
635
+ }
636
+
637
+ await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
638
+ await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
639
+ }
640
+
536
641
  async ensurePrimaryFile() {
537
642
  let created = false;
538
643
  try {
@@ -558,6 +663,9 @@ export class WorkflowState {
558
663
  updatedAt: new Date().toISOString(),
559
664
  };
560
665
  await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
666
+ // History ditulis ke berkas bersama supaya sesi lain melihat task yang
667
+ // selesai di sini, sementara task yang sedang berjalan tetap milik sesi.
668
+ await this.writeHistoryFile();
561
669
  await ensureCompatibilityLinks(this.stateFile);
562
670
  await this.updateProjectSummary();
563
671
  await this.syncToDatabase();