@programinglive/dev-workflow-mcp-server 1.11.0 → 1.12.1
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/package.json +1 -1
- package/workflow-state.js +142 -25
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.1",
|
|
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",
|
package/workflow-state.js
CHANGED
|
@@ -408,6 +408,84 @@ 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
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Induk direktori `.state` untuk sebuah berkas state.
|
|
462
|
+
*
|
|
463
|
+
* Dulu ini dihitung dengan empat `dirname` berurutan, yang hanya benar selama
|
|
464
|
+
* tata letaknya tepat `<root>/.state/users/<user>/<berkas>`. Begitu kedalaman
|
|
465
|
+
* berubah (mis. state sesi ditaruh di subdirektori), angka itu diam-diam
|
|
466
|
+
* menghasilkan root yang salah dan baris database tercatat di proyek lain.
|
|
467
|
+
*
|
|
468
|
+
* Sekarang dicari dengan sengaja: naik sampai menemukan direktori `.state`,
|
|
469
|
+
* lalu ambil induknya. Tidak bergantung pada kedalaman.
|
|
470
|
+
*/
|
|
471
|
+
export function resolveProjectRootForState(stateFilePath) {
|
|
472
|
+
const resolved = path.resolve(stateFilePath);
|
|
473
|
+
let current = path.dirname(resolved);
|
|
474
|
+
|
|
475
|
+
for (;;) {
|
|
476
|
+
if (path.basename(current) === STATE_DIR) {
|
|
477
|
+
return path.dirname(current);
|
|
478
|
+
}
|
|
479
|
+
const parent = path.dirname(current);
|
|
480
|
+
if (parent === current) break;
|
|
481
|
+
current = parent;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Di luar tata letak `.state` (mis. path eksplisit untuk tes): pakai
|
|
485
|
+
// direktori yang memuat berkasnya.
|
|
486
|
+
return path.dirname(resolved);
|
|
487
|
+
}
|
|
488
|
+
|
|
411
489
|
export function resolveStateFile() {
|
|
412
490
|
const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
|
|
413
491
|
if (customPath) {
|
|
@@ -438,12 +516,15 @@ export function resolveStateFile() {
|
|
|
438
516
|
|
|
439
517
|
const stateDir = path.join(projectRoot, STATE_DIR);
|
|
440
518
|
const userDir = getUserScopedDir(stateDir);
|
|
441
|
-
return path.join(userDir,
|
|
519
|
+
return path.join(userDir, sessionStateFilename());
|
|
442
520
|
}
|
|
443
521
|
|
|
444
522
|
export class WorkflowState {
|
|
445
523
|
constructor(stateFilePath = resolveStateFile()) {
|
|
446
524
|
this.stateFile = stateFilePath;
|
|
525
|
+
// History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
|
|
526
|
+
// berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
|
|
527
|
+
this.historyFile = resolveHistoryFile(stateFilePath);
|
|
447
528
|
this.state = {
|
|
448
529
|
currentPhase: "idle",
|
|
449
530
|
taskDescription: "",
|
|
@@ -482,6 +563,7 @@ export class WorkflowState {
|
|
|
482
563
|
// Ensure compatibility mirrors exist whenever the primary file is present
|
|
483
564
|
// Run this as non-blocking to speed up startup
|
|
484
565
|
ensureCompatibilityLinks(this.stateFile).catch(() => { });
|
|
566
|
+
await this.mergeSharedHistory();
|
|
485
567
|
if (typeof this.state.testsCreated !== "boolean") {
|
|
486
568
|
this.state.testsCreated = false;
|
|
487
569
|
}
|
|
@@ -530,7 +612,59 @@ export class WorkflowState {
|
|
|
530
612
|
}
|
|
531
613
|
// Missing file is fine; bootstrap fresh artifacts for downstream consumers.
|
|
532
614
|
await this.ensurePrimaryFile();
|
|
615
|
+
// Sesi baru tetap harus melihat riwayat proyek yang sudah ada.
|
|
616
|
+
await this.mergeSharedHistory();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Menyatukan `history` sesi ini dengan `history` bersama.
|
|
622
|
+
*
|
|
623
|
+
* History adalah catatan proyek: task yang selesai di satu sesi harus
|
|
624
|
+
* terlihat dari sesi lain, dan entri yang sama tidak boleh berganda walau
|
|
625
|
+
* berkali-kali disimpan.
|
|
626
|
+
*/
|
|
627
|
+
async mergeSharedHistory() {
|
|
628
|
+
if (this.historyFile === this.stateFile) {
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const shared = await this.readHistoryFile();
|
|
633
|
+
const merged = this.mergeDiskHistory(shared);
|
|
634
|
+
if (merged > 0) {
|
|
635
|
+
await this.writeHistoryFile();
|
|
636
|
+
}
|
|
637
|
+
return merged;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async readHistoryFile() {
|
|
641
|
+
try {
|
|
642
|
+
const data = await fs.readFile(this.historyFile, "utf-8");
|
|
643
|
+
return JSON.parse(data);
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Menulis hanya `history` ke berkas bersama, tanpa task milik sesi ini. */
|
|
650
|
+
async writeHistoryFile() {
|
|
651
|
+
if (this.historyFile === this.stateFile) {
|
|
652
|
+
return;
|
|
533
653
|
}
|
|
654
|
+
|
|
655
|
+
const shared = (await this.readHistoryFile()) || {};
|
|
656
|
+
const merged = Array.isArray(shared.history) ? shared.history.slice() : [];
|
|
657
|
+
const seen = new Set(merged.map((entry) => this.historyKey(entry)));
|
|
658
|
+
for (const entry of this.state.history || []) {
|
|
659
|
+
const key = this.historyKey(entry);
|
|
660
|
+
if (!seen.has(key)) {
|
|
661
|
+
merged.push(entry);
|
|
662
|
+
seen.add(key);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
|
|
667
|
+
await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
|
|
534
668
|
}
|
|
535
669
|
|
|
536
670
|
async ensurePrimaryFile() {
|
|
@@ -558,6 +692,9 @@ export class WorkflowState {
|
|
|
558
692
|
updatedAt: new Date().toISOString(),
|
|
559
693
|
};
|
|
560
694
|
await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
|
|
695
|
+
// History ditulis ke berkas bersama supaya sesi lain melihat task yang
|
|
696
|
+
// selesai di sini, sementara task yang sedang berjalan tetap milik sesi.
|
|
697
|
+
await this.writeHistoryFile();
|
|
561
698
|
await ensureCompatibilityLinks(this.stateFile);
|
|
562
699
|
await this.updateProjectSummary();
|
|
563
700
|
await this.syncToDatabase();
|
|
@@ -677,30 +814,10 @@ export class WorkflowState {
|
|
|
677
814
|
try {
|
|
678
815
|
const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
|
|
679
816
|
const userId = getCurrentUserId();
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
// A safe bet that is unique per project on this machine is the project root found by resolveStateFile.
|
|
685
|
-
// Let's perform a simple resolution:
|
|
686
|
-
|
|
687
|
-
const stateDir = path.dirname(path.dirname(path.dirname(this.stateFile)));
|
|
688
|
-
// stateFile = <root>/.state/users/<user>/workflow-state.json
|
|
689
|
-
// dirname = <root>/.state/users/<user>
|
|
690
|
-
// dirname = <root>/.state/users
|
|
691
|
-
// dirname = <root>/.state
|
|
692
|
-
// dirname = <root>
|
|
693
|
-
|
|
694
|
-
// Wait, let's look at the path structure from resolveStateFile:
|
|
695
|
-
// path.join(userDir, STATE_FILENAME); where userDir is path.join(stateDir, "users", userId);
|
|
696
|
-
// and stateDir is path.join(projectRoot, STATE_DIR);
|
|
697
|
-
// So: projectRoot/.state/users/userId/workflow-state.json
|
|
698
|
-
// 1. dirname -> .../userId
|
|
699
|
-
// 2. dirname -> .../users
|
|
700
|
-
// 3. dirname -> .../.state
|
|
701
|
-
// 4. dirname -> .../projectRoot
|
|
702
|
-
|
|
703
|
-
const projectRoot = path.dirname(path.dirname(path.dirname(path.dirname(this.stateFile))));
|
|
817
|
+
// projectRoot dipakai sebagai identitas proyek di database. Ditentukan
|
|
818
|
+
// lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
|
|
819
|
+
// berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
|
|
820
|
+
const projectRoot = resolveProjectRootForState(this.stateFile);
|
|
704
821
|
|
|
705
822
|
for (const entry of this.state.history || []) {
|
|
706
823
|
await insertHistoryEntry(userId, projectRoot, entry);
|