@programinglive/dev-workflow-mcp-server 1.13.0 → 1.13.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/db/adapters/base.js +20 -0
- package/db/adapters/mysql.js +2 -2
- package/db/adapters/postgres.js +2 -2
- package/db/adapters/sqlite.js +2 -2
- package/package.json +1 -1
- package/tools/handler-modules/index.js +34 -1
- package/tools/handler-modules/workflow-handlers.js +4 -1
- package/workflow-state.js +141 -17
package/db/adapters/base.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { assertSafeKnowledge } from '../../security/policy.js';
|
|
2
2
|
import { securityOutputFilter } from '../../security/sanitizer.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Nilai `task_type` yang selalu boleh masuk kolom NOT NULL.
|
|
6
|
+
*
|
|
7
|
+
* MENGAPA ADA
|
|
8
|
+
* -----------
|
|
9
|
+
* Kolom `workflow_history.task_type` adalah NOT NULL, tetapi entri history
|
|
10
|
+
* yang lebih tua tidak selalu punya nilainya: penanda `dropped` versi lama
|
|
11
|
+
* ditulis tanpa `taskType` sama sekali, dan sebagian entri `forced` menyimpan
|
|
12
|
+
* string kosong. Saat `undefined` di-bind, driver mengubahnya menjadi NULL dan
|
|
13
|
+
* SQLite menolak dengan `NOT NULL constraint failed: workflow_history.task_type`.
|
|
14
|
+
*
|
|
15
|
+
* Dulu kegagalan itu membatalkan seluruh `syncToDatabase()`: ringkasan proyek
|
|
16
|
+
* dan state workflow ikut tidak pernah ditulis, jadi riwayat proyek diam-diam
|
|
17
|
+
* berhenti tersimpan. Normalisasi di sini membuat data lama tetap dapat
|
|
18
|
+
* disinkronkan tanpa menebak isi aslinya.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeTaskType(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'other';
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
export class DbAdapter {
|
|
5
25
|
constructor(config = {}) {
|
|
6
26
|
this.config = config;
|
package/db/adapters/mysql.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mysql from "mysql2/promise";
|
|
2
|
-
import { DbAdapter } from "./base.js";
|
|
2
|
+
import { DbAdapter, normalizeTaskType } from "./base.js";
|
|
3
3
|
import { assertSafeKnowledge, classify } from '../../security/policy.js';
|
|
4
4
|
|
|
5
5
|
const FREQUENCY_FORMATS = {
|
|
@@ -106,7 +106,7 @@ export class MysqlAdapter extends DbAdapter {
|
|
|
106
106
|
userId,
|
|
107
107
|
normalizedProjectPath,
|
|
108
108
|
entry.taskDescription,
|
|
109
|
-
entry.taskType,
|
|
109
|
+
normalizeTaskType(entry.taskType),
|
|
110
110
|
entry.commitMessage,
|
|
111
111
|
this.formatDate(entry.timestamp),
|
|
112
112
|
entry.forced ? 1 : 0,
|
package/db/adapters/postgres.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pg from "pg";
|
|
2
|
-
import { DbAdapter } from "./base.js";
|
|
2
|
+
import { DbAdapter, normalizeTaskType } from "./base.js";
|
|
3
3
|
import { assertSafeKnowledge, classify } from '../../security/policy.js';
|
|
4
4
|
|
|
5
5
|
const { Pool } = pg;
|
|
@@ -141,7 +141,7 @@ export class PostgresAdapter extends DbAdapter {
|
|
|
141
141
|
numericUserId,
|
|
142
142
|
normalizedProjectPath,
|
|
143
143
|
entry.taskDescription || entry.description,
|
|
144
|
-
entry.taskType,
|
|
144
|
+
normalizeTaskType(entry.taskType),
|
|
145
145
|
entry.commitMessage,
|
|
146
146
|
entry.timestamp || entry.completed_at || new Date().toISOString(),
|
|
147
147
|
entry.forced || false,
|
package/db/adapters/sqlite.js
CHANGED
|
@@ -2,7 +2,7 @@ import { safeLog } from '../../security/sanitizer.js';
|
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { DbAdapter } from "./base.js";
|
|
5
|
+
import { DbAdapter, normalizeTaskType } from "./base.js";
|
|
6
6
|
import { assertSafeKnowledge, classify } from '../../security/policy.js';
|
|
7
7
|
|
|
8
8
|
const DB_FILENAME = "dev-workflow.db";
|
|
@@ -166,7 +166,7 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
166
166
|
userId,
|
|
167
167
|
normalizedProjectPath,
|
|
168
168
|
entry.taskDescription,
|
|
169
|
-
entry.taskType,
|
|
169
|
+
normalizeTaskType(entry.taskType),
|
|
170
170
|
entry.commitMessage,
|
|
171
171
|
entry.timestamp || new Date().toISOString(),
|
|
172
172
|
entry.forced ? 1 : 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.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",
|
|
@@ -269,8 +269,9 @@ export async function handleToolCall(context) {
|
|
|
269
269
|
if(error) return securityOutputFilter(textResponse(error));
|
|
270
270
|
assertSafeKnowledge({tool:context.request.params.name,args});
|
|
271
271
|
assertSafeToolCommand(context.request.params.name,args);
|
|
272
|
+
if (context.workflowState) context.workflowState.lastConflict = null;
|
|
272
273
|
const response=await dispatchToolCall({...context,request:{...context.request,params:{...context.request.params,arguments:args}}});
|
|
273
|
-
return securityOutputFilter(response);
|
|
274
|
+
return securityOutputFilter(withConflictNotice(response, context.workflowState));
|
|
274
275
|
} catch(error) {
|
|
275
276
|
if(['SECRET_CONTENT_DENIED','UNSAFE_COMMAND_DENIED'].includes(error.code)) {
|
|
276
277
|
return {isError:true,content:[{type:'text',text:JSON.stringify({error:error.code,message:error.message,findings:error.findings})}]};
|
|
@@ -279,3 +280,35 @@ export async function handleToolCall(context) {
|
|
|
279
280
|
return {isError:true,content:[{type:'text',text:'⚠️ Internal server error while processing the tool request.'}]};
|
|
280
281
|
}
|
|
281
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Tempelkan peringatan bila penyimpanan terakhir kalah dari proses lain.
|
|
286
|
+
*
|
|
287
|
+
* Tanpa ini, tool menjawab "berhasil" untuk state yang sebenarnya tidak
|
|
288
|
+
* tersimpan. Kejadian 29 Okt 2026: `start_task` menjawab "✅ Task Started",
|
|
289
|
+
* tetapi `get_workflow_status` berikutnya menampilkan task lain.
|
|
290
|
+
*/
|
|
291
|
+
function withConflictNotice(response, workflowState) {
|
|
292
|
+
const conflict = workflowState?.lastConflict;
|
|
293
|
+
if (!conflict) {
|
|
294
|
+
return response;
|
|
295
|
+
}
|
|
296
|
+
const text = response?.content?.[0]?.text;
|
|
297
|
+
if (typeof text !== "string") {
|
|
298
|
+
return response;
|
|
299
|
+
}
|
|
300
|
+
// Peringatan ditaruh DI DEPAN, dan menyebut terus terang bahwa aksi tadi
|
|
301
|
+
// tidak berlaku. Menempelkan "✅ berhasil" di atas peringatan membuat agen
|
|
302
|
+
// membaca baris pertama lalu melanjutkan seolah semuanya tersimpan.
|
|
303
|
+
const notice =
|
|
304
|
+
`⚠️ AKSI TIDAK BERLAKU - proses MCP lain memulai task yang lebih baru.\n` +
|
|
305
|
+
` Task di disk : "${conflict.diskTask}"\n` +
|
|
306
|
+
` Task Anda : "${conflict.staleTask || '(kosong)'}"\n` +
|
|
307
|
+
` State Anda sudah dimuat ulang dari disk, jadi langkah berikutnya mengikuti task di disk.\n` +
|
|
308
|
+
` Jalankan ulang aksi ini bila memang untuk task tersebut, atau 'start_task' untuk menggantinya.\n\n` +
|
|
309
|
+
`--- pesan asli tool (TIDAK tersimpan) ---\n`;
|
|
310
|
+
return {
|
|
311
|
+
...response,
|
|
312
|
+
content: [{ ...response.content[0], text: notice + text }, ...response.content.slice(1)],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -17,7 +17,10 @@ export async function handleStartTask(args, workflowState) {
|
|
|
17
17
|
workflowState.state.commitAndPushCompleted = false;
|
|
18
18
|
workflowState.state.lastCommitMessage = "";
|
|
19
19
|
workflowState.state.lastPushBranch = "";
|
|
20
|
-
|
|
20
|
+
// start_task memang bermaksud MENGGANTI task, jadi ia boleh menang atas
|
|
21
|
+
// task berjalan. Task yang diganti tetap diarsipkan oleh reconcileWithDisk,
|
|
22
|
+
// bukan dihapus.
|
|
23
|
+
const reconciled = await workflowState.save({ allowReplaceRunningTask: true });
|
|
21
24
|
|
|
22
25
|
const warnings = [];
|
|
23
26
|
const preservedForeign = reconciled?.preservedTask || preservedBeforeSave?.preservedTask;
|
package/workflow-state.js
CHANGED
|
@@ -560,6 +560,13 @@ export class WorkflowState {
|
|
|
560
560
|
// History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
|
|
561
561
|
// berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
|
|
562
562
|
this.historyFile = resolveHistoryFile(stateFilePath);
|
|
563
|
+
// Task yang terakhir kita baca dari / tulis ke disk. Dipakai untuk
|
|
564
|
+
// membedakan "task ini milik kita" dari "task ini ditulis proses lain
|
|
565
|
+
// sesudah kita memuat" - tanpa ini penulis basi menang tanpa perlawanan.
|
|
566
|
+
this.loadedTask = "";
|
|
567
|
+
// Konflik penyimpanan terakhir, dibaca oleh lapisan tool untuk memberi tahu
|
|
568
|
+
// pengguna. Null berarti penyimpanan terakhir aman.
|
|
569
|
+
this.lastConflict = null;
|
|
563
570
|
this.state = {
|
|
564
571
|
currentPhase: "idle",
|
|
565
572
|
classification: "INTERNAL",
|
|
@@ -643,6 +650,7 @@ export class WorkflowState {
|
|
|
643
650
|
// Run this as non-blocking to speed up startup
|
|
644
651
|
ensureCompatibilityLinks(this.stateFile).catch(() => { });
|
|
645
652
|
await this.mergeSharedHistory();
|
|
653
|
+
this.loadedTask = this.state.taskDescription || "";
|
|
646
654
|
if (typeof this.state.testsCreated !== "boolean") {
|
|
647
655
|
this.state.testsCreated = false;
|
|
648
656
|
}
|
|
@@ -766,10 +774,25 @@ export class WorkflowState {
|
|
|
766
774
|
return created;
|
|
767
775
|
}
|
|
768
776
|
|
|
769
|
-
async save() {
|
|
777
|
+
async save(options = {}) {
|
|
770
778
|
assertSafeKnowledge(this.state);
|
|
771
779
|
const reconciled = await this.reconcileWithDisk();
|
|
772
780
|
assertSafeKnowledge(this.state);
|
|
781
|
+
|
|
782
|
+
// Penulis basi tidak boleh menang.
|
|
783
|
+
//
|
|
784
|
+
// Bug 29 Okt 2026: satu proses MCP memegang state lama, lalu menyimpan
|
|
785
|
+
// sesudah proses lain memulai task baru. Task baru tertimpa dan pemanggil
|
|
786
|
+
// tetap diberi tahu "berhasil" - `get_workflow_status` berikutnya
|
|
787
|
+
// menampilkan task yang sama sekali lain.
|
|
788
|
+
const conflict = this.detectStaleOverwrite(reconciled, options);
|
|
789
|
+
if (conflict) {
|
|
790
|
+
await this.adoptDiskState();
|
|
791
|
+
this.lastConflict = conflict;
|
|
792
|
+
return { ...reconciled, conflict, verified: false };
|
|
793
|
+
}
|
|
794
|
+
this.lastConflict = null;
|
|
795
|
+
|
|
773
796
|
this.state.owner = {
|
|
774
797
|
processMarker: PROCESS_TOKEN,
|
|
775
798
|
pid: process.pid,
|
|
@@ -783,9 +806,82 @@ export class WorkflowState {
|
|
|
783
806
|
await this.updateProjectSummary();
|
|
784
807
|
await this.syncToDatabase();
|
|
785
808
|
this.lastReconcile = reconciled;
|
|
809
|
+
this.loadedTask = this.state.taskDescription || "";
|
|
810
|
+
await this.verifyPersisted(this.loadedTask);
|
|
786
811
|
return reconciled;
|
|
787
812
|
}
|
|
788
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Apakah penyimpanan ini akan menimpa task berjalan milik proses lain?
|
|
816
|
+
*
|
|
817
|
+
* Konflik hanya kalau SEMUA ini benar:
|
|
818
|
+
* - disk memuat task yang SEDANG berjalan (bukan idle),
|
|
819
|
+
* - task di disk bukan task yang terakhir kita muat (jadi ditulis orang
|
|
820
|
+
* lain sesudah kita membaca),
|
|
821
|
+
* - task di disk bukan task yang akan kita tulis (jadi bukan kita sendiri),
|
|
822
|
+
* - pemanggil tidak menyatakan maksud mengganti task.
|
|
823
|
+
*
|
|
824
|
+
* `start_task` memang bermaksud mengganti task, jadi ia memakai
|
|
825
|
+
* `allowReplaceRunningTask: true` dan tetap menang - tetapi task yang diganti
|
|
826
|
+
* diarsipkan oleh `reconcileWithDisk`, bukan dihapus.
|
|
827
|
+
*/
|
|
828
|
+
detectStaleOverwrite(reconciled, options = {}) {
|
|
829
|
+
if (options.allowReplaceRunningTask === true) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
const diskTask = reconciled?.diskTask || "";
|
|
833
|
+
const diskPhase = reconciled?.diskPhase || "idle";
|
|
834
|
+
if (!diskTask || diskPhase === "idle") {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
if (diskTask === this.loadedTask) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
if (diskTask === (this.state.taskDescription || "")) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
diskTask,
|
|
845
|
+
staleTask: this.state.taskDescription || "",
|
|
846
|
+
diskPhase,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Ambil state dari disk apa adanya, dengan history digabung, tanpa menulis. */
|
|
851
|
+
async adoptDiskState() {
|
|
852
|
+
const disk = await this.readDiskState();
|
|
853
|
+
if (!disk) {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
const mergedHistory = this.state.history || [];
|
|
857
|
+
this.state = { ...disk, history: mergedHistory };
|
|
858
|
+
this.mergeDiskHistory(disk);
|
|
859
|
+
this.loadedTask = this.state.taskDescription || "";
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Pastikan tulisan kita benar-benar bertahan.
|
|
865
|
+
*
|
|
866
|
+
* Tanpa ini, proses yang kalah balapan tetap melaporkan sukses. Lebih baik
|
|
867
|
+
* gagal terang-terangan daripada memberi tahu "berhasil" untuk state yang
|
|
868
|
+
* sudah ditimpa proses lain.
|
|
869
|
+
*/
|
|
870
|
+
async verifyPersisted(expectedTask) {
|
|
871
|
+
const disk = await this.readDiskState();
|
|
872
|
+
if (!disk) {
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
const actual = disk.taskDescription || "";
|
|
876
|
+
if (actual !== (expectedTask || "")) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`State workflow tidak bertahan: yang ditulis "${expectedTask}" tetapi di disk "${actual}". ` +
|
|
879
|
+
`Proses MCP lain menimpanya. Muat ulang state sebelum melanjutkan.`
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
789
885
|
/**
|
|
790
886
|
* Baca isi berkas state APA ADANYA dari disk (bukan dari memori).
|
|
791
887
|
* Mengembalikan null kalau belum ada / rusak.
|
|
@@ -840,12 +936,20 @@ export class WorkflowState {
|
|
|
840
936
|
* pekerjaan resize docker-alma tidak pernah muncul di view_history.
|
|
841
937
|
*/
|
|
842
938
|
async reconcileWithDisk() {
|
|
843
|
-
const result = {
|
|
939
|
+
const result = {
|
|
940
|
+
mergedHistory: 0,
|
|
941
|
+
preservedTask: null,
|
|
942
|
+
foreignToken: null,
|
|
943
|
+
diskTask: "",
|
|
944
|
+
diskPhase: "idle",
|
|
945
|
+
};
|
|
844
946
|
const disk = await this.readDiskState();
|
|
845
947
|
if (!disk) {
|
|
846
948
|
return result;
|
|
847
949
|
}
|
|
848
950
|
|
|
951
|
+
result.diskTask = disk.taskDescription || "";
|
|
952
|
+
result.diskPhase = disk.currentPhase || "idle";
|
|
849
953
|
result.mergedHistory = this.mergeDiskHistory(disk);
|
|
850
954
|
|
|
851
955
|
if (!this.isForeignWrite(disk)) {
|
|
@@ -897,24 +1001,44 @@ export class WorkflowState {
|
|
|
897
1001
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
|
|
898
1002
|
}
|
|
899
1003
|
|
|
900
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Menyalin history, ringkasan, dan state ke database.
|
|
1006
|
+
*
|
|
1007
|
+
* `db` dapat diinjeksi untuk pengujian; pemanggil biasa tidak mengirimnya.
|
|
1008
|
+
*
|
|
1009
|
+
* Satu entri yang gagal TIDAK boleh menghentikan sisanya. Dulu kegagalan
|
|
1010
|
+
* pertama (mis. entri lama tanpa `task_type`) membatalkan seluruh fungsi,
|
|
1011
|
+
* sehingga `updateSummaryForUser` dan `saveState` ikut terlewat dan riwayat
|
|
1012
|
+
* proyek diam-diam berhenti tersimpan sama sekali.
|
|
1013
|
+
*/
|
|
1014
|
+
async syncToDatabase(options = {}) {
|
|
901
1015
|
assertSafeKnowledge(this.state);
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1016
|
+
const db = options.db ?? (await import("./db/index.js"));
|
|
1017
|
+
const userId = getCurrentUserId();
|
|
1018
|
+
// projectRoot dipakai sebagai identitas proyek di database. Ditentukan
|
|
1019
|
+
// lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
|
|
1020
|
+
// berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
|
|
1021
|
+
const projectRoot = resolveProjectRootForState(this.stateFile);
|
|
1022
|
+
|
|
1023
|
+
let skipped = 0;
|
|
1024
|
+
for (const entry of this.state.history || []) {
|
|
1025
|
+
try {
|
|
1026
|
+
await db.insertHistoryEntry(userId, projectRoot, entry);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
skipped++;
|
|
1029
|
+
safeLog("error", "History entry skipped:", error.message);
|
|
912
1030
|
}
|
|
913
|
-
|
|
914
|
-
|
|
1031
|
+
}
|
|
1032
|
+
if (skipped > 0) {
|
|
1033
|
+
safeLog("error", `History sync skipped ${skipped} entr${skipped === 1 ? "y" : "ies"}.`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
await db.updateSummaryForUser(userId, projectRoot);
|
|
1038
|
+
await db.saveState(userId, projectRoot, this.state);
|
|
915
1039
|
} catch (error) {
|
|
916
|
-
//
|
|
917
|
-
safeLog(
|
|
1040
|
+
// Ringkasan dan state tetap opsional: jangan pernah menggagalkan save().
|
|
1041
|
+
safeLog("error", "Database sync failed:", error.message);
|
|
918
1042
|
}
|
|
919
1043
|
}
|
|
920
1044
|
|