@programinglive/dev-workflow-mcp-server 1.13.0 → 1.13.2

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.
@@ -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;
@@ -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,
@@ -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,
@@ -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/git-helpers.js CHANGED
@@ -1,27 +1,37 @@
1
1
  import { exec } from "./exec.js";
2
2
 
3
3
  export async function hasStagedChanges() {
4
- try {
5
- const { stdout } = await exec("git status --porcelain");
6
- return stdout
7
- .split("\n")
8
- .filter(Boolean)
9
- .some((line) => {
10
- const indexStatus = line[0];
11
- return indexStatus && indexStatus !== " " && indexStatus !== "?";
12
- });
13
- } catch (error) {
14
- return false;
15
- }
4
+ const { stdout } = await exec("git status --porcelain");
5
+ return stdout
6
+ .split("\n")
7
+ .filter(Boolean)
8
+ .some((line) => {
9
+ const indexStatus = line[0];
10
+ return indexStatus && indexStatus !== " " && indexStatus !== "?";
11
+ });
16
12
  }
17
13
 
14
+ /**
15
+ * Apakah ada perubahan yang belum di-commit?
16
+ *
17
+ * MENGAPA TIDAK `catch { return false }`
18
+ * --------------------------------------
19
+ * Dulu fungsi ini menelan setiap error git dan menjawab `false`. Pemanggil
20
+ * membacanya sebagai "tidak ada perubahan", lalu `commit_and_push` menjawab
21
+ * "No changes detected. Assuming commit and push already completed" - sukses
22
+ * palsu, padahal tidak ada commit sama sekali.
23
+ *
24
+ * Yang terjadi 30 Oktober 2026: server MCP dijalankan dengan cwd direktori sesi
25
+ * OpenCode (`Documents\Default Project`) yang bukan repo git. `git status`
26
+ * gagal, error ditelan, delapan berkas tidak pernah ter-commit, dan tool tetap
27
+ * melaporkan berhasil.
28
+ *
29
+ * "Tidak tahu" bukan "tidak ada perubahan". Sekarang error diteruskan supaya
30
+ * pemanggil bisa melaporkan masalah sebenarnya.
31
+ */
18
32
  export async function hasWorkingChanges() {
19
- try {
20
- const { stdout } = await exec("git status --porcelain");
21
- return stdout.split("\n").some((line) => line && line.trim().length > 0);
22
- } catch (error) {
23
- return false;
24
- }
33
+ const { stdout } = await exec("git status --porcelain");
34
+ return stdout.split("\n").some((line) => line && line.trim().length > 0);
25
35
  }
26
36
 
