@programinglive/dev-workflow-mcp-server 1.12.1 → 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/.dockerignore +24 -0
- package/.env.example +11 -31
- package/README.md +109 -6
- package/db/adapters/base.js +32 -0
- package/db/adapters/mysql.js +13 -7
- package/db/adapters/postgres.js +12 -7
- package/db/adapters/sqlite.js +17 -9
- package/db/index.js +2 -1
- package/docker-compose.yml +7 -5
- package/exec.js +78 -12
- package/factory-client.js +142 -10
- package/index.js +28 -19
- package/install.js +6 -5
- package/package.json +4 -1
- package/release-wrapper.js +32 -19
- package/scripts/check-review-guard.mjs +33 -0
- package/scripts/create-admin.js +11 -9
- package/scripts/install-husky.js +3 -2
- package/scripts/lint.mjs +20 -0
- package/scripts/release.cjs +25 -22
- package/scripts/release.js +6 -5
- package/scripts/setup-stack.js +42 -41
- package/scripts/update-release-notes.cjs +13 -12
- package/scripts/update-release-notes.js +8 -7
- package/scripts/verify-bundle.mjs +8 -7
- package/security/audit.js +86 -0
- package/security/commands.js +10 -0
- package/security/config.js +24 -0
- package/security/files.js +27 -0
- package/security/http.js +26 -0
- package/security/log-bridge.cjs +17 -0
- package/security/policy.js +37 -0
- package/security/references.js +21 -0
- package/security/runtime-secrets.js +42 -0
- package/security/sanitizer.js +42 -0
- package/security/scanner.js +108 -0
- package/tools/definitions.js +51 -3
- package/tools/handler-modules/commit-handlers.js +3 -7
- package/tools/handler-modules/factory-handlers.js +84 -0
- package/tools/handler-modules/index.js +78 -3
- package/tools/handler-modules/shared.js +5 -2
- package/tools/handler-modules/status-handlers.js +1 -1
- package/tools/handler-modules/summary-handlers.js +2 -1
- package/tools/handler-modules/workflow-handlers.js +6 -1
- package/utils.js +1 -0
- package/verify-build.js +7 -6
- package/vite.config.js +4 -0
- package/workflow-state.js +249 -34
package/.dockerignore
CHANGED
|
@@ -12,6 +12,30 @@ yarn-error.log
|
|
|
12
12
|
.env
|
|
13
13
|
.env.local
|
|
14
14
|
.env.*.local
|
|
15
|
+
**/.env*
|
|
16
|
+
**/.npmrc
|
|
17
|
+
**/.ssh
|
|
18
|
+
**/.docker
|
|
19
|
+
**/.aws
|
|
20
|
+
**/.kube
|
|
21
|
+
**/.config/gcloud
|
|
22
|
+
**/.git-credentials
|
|
23
|
+
**/.netrc
|
|
24
|
+
**/.aws
|
|
25
|
+
**/.kube
|
|
26
|
+
**/.config/gcloud
|
|
27
|
+
**/.git-credentials
|
|
28
|
+
**/.netrc
|
|
29
|
+
**/*credentials*.json
|
|
30
|
+
**/service-account*.json
|
|
31
|
+
**/id_rsa*
|
|
32
|
+
**/id_ed25519*
|
|
33
|
+
**/*.pem
|
|
34
|
+
**/*.key
|
|
35
|
+
**/*.p12
|
|
36
|
+
**/*.pfx
|
|
37
|
+
.state
|
|
38
|
+
**/*.db*
|
|
15
39
|
|
|
16
40
|
# Development files
|
|
17
41
|
*.log
|
package/.env.example
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
1
|
+
# Strict mode is the default and the only supported mode.
|
|
2
|
+
DEV_WORKFLOW_SECURITY_MODE=strict
|
|
3
3
|
DEV_WORKFLOW_DB_TYPE=sqlite
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
# ── Homelab Development ───────────────────────────────────────────────────────
|
|
15
|
-
# These are reference values. Replace with your actual credentials.
|
|
16
|
-
# Never commit this file with real values — use .env instead.
|
|
17
|
-
|
|
18
|
-
# GitHub
|
|
19
|
-
GH_TOKEN=
|
|
20
|
-
|
|
21
|
-
# GitLab (profile mahardhika)
|
|
22
|
-
GL_TOKEN=
|
|
23
|
-
|
|
24
|
-
# Cloudflare (for wrangler / CDN)
|
|
25
|
-
CF_ACCOUNT_ID=
|
|
26
|
-
CF_API_TOKEN=
|
|
27
|
-
|
|
28
|
-
# Tailscale (for auth key generation, optional)
|
|
29
|
-
TAILSCALE_AUTH_KEY=
|
|
30
|
-
|
|
31
|
-
# SSH config — do NOT store private keys here.
|
|
32
|
-
# Private keys stay in ~/.ssh/ on each machine.
|
|
4
|
+
# Supply PROJECT_DATABASE_URL privately through your shell/service manager.
|
|
5
|
+
# Do not put its value in MCP client JSON or knowledge records.
|
|
6
|
+
DEV_WORKFLOW_DB_URL_ENV=
|
|
7
|
+
# For PostgreSQL/MySQL: DEV_WORKFLOW_DB_URL_ENV=PROJECT_DATABASE_URL
|
|
8
|
+
# For SQLite, omit DEV_WORKFLOW_DB_URL_ENV or leave it empty.
|
|
9
|
+
# Knowledge example: secret_ref: env://PROJECT_DATABASE_URL
|
|
10
|
+
# Runtime-only web auth secrets must be independently generated (32+ characters).
|
|
11
|
+
# JWT_SECRET and SESSION_SECRET: provision outside this example file.
|
|
12
|
+
# Never commit real credentials.
|
package/README.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Dev Workflow MCP Server
|
|
2
2
|
|
|
3
|
+
## Credential isolation
|
|
4
|
+
|
|
5
|
+
Never commit real credentials. Never store raw secrets inside the workflow
|
|
6
|
+
knowledge database. Use environment variables or a secret manager.
|
|
7
|
+
|
|
8
|
+
`DEV_WORKFLOW_SECURITY_MODE=strict` is the default and only supported mode.
|
|
9
|
+
Knowledge may describe credential locations and requirements using SecretReference:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
database:
|
|
13
|
+
host: db.internal
|
|
14
|
+
secret_ref: env://PROJECT_DATABASE_URL
|
|
15
|
+
classification: INTERNAL
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Supported reference metadata: `env://VARIABLE_NAME`, `op://vault/item/field`,
|
|
19
|
+
`vault://path/to/secret`, `aws-secrets://secret-name`, `gcp-secret://secret-name`,
|
|
20
|
+
`bitwarden://item/field`. MCP does not resolve these references.
|
|
21
|
+
|
|
22
|
+
For PostgreSQL/MySQL, client configuration should specify the variable name only:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{"DEV_WORKFLOW_DB_TYPE":"postgres","DEV_WORKFLOW_DB_URL_ENV":"PROJECT_DATABASE_URL"}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Provision that variable in the server's service manager, shell or secret-manager
|
|
29
|
+
launcher outside the model and client JSON. The legacy direct URL setting remains
|
|
30
|
+
supported with a value-free deprecation warning. SQLite needs neither URL setting.
|
|
31
|
+
|
|
32
|
+
Raw secret input and writes are rejected with sanitized findings. SECRET-labelled
|
|
33
|
+
records are never returned; PUBLIC, INTERNAL (default) and CONFIDENTIAL records
|
|
34
|
+
still pass through the output scanner. Use `npm run security:audit` or MCP
|
|
35
|
+
`security_audit` for bounded, current-user/project findings. Protected files return
|
|
36
|
+
metadata only; the audit does not read their content, resolve secrets or repair data.
|
|
37
|
+
|
|
38
|
+
Migration: supply explicit `JWT_SECRET`, `SESSION_SECRET` (32+ characters) for their
|
|
39
|
+
respective web servers and `ADMIN_PASSWORD` (16+ characters) to create an admin.
|
|
40
|
+
Login now uses an HttpOnly cookie; `token` is no longer returned in JSON. Previously
|
|
41
|
+
issued bearer tokens remain accepted until expiry. Update custom clients to cookie
|
|
42
|
+
authentication. Browser cross-site cookie policies may require a same-site API domain.
|
|
43
|
+
Compose requires explicit database credentials and `JWT_SECRET`; build its API from
|
|
44
|
+
the repository root. Strict release tools accept the established `npm run release`
|
|
45
|
+
syntax only; run other deployment commands in a trusted operator session.
|
|
46
|
+
|
|
47
|
+
Read the [audit, threat model and migration plan](docs/security-audit.md) and
|
|
48
|
+
[verification and remaining risks](docs/security-verification.md). Passing tests
|
|
49
|
+
does not prove that all secrets are detected. Trusted executable repository scripts,
|
|
50
|
+
external tools, runtime authentication stores, historical Git and backups still
|
|
51
|
+
require operational controls. A credential already pasted into a model cannot be
|
|
52
|
+
removed by this server.
|
|
53
|
+
|
|
3
54
|
An MCP server that enforces development workflow discipline: **tests → documentation → commit → release**, with no way to skip a step.
|
|
4
55
|
|
|
5
56
|
```mermaid
|
|
@@ -86,12 +137,14 @@ See [MCP client configuration](#mcp-client-configuration).
|
|
|
86
137
|
| `project_summary_data` | Read the persisted `project-summary.json` |
|
|
87
138
|
| `project_summary_db` | Read the per-user summary from SQLite |
|
|
88
139
|
| `list_factory_tasks` | List your Factory task queue (requires config) |
|
|
140
|
+
| `create_factory_task` | Record a new task with a required definition of done |
|
|
141
|
+
| `update_factory_task` | Edit a task, including filling a missing definition of done |
|
|
89
142
|
| `claim_factory_task` | Claim a Factory task and start a local workflow |
|
|
90
143
|
| `sync_task_status` | Report a status change back to Factory |
|
|
91
144
|
|
|
92
145
|
### Factory task integration
|
|
93
146
|
|
|
94
|
-
|
|
147
|
+
Five tools connect this server to the **Factory** board at
|
|
95
148
|
`factory.programinglive.com`. They read and write the real task table
|
|
96
149
|
(`factory_tasks`), not the retired PocketBase `activity_tasks` collection
|
|
97
150
|
(which still exists but holds 0 records and never reached the board).
|
|
@@ -127,6 +180,8 @@ account only sees its own tasks.
|
|
|
127
180
|
| Tool | Writes? | What it does |
|
|
128
181
|
|---|---|---|
|
|
129
182
|
| `list_factory_tasks` | no | Reads `GET /api/factory`, maps tasks plus their latest work session, and orders them status → priority. Defaults to `running, queued, backlog, review, blocked` |
|
|
183
|
+
| `create_factory_task` | yes | Creates a task (`POST /api/factory {action:create_task}`). Requires `acceptance` and `definitionOfDone`; defaults to `backlog` so recording work never starts a worker run |
|
|
184
|
+
| `update_factory_task` | yes | Edits title, description, acceptance or definitionOfDone (`POST /api/factory {action:update_task}`), sending the task's current version so Factory can reject a stale edit with 409 |
|
|
130
185
|
| `claim_factory_task` | yes | Opens a work session (`POST /api/work {action:start}`), which is what turns the task `running` on the board, then starts a local workflow |
|
|
131
186
|
| `sync_task_status` | yes | Uses the only endpoint that can produce the status; see the table below |
|
|
132
187
|
|
|
@@ -143,10 +198,40 @@ the endpoint that actually supports it:
|
|
|
143
198
|
| `done` | — rejected | A human approves the work in Factory |
|
|
144
199
|
|
|
145
200
|
`review` requires an active work session. Asking for it without one fails with
|
|
146
|
-
"no active work session" instead of silently doing nothing
|
|
201
|
+
"no active work session" instead of silently doing nothing - the task must be
|
|
147
202
|
claimed first. Full behaviour, limits (summary 2000 chars, step 240 chars) and
|
|
148
203
|
troubleshooting live in [docs/factory-integration.md](docs/factory-integration.md).
|
|
149
204
|
|
|
205
|
+
#### Review is reported once, not twice
|
|
206
|
+
|
|
207
|
+
Factory reads a second `review` report as an **approval**, so a task can jump to
|
|
208
|
+
`done` without a human deciding anything. That happened on 29 October 2026 to
|
|
209
|
+
four tasks whose earlier session was already `review` while the board still said
|
|
210
|
+
`backlog`.
|
|
211
|
+
|
|
212
|
+
The client now refuses `review` when the latest lifecycle event for that task is
|
|
213
|
+
a `work.review` or `task.done`, and says what to do instead:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
Task "..." sudah pernah dilaporkan untuk review (work.review pada ...).
|
|
217
|
+
Melaporkan review kedua kali dibaca Factory sebagai approval, ...
|
|
218
|
+
Minta pemilik memutuskan: set done bila pekerjaan diterima, atau kembalikan
|
|
219
|
+
ke backlog bila perlu dikerjakan ulang.
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`blocked` and `paused` are unaffected: only `review` carries approval semantics.
|
|
223
|
+
|
|
224
|
+
A later `work.paused` event re-opens the task, so rework can be reported again.
|
|
225
|
+
That matters because `paused` is the only supported way back from `review` to
|
|
226
|
+
`backlog`; without the exception the guard would block legitimate second
|
|
227
|
+
reviews forever.
|
|
228
|
+
|
|
229
|
+
#### Task ids are UUIDs
|
|
230
|
+
|
|
231
|
+
`list_factory_tasks` prints the full UUID, because `sync_task_status` and
|
|
232
|
+
`claim_factory_task` reject the 8-character short key. Earlier versions printed
|
|
233
|
+
the short key, which forced agents to call `GET /api/factory` by hand.
|
|
234
|
+
|
|
150
235
|
|
|
151
236
|
### Example session
|
|
152
237
|
|
|
@@ -275,7 +360,7 @@ supported:
|
|
|
275
360
|
```bash
|
|
276
361
|
# .env or environment variable
|
|
277
362
|
DEV_WORKFLOW_DB_TYPE=postgres
|
|
278
|
-
DEV_WORKFLOW_DB_URL=postgresql://
|
|
363
|
+
DEV_WORKFLOW_DB_URL=postgresql://USER:[REDACTED]@HOST:5432/database
|
|
279
364
|
```
|
|
280
365
|
|
|
281
366
|
| Variable | Default | Description |
|
|
@@ -373,8 +458,8 @@ The two skipped tests are the MySQL and PostgreSQL adapters. They run only when
|
|
|
373
458
|
database is reachable:
|
|
374
459
|
|
|
375
460
|
```bash
|
|
376
|
-
TEST_MYSQL_URL=mysql://
|
|
377
|
-
TEST_POSTGRES_URL=postgres://
|
|
461
|
+
TEST_MYSQL_URL=mysql://USER:[REDACTED]@HOST:5432/database npm test
|
|
462
|
+
TEST_POSTGRES_URL=postgres://USER:[REDACTED]@HOST:5432/database npm test
|
|
378
463
|
```
|
|
379
464
|
|
|
380
465
|
To exercise them against real servers without exposing them publicly:
|
|
@@ -518,9 +603,27 @@ installed. Run `npm install` in the repo.
|
|
|
518
603
|
because there is no prebuild for your Node ABI. Either switch to a Node version
|
|
519
604
|
that has one, or install Visual Studio Build Tools.
|
|
520
605
|
|
|
521
|
-
**`EPERM ... better_sqlite3.node`**
|
|
606
|
+
**`EPERM ... better_sqlite3.node`** - a running process holds the file. Stop it,
|
|
522
607
|
then reinstall.
|
|
523
608
|
|
|
609
|
+
**`commit_and_push` says "No changes detected" while the project clearly has
|
|
610
|
+
changes** - git ran in the wrong repository. The server used to fall back to its
|
|
611
|
+
own package directory, which is also a git repo, so commits landed there (or
|
|
612
|
+
nowhere) instead of in your project. It now refuses to run git in the package
|
|
613
|
+
repo and asks for the project explicitly:
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
export WORKFLOW_PROJECT_DIR=/path/to/your/project
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Point it at any directory inside the project; the repo root is discovered
|
|
620
|
+
upward. Non-git commands are unaffected.
|
|
621
|
+
|
|
622
|
+
**A task jumped straight to `done` after reporting `review`** - it was reported
|
|
623
|
+
twice. Factory treats the second report as approval. Current versions block that
|
|
624
|
+
at the client, but if it already happened, review the task and move it back to
|
|
625
|
+
`backlog` if the work is not accepted.
|
|
626
|
+
|
|
524
627
|
**OpenCode does not load the server** — check the config shape. V2 requires
|
|
525
628
|
`mcp.servers` and rejects `enabled`:
|
|
526
629
|
|
package/db/adapters/base.js
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
|
+
import { assertSafeKnowledge } from '../../security/policy.js';
|
|
2
|
+
import { securityOutputFilter } from '../../security/sanitizer.js';
|
|
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
|
+
|
|
1
24
|
export class DbAdapter {
|
|
2
25
|
constructor(config = {}) {
|
|
3
26
|
this.config = config;
|
|
27
|
+
// Guard direct adapter use as well as the db/index facade.
|
|
28
|
+
for (const name of ['insertHistoryEntry','saveState','updateSummaryForUser']) {
|
|
29
|
+
const operation=this[name].bind(this);
|
|
30
|
+
this[name]=async(...args)=>{assertSafeKnowledge(args);return operation(...args);};
|
|
31
|
+
}
|
|
32
|
+
for (const name of ['getState','getHistoryForUser','getSummaryForUser','getHistorySummary']) {
|
|
33
|
+
const operation=this[name].bind(this);
|
|
34
|
+
this[name]=async(...args)=>securityOutputFilter(await operation(...args));
|
|
35
|
+
}
|
|
4
36
|
}
|
|
5
37
|
|
|
6
38
|
async connect() {
|
package/db/adapters/mysql.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import mysql from "mysql2/promise";
|
|
2
|
-
import { DbAdapter } from "./base.js";
|
|
2
|
+
import { DbAdapter, normalizeTaskType } from "./base.js";
|
|
3
|
+
import { assertSafeKnowledge, classify } from '../../security/policy.js';
|
|
3
4
|
|
|
4
5
|
const FREQUENCY_FORMATS = {
|
|
5
6
|
daily: "%Y-%m-%d",
|
|
@@ -69,6 +70,8 @@ export class MysqlAdapter extends DbAdapter {
|
|
|
69
70
|
)
|
|
70
71
|
`);
|
|
71
72
|
|
|
73
|
+
const [classificationColumns]=await conn.query("SHOW COLUMNS FROM workflow_history LIKE 'classification'");
|
|
74
|
+
if(!classificationColumns.length) await conn.query("ALTER TABLE workflow_history ADD COLUMN classification VARCHAR(20) NOT NULL DEFAULT 'INTERNAL'");
|
|
72
75
|
// Indexes are effectively covered by PRIMARY/UNIQUE keys, but we can add secondary if needed.
|
|
73
76
|
} finally {
|
|
74
77
|
conn.release();
|
|
@@ -89,27 +92,28 @@ export class MysqlAdapter extends DbAdapter {
|
|
|
89
92
|
// MySQL INSERT ON DUPLICATE KEY UPDATE
|
|
90
93
|
const query = `
|
|
91
94
|
INSERT INTO workflow_history
|
|
92
|
-
(user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason)
|
|
93
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
95
|
+
(user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason, classification)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
94
97
|
ON DUPLICATE KEY UPDATE
|
|
95
98
|
commit_message=VALUES(commit_message),
|
|
96
99
|
forced=VALUES(forced),
|
|
97
100
|
force_reason=VALUES(force_reason),
|
|
98
101
|
dropped=VALUES(dropped),
|
|
99
|
-
drop_reason=VALUES(drop_reason)
|
|
102
|
+
drop_reason=VALUES(drop_reason), classification=VALUES(classification)
|
|
100
103
|
`;
|
|
101
104
|
|
|
102
105
|
await this.pool.execute(query, [
|
|
103
106
|
userId,
|
|
104
107
|
normalizedProjectPath,
|
|
105
108
|
entry.taskDescription,
|
|
106
|
-
entry.taskType,
|
|
109
|
+
normalizeTaskType(entry.taskType),
|
|
107
110
|
entry.commitMessage,
|
|
108
111
|
this.formatDate(entry.timestamp),
|
|
109
112
|
entry.forced ? 1 : 0,
|
|
110
113
|
entry.forceReason || null,
|
|
111
114
|
entry.dropped ? 1 : 0,
|
|
112
|
-
entry.dropReason || null
|
|
115
|
+
entry.dropReason || null,
|
|
116
|
+
classify(entry)
|
|
113
117
|
]);
|
|
114
118
|
|
|
115
119
|
await this.updateSummaryForUser(userId, normalizedProjectPath);
|
|
@@ -120,7 +124,7 @@ export class MysqlAdapter extends DbAdapter {
|
|
|
120
124
|
const normalizedProjectPath = projectPath || '';
|
|
121
125
|
|
|
122
126
|
const [rows] = await this.pool.execute(`
|
|
123
|
-
SELECT task_type, timestamp, task_description
|
|
127
|
+
SELECT classification, task_type, timestamp, task_description
|
|
124
128
|
FROM workflow_history
|
|
125
129
|
WHERE user_id = ? AND project_path = ?
|
|
126
130
|
ORDER BY timestamp DESC
|
|
@@ -132,7 +136,9 @@ export class MysqlAdapter extends DbAdapter {
|
|
|
132
136
|
taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
assertSafeKnowledge(rows);
|
|
135
140
|
const recentTasks = rows.slice(0, 5).map(r => ({
|
|
141
|
+
classification: r.classification,
|
|
136
142
|
description: r.task_description,
|
|
137
143
|
type: r.task_type,
|
|
138
144
|
timestamp: r.timestamp,
|
package/db/adapters/postgres.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from "pg";
|
|
2
|
-
import { DbAdapter } from "./base.js";
|
|
2
|
+
import { DbAdapter, normalizeTaskType } from "./base.js";
|
|
3
|
+
import { assertSafeKnowledge, classify } from '../../security/policy.js';
|
|
3
4
|
|
|
4
5
|
const { Pool } = pg;
|
|
5
6
|
|
|
@@ -59,6 +60,7 @@ export class PostgresAdapter extends DbAdapter {
|
|
|
59
60
|
)
|
|
60
61
|
`);
|
|
61
62
|
|
|
63
|
+
await client.query("ALTER TABLE workflow_history ADD COLUMN IF NOT EXISTS classification TEXT NOT NULL DEFAULT 'INTERNAL'");
|
|
62
64
|
// Migration: Add columns to existing table if they don't exist
|
|
63
65
|
const columns = await client.query(`
|
|
64
66
|
SELECT column_name FROM information_schema.columns WHERE table_name = 'workflow_history'
|
|
@@ -125,27 +127,28 @@ export class PostgresAdapter extends DbAdapter {
|
|
|
125
127
|
|
|
126
128
|
const query = `
|
|
127
129
|
INSERT INTO workflow_history
|
|
128
|
-
(user_id, project_path, description, task_type, commit_message, completed_at, forced, force_reason, dropped, drop_reason)
|
|
129
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
130
|
+
(user_id, project_path, description, task_type, commit_message, completed_at, forced, force_reason, dropped, drop_reason, classification)
|
|
131
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
130
132
|
ON CONFLICT (user_id, project_path, completed_at, description) DO UPDATE SET
|
|
131
133
|
commit_message=EXCLUDED.commit_message,
|
|
132
134
|
forced=EXCLUDED.forced,
|
|
133
135
|
force_reason=EXCLUDED.force_reason,
|
|
134
136
|
dropped=EXCLUDED.dropped,
|
|
135
|
-
drop_reason=EXCLUDED.drop_reason
|
|
137
|
+
drop_reason=EXCLUDED.drop_reason, classification=EXCLUDED.classification
|
|
136
138
|
`;
|
|
137
139
|
|
|
138
140
|
await this.pool.query(query, [
|
|
139
141
|
numericUserId,
|
|
140
142
|
normalizedProjectPath,
|
|
141
143
|
entry.taskDescription || entry.description,
|
|
142
|
-
entry.taskType,
|
|
144
|
+
normalizeTaskType(entry.taskType),
|
|
143
145
|
entry.commitMessage,
|
|
144
146
|
entry.timestamp || entry.completed_at || new Date().toISOString(),
|
|
145
147
|
entry.forced || false,
|
|
146
148
|
entry.forceReason || null,
|
|
147
149
|
entry.dropped || false,
|
|
148
|
-
entry.dropReason || null
|
|
150
|
+
entry.dropReason || null,
|
|
151
|
+
classify(entry)
|
|
149
152
|
]);
|
|
150
153
|
|
|
151
154
|
await this.updateSummaryForUser(userId, normalizedProjectPath);
|
|
@@ -157,7 +160,7 @@ export class PostgresAdapter extends DbAdapter {
|
|
|
157
160
|
const numericUserId = this.getNumericUserId(userId);
|
|
158
161
|
|
|
159
162
|
const res = await this.pool.query(`
|
|
160
|
-
SELECT task_type, completed_at as timestamp, description as task_description
|
|
163
|
+
SELECT classification, task_type, completed_at as timestamp, description as task_description
|
|
161
164
|
FROM workflow_history
|
|
162
165
|
WHERE user_id = $1 AND project_path = $2
|
|
163
166
|
ORDER BY completed_at DESC
|
|
@@ -172,7 +175,9 @@ export class PostgresAdapter extends DbAdapter {
|
|
|
172
175
|
taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
|
|
173
176
|
}
|
|
174
177
|
|
|
178
|
+
assertSafeKnowledge(rows);
|
|
175
179
|
const recentTasks = rows.slice(0, 5).map(r => ({
|
|
180
|
+
classification: r.classification,
|
|
176
181
|
description: r.task_description,
|
|
177
182
|
type: r.task_type,
|
|
178
183
|
timestamp: r.timestamp,
|
package/db/adapters/sqlite.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { safeLog } from '../../security/sanitizer.js';
|
|
1
2
|
import Database from "better-sqlite3";
|
|
2
3
|
import { existsSync } from "fs";
|
|
3
4
|
import path from "path";
|
|
4
|
-
import { DbAdapter } from "./base.js";
|
|
5
|
+
import { DbAdapter, normalizeTaskType } from "./base.js";
|
|
6
|
+
import { assertSafeKnowledge, classify } from '../../security/policy.js';
|
|
5
7
|
|
|
6
8
|
const DB_FILENAME = "dev-workflow.db";
|
|
7
9
|
const FREQUENCY_FORMATS = {
|
|
@@ -32,7 +34,7 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
32
34
|
limit--;
|
|
33
35
|
}
|
|
34
36
|
if (limit === 0) {
|
|
35
|
-
|
|
37
|
+
safeLog('error', "Warning: Could not find project root effectively, defaulting to INIT_CWD");
|
|
36
38
|
projectRoot = initCwd;
|
|
37
39
|
}
|
|
38
40
|
return path.join(projectRoot, ".state", DB_FILENAME);
|
|
@@ -89,6 +91,9 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
89
91
|
`;
|
|
90
92
|
|
|
91
93
|
this.db.exec(createHistory);
|
|
94
|
+
if(!this.db.prepare('PRAGMA table_info(workflow_history)').all().some(col=>col.name==='classification')) {
|
|
95
|
+
this.db.exec("ALTER TABLE workflow_history ADD COLUMN classification TEXT NOT NULL DEFAULT 'INTERNAL'");
|
|
96
|
+
}
|
|
92
97
|
|
|
93
98
|
// 2. MIGRATION: Handle existing databases (workflow_history)
|
|
94
99
|
try {
|
|
@@ -104,7 +109,7 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
104
109
|
// Duplicate entries from old schema (with no project_path) effectively mean project_path='' which matches new default.
|
|
105
110
|
}
|
|
106
111
|
} catch (e) {
|
|
107
|
-
|
|
112
|
+
safeLog('error', "Migration error (workflow_history):", e.message);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
// 3. MIGRATION: Handle project_summary (Drop and Recreate if schema mismatch)
|
|
@@ -147,27 +152,28 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
147
152
|
// SQLite upsert syntax
|
|
148
153
|
const stmt = this.db.prepare(`
|
|
149
154
|
INSERT INTO workflow_history
|
|
150
|
-
(user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason)
|
|
151
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
155
|
+
(user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason, classification)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
152
157
|
ON CONFLICT(user_id, project_path, timestamp, task_description) DO UPDATE SET
|
|
153
158
|
commit_message=excluded.commit_message,
|
|
154
159
|
forced=excluded.forced,
|
|
155
160
|
force_reason=excluded.force_reason,
|
|
156
161
|
dropped=excluded.dropped,
|
|
157
|
-
drop_reason=excluded.drop_reason
|
|
162
|
+
drop_reason=excluded.drop_reason, classification=excluded.classification
|
|
158
163
|
`);
|
|
159
164
|
|
|
160
165
|
stmt.run(
|
|
161
166
|
userId,
|
|
162
167
|
normalizedProjectPath,
|
|
163
168
|
entry.taskDescription,
|
|
164
|
-
entry.taskType,
|
|
169
|
+
normalizeTaskType(entry.taskType),
|
|
165
170
|
entry.commitMessage,
|
|
166
171
|
entry.timestamp || new Date().toISOString(),
|
|
167
172
|
entry.forced ? 1 : 0,
|
|
168
173
|
entry.forceReason || null,
|
|
169
174
|
entry.dropped ? 1 : 0,
|
|
170
|
-
entry.dropReason || null
|
|
175
|
+
entry.dropReason || null,
|
|
176
|
+
classify(entry)
|
|
171
177
|
);
|
|
172
178
|
|
|
173
179
|
await this.updateSummaryForUser(userId, normalizedProjectPath);
|
|
@@ -178,7 +184,7 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
178
184
|
const normalizedProjectPath = projectPath || '';
|
|
179
185
|
|
|
180
186
|
const getHistory = this.db.prepare(`
|
|
181
|
-
SELECT task_type, timestamp, task_description
|
|
187
|
+
SELECT classification, task_type, timestamp, task_description
|
|
182
188
|
FROM workflow_history
|
|
183
189
|
WHERE user_id = ? AND project_path = ?
|
|
184
190
|
ORDER BY timestamp DESC
|
|
@@ -191,7 +197,9 @@ export class SqliteAdapter extends DbAdapter {
|
|
|
191
197
|
taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
assertSafeKnowledge(rows);
|
|
194
201
|
const recentTasks = rows.slice(0, 5).map(r => ({
|
|
202
|
+
classification: r.classification,
|
|
195
203
|
description: r.task_description,
|
|
196
204
|
type: r.task_type,
|
|
197
205
|
timestamp: r.timestamp,
|
package/db/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SqliteAdapter } from "./adapters/sqlite.js";
|
|
2
2
|
import { MysqlAdapter } from "./adapters/mysql.js";
|
|
3
3
|
import { PostgresAdapter } from "./adapters/postgres.js";
|
|
4
|
+
import { databaseConnectionUrl } from '../security/config.js';
|
|
4
5
|
|
|
5
6
|
let adapterInstance = null;
|
|
6
7
|
|
|
@@ -11,7 +12,7 @@ export async function getAdapter() {
|
|
|
11
12
|
|
|
12
13
|
const type = (process.env.DEV_WORKFLOW_DB_TYPE || "sqlite").toLowerCase();
|
|
13
14
|
const config = {
|
|
14
|
-
connectionUrl:
|
|
15
|
+
connectionUrl: databaseConnectionUrl(),
|
|
15
16
|
dbPath: process.env.DEV_WORKFLOW_DB_PATH // optional override for sqlite
|
|
16
17
|
};
|
|
17
18
|
|
package/docker-compose.yml
CHANGED
|
@@ -7,7 +7,7 @@ services:
|
|
|
7
7
|
restart: unless-stopped
|
|
8
8
|
environment:
|
|
9
9
|
POSTGRES_USER: ${POSTGRES_USER:-devworkflow}
|
|
10
|
-
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD
|
|
10
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Provision POSTGRES_PASSWORD privately}
|
|
11
11
|
POSTGRES_DB: ${POSTGRES_DB:-devworkflow}
|
|
12
12
|
ports:
|
|
13
13
|
- "5432:5432"
|
|
@@ -21,14 +21,16 @@ services:
|
|
|
21
21
|
|
|
22
22
|
api:
|
|
23
23
|
build:
|
|
24
|
-
context:
|
|
25
|
-
dockerfile: Dockerfile.api
|
|
24
|
+
context: .
|
|
25
|
+
dockerfile: web/Dockerfile.api
|
|
26
26
|
container_name: dev-workflow-api
|
|
27
27
|
ports:
|
|
28
28
|
- "8080:8080"
|
|
29
29
|
environment:
|
|
30
|
-
-
|
|
31
|
-
-
|
|
30
|
+
- DEV_WORKFLOW_DB_URL_ENV=PROJECT_DATABASE_URL
|
|
31
|
+
- PROJECT_DATABASE_URL=${PROJECT_DATABASE_URL:?Provision PROJECT_DATABASE_URL privately}
|
|
32
|
+
- JWT_SECRET=${JWT_SECRET:?Provision JWT_SECRET privately}
|
|
33
|
+
- DEV_WORKFLOW_SECURITY_MODE=strict
|
|
32
34
|
- NODE_ENV=${NODE_ENV:-production}
|
|
33
35
|
- API_PORT=8080
|
|
34
36
|
depends_on:
|
package/exec.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { exec as execCallback } from "child_process";
|
|
1
|
+
import { exec as execCallback, execFile as execFileCallback } from "child_process";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { promisify } from "util";
|
|
6
|
+
import { sanitize } from './security/sanitizer.js';
|
|
6
7
|
|
|
7
8
|
const execPromise = promisify(execCallback);
|
|
9
|
+
const execFilePromise = promisify(execFileCallback);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Direktori paket server MCP ini.
|
|
13
|
+
*
|
|
14
|
+
* Penting: repo ini juga repo git. Kalau git dijalankan di sini, hasilnya
|
|
15
|
+
* "No changes detected" sementara perubahan proyek tidak pernah ter-commit -
|
|
16
|
+
* persis bug yang terjadi 29 Oktober 2026 saat `commit_and_push` melaporkan
|
|
17
|
+
* commit `docs(release): catat rilis 1.12.3` milik repo paket, bukan repo kerja.
|
|
18
|
+
*/
|
|
19
|
+
export const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
8
20
|
|
|
9
21
|
function findRepoRoot(startDir) {
|
|
10
22
|
if (!startDir) {
|
|
@@ -32,18 +44,72 @@ function findRepoRoot(startDir) {
|
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
function samePath(a, b) {
|
|
48
|
+
return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Menentukan direktori repo proyek tempat git boleh dijalankan.
|
|
53
|
+
*
|
|
54
|
+
* Urutan: eksplisit -> WORKFLOW_PROJECT_DIR/PROJECT_DIR -> kandidat.
|
|
55
|
+
* Repo paket MCP selalu dilewati; kalau tidak ada pilihan lain, lebih baik
|
|
56
|
+
* gagal dengan pesan jelas daripada commit ke repo yang salah.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveWorkingDirectory({ explicit = "", env = process.env, candidates = [] } = {}) {
|
|
59
|
+
if (explicit) {
|
|
60
|
+
const root = findRepoRoot(explicit);
|
|
61
|
+
if (!root) {
|
|
62
|
+
throw new Error(`Direktori "${explicit}" bukan repo git (tidak ada .git).`);
|
|
63
|
+
}
|
|
64
|
+
return root;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const envDir = (env.WORKFLOW_PROJECT_DIR || env.PROJECT_DIR || "").trim();
|
|
68
|
+
if (envDir) {
|
|
69
|
+
const root = findRepoRoot(envDir);
|
|
70
|
+
if (!root) {
|
|
71
|
+
throw new Error(`WORKFLOW_PROJECT_DIR "${envDir}" bukan repo git (tidak ada .git).`);
|
|
41
72
|
}
|
|
42
|
-
return
|
|
43
|
-
}
|
|
73
|
+
return root;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const found = candidates.map((candidate) => findRepoRoot(candidate)).filter(Boolean);
|
|
77
|
+
const usable = found.find((dir) => !samePath(dir, PACKAGE_DIR));
|
|
78
|
+
if (usable) {
|
|
79
|
+
return usable;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(
|
|
83
|
+
"Tidak bisa menentukan repo proyek: satu-satunya kandidat adalah repo paket server MCP. " +
|
|
84
|
+
"Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari direktori proyek.",
|
|
85
|
+
);
|
|
44
86
|
}
|
|
45
87
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
88
|
+
function getDefaultCwd(command) {
|
|
89
|
+
try {
|
|
90
|
+
return resolveWorkingDirectory({
|
|
91
|
+
env: process.env,
|
|
92
|
+
candidates: [process.cwd(), process.env.INIT_CWD],
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Perintah non-git (echo, npm run release, ...) tidak butuh repo proyek.
|
|
96
|
+
// Yang berbahaya hanya git: di sana cwd menentukan repo mana yang diubah.
|
|
97
|
+
if (/^\s*git\b/i.test(String(command))) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return process.cwd();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function exec(command, options = {}) {
|
|
105
|
+
const {executable,arguments:args,...runtimeOptions}=options;
|
|
106
|
+
const execOptions = runtimeOptions.cwd ? runtimeOptions : { ...runtimeOptions, cwd: getDefaultCwd(executable || command) };
|
|
107
|
+
try { return sanitize(await (executable ? execFilePromise(executable,args,{...execOptions,shell:false}) : execPromise(command, execOptions))); }
|
|
108
|
+
catch(error) {
|
|
109
|
+
const safe=new Error(sanitize(error.message));
|
|
110
|
+
safe.code=error.code;
|
|
111
|
+
safe.stdout=sanitize(error.stdout || '');
|
|
112
|
+
safe.stderr=sanitize(error.stderr || '');
|
|
113
|
+
throw safe;
|
|
114
|
+
}
|
|
49
115
|
}
|