@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 +84 -2
- package/package.json +1 -1
- package/session-keeper.js +72 -0
- package/tools/handler-modules/factory-handlers.js +20 -8
- package/workflow-state.js +109 -1
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.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
|
|
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
|
[
|
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,
|
|
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();
|