27
37
  export function workingTreeSummary(statusOutput) {
@@ -113,51 +123,31 @@ function parseNameStatusLine(line) {
113
123
  }
114
124
 
115
125
  export async function getStagedChanges() {
116
- try {
117
- const { stdout } = await exec("git diff --cached --name-status");
118
- return stdout
119
- .split("\n")
120
- .map((line) => parseNameStatusLine(line))
121
- .filter(Boolean);
122
- } catch (error) {
123
- return [];
124
- }
126
+ const { stdout } = await exec("git diff --cached --name-status");
127
+ return stdout
128
+ .split("\n")
129
+ .map((line) => parseNameStatusLine(line))
130
+ .filter(Boolean);
125
131
  }
126
132
 
127
133
  export async function hasTestChanges() {
128
- try {
129
- const { stdout } = await exec("git status --porcelain");
130
- return containsTestFilesInStatus(stdout);
131
- } catch (error) {
132
- return false;
133
- }
134
+ const { stdout } = await exec("git status --porcelain");
135
+ return containsTestFilesInStatus(stdout);
134
136
  }
135
137
 
136
138
  export async function getLastCommitMessage() {
137
- try {
138
- const { stdout } = await exec("git log -1 --pretty=%B");
139
- return stdout.trim();
140
- } catch (error) {
141
- return "";
142
- }
139
+ const { stdout } = await exec("git log -1 --pretty=%B");
140
+ return stdout.trim();
143
141
  }
144
142
 
145
143
  export async function getCurrentBranch() {
146
- try {
147
- const { stdout } = await exec("git rev-parse --abbrev-ref HEAD");
148
- return stdout.trim();
149
- } catch (error) {
150
- return "";
151
- }
144
+ const { stdout } = await exec("git rev-parse --abbrev-ref HEAD");
145
+ return stdout.trim();
152
146
  }
153
147
 
154
148
  export async function getStatusOutput() {
155
- try {
156
- const { stdout } = await exec("git status --porcelain");
157
- return stdout;
158
- } catch (error) {
159
- return "";
160
- }
149
+ const { stdout } = await exec("git status --porcelain");
150
+ return stdout;
161
151
  }
162
152
 
163
153
  export async function getPrimaryBranch() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
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",
@@ -15,11 +15,26 @@ export async function handleCommitAndPush(args, context) {
15
15
  typeof args.commitMessage === "string" ? args.commitMessage.trim() : "";
16
16
 
17
17
  const requestedBranch = typeof args.branch === "string" ? args.branch.trim() : "";
18
- const primaryBranch = await git.getPrimaryBranch();
19
- const currentBranch = await git.getCurrentBranch();
20
- const branchForPush = requestedBranch || primaryBranch || currentBranch || "";
21
18
 
22
- const hasWorkingChanges = await git.hasWorkingChanges();
19
+ // Preflight: kalau git tidak bisa dijalankan, jangan menebak. Dulu rantai ini
20
+ // gagal, error ditelan helper, lalu tool menjawab "No changes detected" dan
21
+ // menandai commit selesai padahal tidak ada commit sama sekali.
22
+ let primaryBranch;
23
+ let currentBranch;
24
+ let hasWorkingChanges;
25
+ try {
26
+ primaryBranch = await git.getPrimaryBranch();
27
+ currentBranch = await git.getCurrentBranch();
28
+ hasWorkingChanges = await git.hasWorkingChanges();
29
+ } catch (error) {
30
+ return textResponse(
31
+ `❌ Tidak bisa memeriksa repository Git, jadi tidak ada yang di-commit.\n\n` +
32
+ `Penyebab: ${error.message}\n\n` +
33
+ `Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.`
34
+ );
35
+ }
36
+
37
+ const branchForPush = requestedBranch || primaryBranch || currentBranch || "";
23
38
 
24
39
  if (!hasWorkingChanges) {
25
40
  const lastCommitMessage = await git.getLastCommitMessage();
@@ -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
+ }
@@ -50,7 +50,18 @@ export async function handlePerformRelease(args, context) {
50
50
  return textResponse("⚠️ Please start a task first using 'start_task'!");
51
51
  }
52
52
 
53
- if (await resetToCommitIfWorkingChanges(workflowState, git)) {
53
+ let hasNewChanges;
54
+ try {
55
+ hasNewChanges = await resetToCommitIfWorkingChanges(workflowState, git);
56
+ } catch (error) {
57
+ return textResponse(
58
+ `❌ Tidak bisa memeriksa repository Git, jadi status rilis tidak dapat diverifikasi.\n\n` +
59
+ `Penyebab: ${error.message}\n\n` +
60
+ `Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.`
61
+ );
62
+ }
63
+
64
+ if (hasNewChanges) {
54
65
  return textResponse(
55
66
  "⚠️ Detected new changes after the last commit. Please run 'commit_and_push' again before recording a release."
56
67
  );
@@ -63,7 +74,16 @@ export async function handlePerformRelease(args, context) {
63
74
  }
64
75
 
65
76
  if (!workflowState.state.commitAndPushCompleted) {
66
- const hasWorkingChanges = await git.hasWorkingChanges();
77
+ let hasWorkingChanges;
78
+ try {
79
+ hasWorkingChanges = await git.hasWorkingChanges();
80
+ } catch (error) {
81
+ return textResponse(
82
+ `❌ Tidak bisa memeriksa repository Git, jadi status rilis tidak dapat diverifikasi.\n\n` +
83
+ `Penyebab: ${error.message}\n\n` +
84
+ `Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.`
85
+ );
86
+ }
67
87
 
68
88
  if (hasWorkingChanges) {
69
89
  return textResponse(
@@ -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
- const reconciled = await workflowState.save();
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 = { mergedHistory: 0, preservedTask: null, foreignToken: null };
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
- async syncToDatabase() {
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
- try {
903
- const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
904
- const userId = getCurrentUserId();
905
- // projectRoot dipakai sebagai identitas proyek di database. Ditentukan
906
- // lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
907
- // berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
908
- const projectRoot = resolveProjectRootForState(this.stateFile);
909
-
910
- for (const entry of this.state.history || []) {
911
- await insertHistoryEntry(userId, projectRoot, entry);
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
- await updateSummaryForUser(userId, projectRoot);
914
- await saveState(userId, projectRoot, this.state);
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
- // Database sync failed; log it but don't crash
917
- safeLog('error', "Database sync failed:", error.message);
1040
+ // Ringkasan dan state tetap opsional: jangan pernah menggagalkan save().
1041
+ safeLog("error", "Database sync failed:", error.message);
918
1042
  }
919
1043
  }
920
1044