@programinglive/dev-workflow-mcp-server 1.9.1 → 1.10.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.
@@ -0,0 +1,276 @@
1
+ import { textResponse } from "./shared.js";
2
+ import {
3
+ readFactoryConfig,
4
+ listTasks,
5
+ findTask,
6
+ claimTask,
7
+ updateTaskStatus,
8
+ createTask,
9
+ listProjects,
10
+ formatTaskList,
11
+ formatProjectList,
12
+ planStatusTransition,
13
+ } from "../../factory-client.js";
14
+
15
+ /**
16
+ * Factory tools stay inert unless a Factory URL and credentials are set.
17
+ * That keeps the MCP server usable on machines without Factory access.
18
+ */
19
+ function requireConfig() {
20
+ const config = readFactoryConfig();
21
+ if (!config) {
22
+ return {
23
+ config: null,
24
+ response: textResponse(
25
+ [
26
+ "Factory is not configured on this machine.",
27
+ "",
28
+ "Set these environment variables where the MCP server is launched:",
29
+ " FACTORY_URL=https://factory.programinglive.com",
30
+ " FACTORY_TOKEN=<PocketBase activity_users token>",
31
+ "",
32
+ "Or let the client log in for you:",
33
+ " FACTORY_EMAIL=<activity_users email>",
34
+ " FACTORY_PASSWORD=<password>",
35
+ "",
36
+ "Optional:",
37
+ " FACTORY_PB_URL=<PocketBase base URL, default https://pocketbase.programinglive.com>",
38
+ " FACTORY_MACHINE_ID=<name shown on the device row>",
39
+ " FACTORY_DEVICE_ID=<fixed device UUID>",
40
+ ].join("\n"),
41
+ ),
42
+ };
43
+ }
44
+ return { config, response: null };
45
+ }
46
+
47
+ export async function handleListFactoryTasks(args = {}) {
48
+ const { config, response } = requireConfig();
49
+ if (!config) return response;
50
+
51
+ const statuses = Array.isArray(args.statuses) && args.statuses.length ? args.statuses : undefined;
52
+ const project = typeof args.project === "string" && args.project.trim() ? args.project.trim() : null;
53
+ const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 200) : 50;
54
+
55
+ try {
56
+ const tasks = await listTasks(config, { statuses, project, limit });
57
+ return textResponse(formatTaskList(tasks));
58
+ } catch (error) {
59
+ return textResponse(`Could not read the Factory queue: ${error.message}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Formats how long ago a work session was last seen.
65
+ *
66
+ * Returns an empty string when the timestamp is missing or unparsable, so the
67
+ * caller can leave that part of the message out instead of printing garbage.
68
+ */
69
+ function formatLastSeen(value) {
70
+ if (!value) return "";
71
+ const seen = Date.parse(value);
72
+ if (Number.isNaN(seen)) return "";
73
+ const minutes = Math.floor((Date.now() - seen) / 60000);
74
+ if (minutes < 1) return "baru saja";
75
+ if (minutes < 60) return `${minutes} menit lalu`;
76
+ const hours = Math.floor(minutes / 60);
77
+ if (hours < 24) return `${hours} jam lalu`;
78
+ return `${Math.floor(hours / 24)} hari lalu`;
79
+ }
80
+
81
+ /**
82
+ * Builds the message shown when Factory refuses a claim because another device
83
+ * still holds the task.
84
+ *
85
+ * The refusal is actionable: it names the holder and shows the exact call to
86
+ * make, so the agent does not have to guess which parameter is missing.
87
+ */
88
+ function takeoverRequiredMessage(title, holder = {}) {
89
+ const seen = formatLastSeen(holder.lastSeenAt);
90
+ const holderText = [holder.deviceName, holder.agent].filter(Boolean).join(" (") + (holder.agent ? ")" : "");
91
+ return [
92
+ `"${title}" sedang dikerjakan oleh perangkat lain.`,
93
+ "",
94
+ holderText ? `Pemegang sesi: ${holderText}${seen ? `, terakhir terlihat ${seen}` : ""}.` : "",
95
+ "",
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.",
100
+ ]
101
+ .filter((line) => line !== "")
102
+ .join("\n");
103
+ }
104
+
105
+ export async function handleClaimFactoryTask(args = {}, { workflowState } = {}) {
106
+ const { config, response } = requireConfig();
107
+ if (!config) return response;
108
+
109
+ const recordId = typeof args.recordId === "string" ? args.recordId.trim() : "";
110
+ if (!recordId) {
111
+ return textResponse("Provide the 'recordId' of the task to claim (see list_factory_tasks).");
112
+ }
113
+
114
+ const takeoverReason = typeof args.takeoverReason === "string" ? args.takeoverReason.trim() : "";
115
+ // Judul diambil di luar try supaya pesan penolakan tetap menyebut task yang
116
+ // sebenarnya, bukan teks umum.
117
+ let taskTitle = "Task ini";
118
+
119
+ try {
120
+ const task = await findTask(config, recordId);
121
+ if (!task) {
122
+ return textResponse(`No task with id "${recordId}" is visible to this account.`);
123
+ }
124
+ taskTitle = task.title;
125
+
126
+ const step = typeof args.step === "string" && args.step.trim() ? args.step.trim() : "Working on the task";
127
+ const session = await claimTask(config, { recordId, step, takeoverReason });
128
+ if (!session) throw new Error("Factory accepted the claim but returned no work session.");
129
+
130
+ if (workflowState) {
131
+ workflowState.reset();
132
+ workflowState.state.currentPhase = "coding";
133
+ workflowState.state.taskDescription = `[${task.taskKey || task.id}] ${task.title}`;
134
+ workflowState.state.taskType = "other";
135
+ await workflowState.save();
136
+ }
137
+
138
+ return textResponse(
139
+ [
140
+ `Claimed [${task.taskKey || task.id}] ${task.title}`,
141
+ `Factory status is now running (work session ${session.id}).`,
142
+ task.projectName ? `Project: ${task.projectName}` : "",
143
+ "",
144
+ "Local workflow started for this task.",
145
+ "Use 'sync_task_status' with review, blocked or paused to close the session, or done to approve the task once it is in review.",
146
+ ]
147
+ .filter(Boolean)
148
+ .join("\n"),
149
+ );
150
+ } catch (error) {
151
+ // Factory menolak take over tanpa alasan. Balas dengan arahan yang bisa
152
+ // dijalankan, bukan dengan pesan error mentah.
153
+ if (error?.body?.takeoverRequired) {
154
+ return textResponse(takeoverRequiredMessage(taskTitle, error.body.holder));
155
+ }
156
+ return textResponse(`Could not claim the task: ${error.message}`);
157
+ }
158
+ }
159
+
160
+ export async function handleSyncTaskStatus(args = {}) {
161
+ const { config, response } = requireConfig();
162
+ if (!config) return response;
163
+
164
+ const recordId = typeof args.recordId === "string" ? args.recordId.trim() : "";
165
+ const status = typeof args.status === "string" ? args.status.trim() : "";
166
+ const reason = typeof args.reason === "string" ? args.reason : null;
167
+ const nextAction = typeof args.nextAction === "string" ? args.nextAction : null;
168
+
169
+ if (!recordId) {
170
+ return textResponse("Provide the 'recordId' of the task to update.");
171
+ }
172
+ if (!status) {
173
+ return textResponse("Provide 'status'. Factory accepts: backlog, queued, review, blocked, paused, cancelled, done.");
174
+ }
175
+ if (status === "blocked" && !reason) {
176
+ return textResponse("Marking a task blocked requires a 'reason' so the next person knows what is missing.");
177
+ }
178
+
179
+ // Validate locally so the agent gets an actionable message instead of a
180
+ // transport error from Factory.
181
+ try {
182
+ planStatusTransition(status);
183
+ } catch (error) {
184
+ return textResponse(error.message);
185
+ }
186
+
187
+ try {
188
+ const result = await updateTaskStatus(config, { recordId, status, reason, nextAction });
189
+
190
+ return textResponse(
191
+ [
192
+ `Updated ${recordId} to ${result.status}.`,
193
+ result.session?.summary ? `Summary: ${result.session.summary}` : "",
194
+ result.status === "review" ? "Waiting for human approval in Factory." : "",
195
+ ]
196
+ .filter(Boolean)
197
+ .join("\n"),
198
+ );
199
+ } catch (error) {
200
+ return textResponse(`Could not update the task: ${error.message}`);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Lists projects, because create_task needs a real projectId (UUID) and the
206
+ * agent cannot invent one.
207
+ */
208
+ export async function handleListFactoryProjects() {
209
+ const { config, response } = requireConfig();
210
+ if (!config) return response;
211
+
212
+ try {
213
+ const projects = await listProjects(config);
214
+ return textResponse(formatProjectList(projects));
215
+ } catch (error) {
216
+ return textResponse(`Could not read the Factory projects: ${error.message}`);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Creates a task on the board through the official write path.
222
+ *
223
+ * Defaults to `backlog`. Factory starts a worker run for `queued` tasks, so
224
+ * recording work must not enqueue execution by accident.
225
+ */
226
+ export async function handleCreateFactoryTask(args = {}) {
227
+ const { config, response } = requireConfig();
228
+ if (!config) return response;
229
+
230
+ const title = typeof args.title === "string" ? args.title.trim() : "";
231
+ const acceptance = typeof args.acceptance === "string" ? args.acceptance.trim() : "";
232
+
233
+ if (!title) {
234
+ return textResponse("Provide 'title' for the task (3-140 characters).");
235
+ }
236
+ if (!acceptance) {
237
+ return textResponse(
238
+ [
239
+ "Provide 'acceptance': what must be true for this task to count as done.",
240
+ "Factory requires it, and it is what the next person checks.",
241
+ ].join("\n"),
242
+ );
243
+ }
244
+ if (!args.projectId) {
245
+ return textResponse(
246
+ "Provide 'projectId'. Run 'list_factory_projects' first - Factory needs a real project UUID.",
247
+ );
248
+ }
249
+
250
+ try {
251
+ const created = await createTask(config, {
252
+ projectId: args.projectId,
253
+ title,
254
+ description: args.description,
255
+ acceptance,
256
+ priority: args.priority,
257
+ status: args.status,
258
+ });
259
+
260
+ return textResponse(
261
+ [
262
+ `Created task ${created.id ? `[${String(created.id).slice(0, 8)}] ` : ""}${title}`,
263
+ `Status: ${created.status}${created.status === "backlog" ? " (no worker run started)" : ""}`,
264
+ created.runId ? `Worker run queued: ${created.runId}` : "",
265
+ "",
266
+ created.status === "backlog"
267
+ ? "Recorded in the backlog. Use claim_factory_task when someone starts working on it."
268
+ : "Move it out of the queue if execution was not intended.",
269
+ ]
270
+ .filter(Boolean)
271
+ .join("\n"),
272
+ );
273
+ } catch (error) {
274
+ return textResponse(`Could not create the task: ${error.message}`);
275
+ }
276
+ }
@@ -4,6 +4,7 @@ import { handleSkipRelease, handlePerformRelease } from "./release-handlers.js";
4
4
  import { handleCommitAndPush, handleCompleteTask, handleForceCompleteTask, handleDropTask } from "./commit-handlers.js";
5
5
  import { handleProjectSummary, handleProjectSummaryData, handleProjectSummaryDb, handleViewHistory } from "./summary-handlers.js";
6
6
  import { handleGetWorkflowStatus, handleReadyCheck, handleContinueWorkflow, handleRerunWorkflow } from "./status-handlers.js";
7
+ import { handleListFactoryTasks, handleClaimFactoryTask, handleSyncTaskStatus, handleListFactoryProjects, handleCreateFactoryTask } from "./factory-handlers.js";
7
8
 
8
9
  function summarizeResponse(label, response) {
9
10
  const text = response?.content?.[0]?.text || "";
@@ -216,6 +217,16 @@ export async function handleToolCall({
216
217
  return handleRerunWorkflow(workflowState);
217
218
  case "run_full_workflow":
218
219
  return handleRunFullWorkflow(args, { workflowState, exec, git, utils });
220
+ case "list_factory_tasks":
221
+ return handleListFactoryTasks(args);
222
+ case "list_factory_projects":
223
+ return handleListFactoryProjects(args);
224
+ case "create_factory_task":
225
+ return handleCreateFactoryTask(args);
226
+ case "claim_factory_task":
227
+ return handleClaimFactoryTask(args, { workflowState });
228
+ case "sync_task_status":
229
+ return handleSyncTaskStatus(args);
219
230
  default:
220
231
  throw new Error(`Unknown tool: ${name}`);
221
232
  }
@@ -1,10 +1,30 @@
1
1
  import { textResponse, formatChecklist, isReleaseSatisfied, getContinueGuidance, resetToCommitIfWorkingChanges } from "./shared.js";
2
2
  import { handleCommitAndPush } from "./commit-handlers.js";
3
3
 
4
- export function handleGetWorkflowStatus(workflowState) {
4
+ export async function handleGetWorkflowStatus(workflowState) {
5
5
  const status = workflowState.state;
6
6
  const nextStep = status ? status.currentPhase : "idle";
7
7
 
8
+ // Deteksi berkas state yang terakhir ditulis proses MCP lain. Tanpa
9
+ // peringatan ini, agen bisa meng-commit atas nama task orang lain.
10
+ let sharedWarning = "";
11
+ try {
12
+ const disk = await workflowState.readDiskState();
13
+ if (workflowState.isForeignWrite(disk)) {
14
+ const owner = disk.owner || {};
15
+ sharedWarning =
16
+ `\n\nāš ļø PERINGATAN: berkas state ini terakhir ditulis proses MCP LAIN ` +
17
+ `(${owner.token || "tanpa-token"}, pid ${owner.pid ?? "?"}).\n` +
18
+ ` State workflow dipakai bersama antar sesi di mesin ini.\n` +
19
+ ` Periksa apakah "Task:" di atas benar-benar milik Anda SEBELUM commit_and_push.`;
20
+ if (disk.currentPhase && disk.currentPhase !== "idle" && disk.taskDescription) {
21
+ sharedWarning += `\n Proses itu sedang mengerjakan: "${disk.taskDescription}"`;
22
+ }
23
+ }
24
+ } catch {
25
+ // Status tetap ditampilkan walau pemeriksaan berkas gagal.
26
+ }
27
+
8
28
  return textResponse(
9
29
  `šŸ“Š Current Workflow Status:
10
30
 
@@ -19,7 +39,7 @@ Ready check: ${status.readyCheckCompleted ? "āœ…" : "āŒ"}
19
39
  Commit & push: ${status.commitAndPushCompleted ? "āœ…" : "āŒ"}
20
40
  Released: ${status.released ? "āœ…" : "āŒ"}
21
41
 
22
- Next step: ${nextStep}`
42
+ Next step: ${nextStep}${sharedWarning}`
23
43
  );
24
44
  }
