@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.
- package/README.md +311 -173
- package/factory-client.js +554 -0
- package/git-facade.js +35 -0
- package/index.js +4 -30
- package/package.json +14 -18
- package/scripts/setup-test-dbs.sh +61 -0
- package/scripts/test-db-adapters.sh +71 -0
- package/scripts/verify-bundle.mjs +92 -0
- package/tools/definitions.js +124 -0
- package/tools/handler-modules/factory-handlers.js +276 -0
- package/tools/handler-modules/index.js +11 -0
- package/tools/handler-modules/status-handlers.js +22 -2
- package/tools/handler-modules/workflow-handlers.js +32 -3
- package/vite.config.js +6 -0
- package/workflow-state.js +166 -2
- package/scripts/test-auth-flow.js +0 -75
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|