25
45
 
@@ -1,7 +1,12 @@
1
1
  import { textResponse, isReleaseSatisfied, isErrorResponse, ensurePrdExists } from "./shared.js";
2
2
 
3
3
  export async function handleStartTask(args, workflowState) {
4
- workflowState.reset();
4
+ // Selamatkan dulu pekerjaan yang mungkin tertimpa. Ini menangani dua kasus:
5
+ // 1. Proses MCP LAIN menulis berkas state setelah kita memuatnya.
6
+ // 2. Task kita sendiri yang belum selesai lalu diganti task baru.
7
+ const preservedBeforeSave = await workflowState.reconcileWithDisk();
8
+ const { archivedTask } = workflowState.reset({ archive: true });
9
+
5
10
  workflowState.state.currentPhase = "coding";
6
11
  workflowState.state.taskDescription = args.description;
7
12
  workflowState.state.taskType = args.type;
@@ -10,10 +15,34 @@ export async function handleStartTask(args, workflowState) {
10
15
  workflowState.state.commitAndPushCompleted = false;
11
16
  workflowState.state.lastCommitMessage = "";
12
17
  workflowState.state.lastPushBranch = "";
13
- await workflowState.save();
18
+ const reconciled = await workflowState.save();
19
+
20
+ const warnings = [];
21
+ const preservedForeign = reconciled?.preservedTask || preservedBeforeSave?.preservedTask;
22
+ if (preservedForeign) {
23
+ warnings.push(
24
+ `āš ļø Ada task yang SEDANG BERJALAN dari proses agen lain dan hampir tertimpa:\n` +
25
+ ` "${preservedForeign}"\n` +
26
+ ` Task itu sudah disimpan ke history sebagai 'interrupted', bukan dihapus.\n` +
27
+ ` State workflow di mesin ini dipakai bersama antar sesi - periksa sebelum commit.`
28
+ );
29
+ }
30
+ if (archivedTask) {
31
+ warnings.push(
32
+ `ā„¹ļø Task sebelumnya belum selesai dan diganti: "${archivedTask}".\n` +
33
+ ` Disimpan ke history sebagai 'interrupted'.`
34
+ );
35
+ }
36
+ if (reconciled?.mergedHistory > 0) {
37
+ warnings.push(
38
+ `ā„¹ļø ${reconciled.mergedHistory} entri history dari proses lain digabungkan, tidak ditimpa.`
39
+ );
40
+ }
41
+
42
+ const warningBlock = warnings.length > 0 ? `\n\n${warnings.join("\n\n")}` : "";
14
43
 
15
44
  return textResponse(
16
- `āœ… Task Started: ${args.description}\n\nšŸŽÆ Be conscious about what you're coding!\n\nWorkflow Steps:\n1. āœ“ Start task (current)\n2. ā³ Describe feature flow with Mermaid\n3. ā³ Fix/implement the feature\n4. ā³ Create tests\n5. ā³ Run tests (must pass!)\n6. ā³ Create documentation\n7. ā³ Run 'check_ready_to_commit'\n8. ā³ Run 'commit_and_push' (commits and pushes)\n9. ā³ Run 'perform_release' (handles versioning and tags) or 'skip_release' (when no release is needed)\n10. ā³ Complete task\n\nReminder: Focus on writing clean, maintainable code!`
45
+ `āœ… Task Started: ${args.description}${warningBlock}\n\nšŸŽÆ Be conscious about what you're coding!\n\nWorkflow Steps:\n1. āœ“ Start task (current)\n2. ā³ Describe feature flow with Mermaid\n3. ā³ Fix/implement the feature\n4. ā³ Create tests\n5. ā³ Run tests (must pass!)\n6. ā³ Create documentation\n7. ā³ Run 'check_ready_to_commit'\n8. ā³ Run 'commit_and_push' (commits and pushes)\n9. ā³ Run 'perform_release' (handles versioning and tags) or 'skip_release' (when no release is needed)\n10. ā³ Complete task\n\nReminder: Focus on writing clean, maintainable code!`
17
46
  );
18
47
  }
19
48
 
package/vite.config.js CHANGED
@@ -18,6 +18,12 @@ export default defineConfig({
18
18
  },
19
19
  external: [
20
20
  '@modelcontextprotocol/sdk',
21
+ // dotenv ships CommonJS only (dist/index.cjs). Bundling it into an ES
22
+ // module leaks its `require.main === module` guard, which throws
23
+ // "require is not defined in ES module scope" at startup. Keeping it
24
+ // external lets Node load it as CJS, which is how it is designed to run.
25
+ 'dotenv',
26
+ 'dotenv/config',
21
27
  // Node.js built-in modules
22
28
  'node:fs',
23
29
  'node:path',
package/workflow-state.js CHANGED
@@ -14,6 +14,22 @@ const USER_ID_FILENAME = "user-id";
14
14
  let cachedUserId = null;
15
15
  let cachedUserStateDir = null;
16
16
 
17
+ /**
18
+ * Identitas proses MCP ini.
19
+ *
20
+ * MENGAPA ADA
21
+ * -----------
22
+ * Berkas state disimpan per USER (`user-id`), bukan per sesi agen. Dua sesi
23
+ * opencode di mesin yang sama berbagi satu `workflow-state.json`. Akibatnya
24
+ * sesi B menimpa pekerjaan sesi A tanpa jejak: task A tidak pernah masuk
25
+ * `history` karena history hanya ditulis saat task DITUTUP.
26
+ *
27
+ * Token ini dicap ke setiap penulisan supaya kita bisa mengetahui bahwa isi
28
+ * berkas di disk terakhir ditulis oleh proses LAIN, lalu menyelamatkan task
29
+ * yang sedang berjalan itu sebelum kita menimpanya.
30
+ */
31
+ const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
32
+
17
33
  function generateUserId() {
18
34
  const timestamp = Date.now().toString(16);
19
35
  const randomSuffix = Math.random().toString(16).slice(2, 10);
@@ -535,10 +551,119 @@ export class WorkflowState {
535
551
  }
536
552
 
537
553
  async save() {
554
+ const reconciled = await this.reconcileWithDisk();
555
+ this.state.owner = {
556
+ token: PROCESS_TOKEN,
557
+ pid: process.pid,
558
+ updatedAt: new Date().toISOString(),
559
+ };
538
560
  await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
539
561
  await ensureCompatibilityLinks(this.stateFile);
540
562
  await this.updateProjectSummary();
541
563
  await this.syncToDatabase();
564
+ this.lastReconcile = reconciled;
565
+ return reconciled;
566
+ }
567
+
568
+ /**
569
+ * Baca isi berkas state APA ADANYA dari disk (bukan dari memori).
570
+ * Mengembalikan null kalau belum ada / rusak.
571
+ */
572
+ async readDiskState() {
573
+ try {
574
+ const data = await fs.readFile(this.stateFile, "utf-8");
575
+ return JSON.parse(data);
576
+ } catch {
577
+ return null;
578
+ }
579
+ }
580
+
581
+ /** True kalau berkas di disk terakhir ditulis proses MCP lain. */
582
+ isForeignWrite(diskState) {
583
+ const token = diskState?.owner?.token;
584
+ return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
585
+ }
586
+
587
+ historyKey(entry) {
588
+ return `${entry?.timestamp || ""}|${entry?.taskDescription || ""}`;
589
+ }
590
+
591
+ mergeDiskHistory(diskState) {
592
+ if (!diskState || !Array.isArray(diskState.history)) {
593
+ return 0;
594
+ }
595
+ if (!Array.isArray(this.state.history)) {
596
+ this.state.history = [];
597
+ }
598
+ const seen = new Set(this.state.history.map((entry) => this.historyKey(entry)));
599
+ let added = 0;
600
+ for (const entry of diskState.history) {
601
+ const key = this.historyKey(entry);
602
+ if (!seen.has(key)) {
603
+ this.state.history.push(entry);
604
+ seen.add(key);
605
+ added += 1;
606
+ }
607
+ }
608
+ return added;
609
+ }
610
+
611
+ /**
612
+ * Rekonsiliasi sebelum menulis: jangan buang pekerjaan proses lain.
613
+ *
614
+ * 1. Gabungkan `history` dari disk supaya catatan task lain tidak hilang.
615
+ * 2. Kalau disk memuat task yang SEDANG BERJALAN milik proses lain, arsipkan
616
+ * ke `history` sebagai `interrupted` sebelum kita menimpanya.
617
+ *
618
+ * Tanpa langkah 2, task itu lenyap tanpa jejak - itulah bug yang menyebabkan
619
+ * pekerjaan resize docker-alma tidak pernah muncul di view_history.
620
+ */
621
+ async reconcileWithDisk() {
622
+ const result = { mergedHistory: 0, preservedTask: null, foreignToken: null };
623
+ const disk = await this.readDiskState();
624
+ if (!disk) {
625
+ return result;
626
+ }
627
+
628
+ result.mergedHistory = this.mergeDiskHistory(disk);
629
+
630
+ if (!this.isForeignWrite(disk)) {
631
+ return result;
632
+ }
633
+
634
+ result.foreignToken = disk.owner.token;
635
+
636
+ const hasRunningTask =
637
+ typeof disk.taskDescription === "string" &&
638
+ disk.taskDescription.trim() !== "" &&
639
+ disk.currentPhase &&
640
+ disk.currentPhase !== "idle";
641
+
642
+ if (!hasRunningTask) {
643
+ return result;
644
+ }
645
+
646
+ const alreadyArchived = this.state.history.some(
647
+ (entry) => entry.interrupted && entry.taskDescription === disk.taskDescription
648
+ );
649
+ if (alreadyArchived) {
650
+ return result;
651
+ }
652
+
653
+ this.state.history.push({
654
+ taskDescription: disk.taskDescription,
655
+ taskType: disk.taskType || "other",
656
+ commitMessage: disk.lastCommitMessage || "",
657
+ interrupted: true,
658
+ dropped: true,
659
+ dropReason:
660
+ "Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
661
+ interruptedAtPhase: disk.currentPhase,
662
+ interruptedBy: disk.owner.token,
663
+ timestamp: new Date().toISOString(),
664
+ });
665
+ result.preservedTask = disk.taskDescription;
666
+ return result;
542
667
  }
543
668
 
544
669
  async updateProjectSummary() {
@@ -625,7 +750,43 @@ export class WorkflowState {
625
750
  };
626
751
  }
627
752
 
628
- reset() {
753
+ /**
754
+ * Reset ke keadaan idle.
755
+ *
756
+ * `archive` sengaja OPT-IN (default false). Pemanggil yang sudah menulis
757
+ * `addToHistory` sendiri - complete_task, force_complete_task, drop_task -
758
+ * akan menghasilkan entri ganda kalau reset ikut mengarsipkan.
759
+ *
760
+ * `start_task` memakai `{ archive: true }` supaya task yang sedang berjalan
761
+ * tidak lenyap saat diganti task baru.
762
+ */
763
+ reset(options = {}) {
764
+ const archive = options.archive === true;
765
+ const previous = this.state || {};
766
+ const history = Array.isArray(previous.history) ? previous.history.slice() : [];
767
+
768
+ const hasRunningTask =
769
+ typeof previous.taskDescription === "string" &&
770
+ previous.taskDescription.trim() !== "" &&
771
+ previous.currentPhase &&
772
+ previous.currentPhase !== "idle";
773
+
774
+ let archivedTask = null;
775
+ if (archive && hasRunningTask) {
776
+ archivedTask = previous.taskDescription;
777
+ history.push({
778
+ taskDescription: previous.taskDescription,
779
+ taskType: previous.taskType || "other",
780
+ commitMessage: previous.lastCommitMessage || "",
781
+ interrupted: true,
782
+ dropped: true,
783
+ dropReason:
784
+ options.reason || "Diganti start_task berikutnya sebelum task selesai",
785
+ interruptedAtPhase: previous.currentPhase,
786
+ timestamp: new Date().toISOString(),
787
+ });
788
+ }
789
+
629
790
  this.state = {
630
791
  currentPhase: "idle",
631
792
  taskDescription: "",
@@ -647,8 +808,11 @@ export class WorkflowState {
647
808
  lastPushBranch: "",
648
809
  featureFlowCreated: false,
649
810
  featureFlowDescription: "",
650
- history: this.state.history,
811
+ history,
812
+ taskType: "",
651
813
  };
814
+
815
+ return { archivedTask };
652
816
  }
653
817
 
654
818
  addToHistory(entry) {
@@ -1,75 +0,0 @@
1
- import fetch from 'node-fetch';
2
-
3
- const API_BASE_URL = 'https://hollywood-andrew-cycle-genre.trycloudflare.com';
4
- const USERNAME = 'programinglive';
5
- const PASSWORD = 'mcpdevserver';
6
-
7
- async function testAuthFlow() {
8
- console.log('šŸš€ Starting Auth Flow Test...');
9
- console.log(`Target: ${API_BASE_URL}\n`);
10
-
11
- try {
12
- // 1. Login
13
- console.log('1ļøāƒ£ Attempting Login...');
14
- const loginRes = await fetch(`${API_BASE_URL}/api/auth/login`, {
15
- method: 'POST',
16
- headers: { 'Content-Type': 'application/json' },
17
- body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
18
- });
19
-
20
- console.log(` Status: ${loginRes.status}`);
21
- if (!loginRes.ok) {
22
- throw new Error(`Login failed: ${loginRes.statusText}`);
23
- }
24
-
25
- const loginData = await loginRes.json();
26
- const token = loginData.token;
27
-
28
- if (token) {
29
- console.log(' āœ… Login Successful! JWT Token received.');
30
- console.log(` Token: ${token.substring(0, 20)}...`);
31
- } else {
32
- console.log(' āš ļø Login successful but NO TOKEN returned (Legacy Session mode?)');
33
- }
34
-
35
- // 2. Access Protected Route (GET /me)
36
- console.log('\n2ļøāƒ£ Verifying Session/Token (GET /api/auth/me)...');
37
- const headers = {};
38
- if (token) {
39
- headers['Authorization'] = `Bearer ${token}`;
40
- console.log(' Using Authorization: Bearer header');
41
- } else {
42
- console.log(' Using Cookies (if any)');
43
- }
44
-
45
- const meRes = await fetch(`${API_BASE_URL}/api/auth/me`, {
46
- headers,
47
- });
48
-
49
- console.log(` Status: ${meRes.status}`);
50
- if (meRes.ok) {
51
- const meData = await meRes.json();
52
- console.log(` āœ… Authorized! User: ${meData.username} (${meData.email})`);
53
- } else {
54
- console.error(' āŒ Authorization Failed!');
55
- console.error(await meRes.text());
56
- }
57
-
58
- // 3. Access History (GET /api/history)
59
- console.log('\n3ļøāƒ£ Fetching History...');
60
- const historyRes = await fetch(`${API_BASE_URL}/api/history`, { headers });
61
- console.log(` Status: ${historyRes.status}`);
62
-
63
- if (historyRes.ok) {
64
- const historyData = await historyRes.json();
65
- console.log(` āœ… History fetched! Count: ${historyData.history ? historyData.history.length : 0}`);
66
- } else {
67
- console.log(' āŒ History fetch failed.');
68
- }
69
-
70
- } catch (error) {
71
- console.error('\nāŒ Test Failed:', error.message);
72
- }
73
- }
74
-
75
- testAuthFlow();