@worca/app 1.2.0-rc.3 → 1.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3736 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1589 -212
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +447 -29
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1499 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1304 -194
package/src/core/config.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { getDb, prepare, tx } from './db.mjs';
|
|
16
16
|
import { projectKey } from './store.mjs';
|
|
17
|
+
import { AUTO_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
|
|
17
18
|
import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
|
|
18
19
|
import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
|
|
19
20
|
import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels } from './settings.mjs';
|
|
@@ -136,7 +137,7 @@ function parseJson(text, fallback) {
|
|
|
136
137
|
function readConfigRow(key) {
|
|
137
138
|
getDb();
|
|
138
139
|
return prepare(
|
|
139
|
-
'SELECT steps, custom_models, active_workflow_id, extra FROM project_config WHERE project_key = ?'
|
|
140
|
+
'SELECT steps, custom_models, active_workflow_id, extra, human_in_loop FROM project_config WHERE project_key = ?'
|
|
140
141
|
).get(key) || null;
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -838,11 +839,18 @@ export async function readRunConfig(projectDir) {
|
|
|
838
839
|
const extra = row ? parseJson(row.extra, {}) : {};
|
|
839
840
|
if (extra.webUiTesting && typeof extra.webUiTesting === 'object') out.webUiTesting = extra.webUiTesting;
|
|
840
841
|
// Forward any OTHER unknown keys verbatim too (future-proof, matches "preserve unknown").
|
|
842
|
+
// prRemotes is the ship-it dialog's own preference (readPrRemotePrefs), not run config.
|
|
841
843
|
for (const [k, v] of Object.entries(extra)) {
|
|
842
|
-
if (k !== 'webUiTesting' && !(k in out)) out[k] = v;
|
|
844
|
+
if (k !== 'webUiTesting' && k !== PR_REMOTES_KEY && k !== TEAM_METRICS_KEY && !(k in out)) out[k] = v;
|
|
843
845
|
}
|
|
844
846
|
const active = row && typeof row.active_workflow_id === 'string' ? row.active_workflow_id.trim() : '';
|
|
845
|
-
|
|
847
|
+
// Spec §6.1 / D16: a project with no remembered New-pipeline choice starts on Auto.
|
|
848
|
+
out.activeWorkflowId = active || AUTO_WORKFLOW_ID;
|
|
849
|
+
// Auto workflow (spec §6.1): the human-in-the-loop switch. ON is the default
|
|
850
|
+
// and is NOT echoed — the key appears only when the project turned it off, so
|
|
851
|
+
// every consumer reads `config.humanInLoop ?? true` and the config shape of a
|
|
852
|
+
// project that never touched it stays otherwise byte-identical.
|
|
853
|
+
if (row && row.human_in_loop === 0) out.humanInLoop = false;
|
|
846
854
|
return out;
|
|
847
855
|
}
|
|
848
856
|
|
|
@@ -1010,6 +1018,115 @@ export async function setActiveWorkflow(projectDir, workflowId) {
|
|
|
1010
1018
|
});
|
|
1011
1019
|
}
|
|
1012
1020
|
|
|
1021
|
+
// ── PR remote preferences (project_config.extra.prRemotes) ──────────────────
|
|
1022
|
+
// The History "Ship it?" dialog remembers which remote the branch was pushed to
|
|
1023
|
+
// and which repo the PR was opened in. Stored inside the free-form `extra` JSON
|
|
1024
|
+
// column — the FIRST runtime writer of that column: a read-modify-write of this
|
|
1025
|
+
// ONE key inside a tx, leaving every other top-level key of `extra` byte-identical
|
|
1026
|
+
// (test/config-db.test.mjs pins that for the sibling writers, which upsert only
|
|
1027
|
+
// their own columns and never touch `extra` on conflict).
|
|
1028
|
+
const PR_REMOTES_KEY = 'prRemotes';
|
|
1029
|
+
|
|
1030
|
+
function sanitizeRemoteName(v) {
|
|
1031
|
+
const s = typeof v === 'string' ? v.trim() : '';
|
|
1032
|
+
return s && s.length <= 200 ? s : null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Remembered push/base remote names for a project, or null when none.
|
|
1037
|
+
* @param {string} projectDir
|
|
1038
|
+
* @returns {{ pushRemote:(string|null), baseRemote:(string|null) }|null}
|
|
1039
|
+
*/
|
|
1040
|
+
export function readPrRemotePrefs(projectDir) {
|
|
1041
|
+
const row = readConfigRow(projectKey(projectDir));
|
|
1042
|
+
const extra = row ? parseJson(row.extra, {}) : {};
|
|
1043
|
+
const p = extra[PR_REMOTES_KEY];
|
|
1044
|
+
if (!p || typeof p !== 'object') return null;
|
|
1045
|
+
const pushRemote = sanitizeRemoteName(p.pushRemote);
|
|
1046
|
+
const baseRemote = sanitizeRemoteName(p.baseRemote);
|
|
1047
|
+
return pushRemote || baseRemote ? { pushRemote, baseRemote } : null;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Remember the dialog's choice. Only `extra.prRemotes` changes; every other column/key is preserved. */
|
|
1051
|
+
export async function setPrRemotePrefs(projectDir, { pushRemote, baseRemote } = {}) {
|
|
1052
|
+
const key = projectKey(projectDir);
|
|
1053
|
+
const next = { pushRemote: sanitizeRemoteName(pushRemote), baseRemote: sanitizeRemoteName(baseRemote) };
|
|
1054
|
+
tx(() => {
|
|
1055
|
+
const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
|
|
1056
|
+
const extra = row ? parseJson(row.extra, {}) : {};
|
|
1057
|
+
extra[PR_REMOTES_KEY] = next;
|
|
1058
|
+
prepare(`
|
|
1059
|
+
INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
|
|
1060
|
+
VALUES (?, '{}', '[]', NULL, ?)
|
|
1061
|
+
ON CONFLICT(project_key) DO UPDATE SET extra = excluded.extra
|
|
1062
|
+
`).run(key, JSON.stringify(extra));
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ── Team-metrics preferences (project_config.extra.teamMetrics) ────────────
|
|
1067
|
+
// The discovery cache + local enable state for the team-metrics feature (§4.6).
|
|
1068
|
+
// Same read-modify-write pattern as prRemotes above, one key of the same `extra` blob.
|
|
1069
|
+
export const TEAM_METRICS_KEY = 'teamMetrics';
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* NOTE THE PARAMETER. Unlike its siblings `readPrRemotePrefs(projectDir)` /
|
|
1073
|
+
* `setPrRemotePrefs(projectDir, …)` above, which take a DIRECTORY and call
|
|
1074
|
+
* `projectKey()` themselves, these two take the KEY. Passing a path is not a type error — it is a
|
|
1075
|
+
* valid SQL parameter that matches no row, so the call silently returns null, which reads as
|
|
1076
|
+
* "not enabled" and drops every record. `assertProjectKey` makes that a loud failure instead.
|
|
1077
|
+
*/
|
|
1078
|
+
function assertProjectKey(key) {
|
|
1079
|
+
if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(key)) {
|
|
1080
|
+
throw new TypeError(`team metrics prefs take a projectKey(), not ${JSON.stringify(key)} — did you pass a directory?`);
|
|
1081
|
+
}
|
|
1082
|
+
return key;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** @returns {object|null} the cached team-metrics state for a project key */
|
|
1086
|
+
export function readTeamMetricsPrefs(key) {
|
|
1087
|
+
assertProjectKey(key);
|
|
1088
|
+
const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
|
|
1089
|
+
const extra = row ? parseJson(row.extra, {}) : {};
|
|
1090
|
+
const v = extra[TEAM_METRICS_KEY];
|
|
1091
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Shallow-merge `patch` into extra.teamMetrics; returns the merged object. */
|
|
1095
|
+
export function writeTeamMetricsPrefs(key, patch) {
|
|
1096
|
+
assertProjectKey(key);
|
|
1097
|
+
let next = null;
|
|
1098
|
+
tx(() => {
|
|
1099
|
+
const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
|
|
1100
|
+
const extra = row ? parseJson(row.extra, {}) : {};
|
|
1101
|
+
const cur = extra[TEAM_METRICS_KEY] && typeof extra[TEAM_METRICS_KEY] === 'object' ? extra[TEAM_METRICS_KEY] : {};
|
|
1102
|
+
next = { ...cur, ...patch };
|
|
1103
|
+
extra[TEAM_METRICS_KEY] = next;
|
|
1104
|
+
prepare(`
|
|
1105
|
+
INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
|
|
1106
|
+
VALUES (?, '{}', '[]', NULL, ?)
|
|
1107
|
+
ON CONFLICT(project_key) DO UPDATE SET extra = excluded.extra
|
|
1108
|
+
`).run(key, JSON.stringify(extra));
|
|
1109
|
+
});
|
|
1110
|
+
return next;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Set the project's human-in-the-loop switch for Auto runs (spec D15/D20).
|
|
1115
|
+
* @param {string} projectDir
|
|
1116
|
+
* @param {boolean} value
|
|
1117
|
+
*/
|
|
1118
|
+
export async function setHumanInLoop(projectDir, value) {
|
|
1119
|
+
const key = projectKey(projectDir);
|
|
1120
|
+
const v = value === false ? 0 : 1;
|
|
1121
|
+
tx(() => {
|
|
1122
|
+
prepare(`
|
|
1123
|
+
INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra, human_in_loop)
|
|
1124
|
+
VALUES (?, '{}', '[]', NULL, '{}', ?)
|
|
1125
|
+
ON CONFLICT(project_key) DO UPDATE SET human_in_loop = excluded.human_in_loop
|
|
1126
|
+
`).run(key, v);
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1013
1130
|
/**
|
|
1014
1131
|
* Resolve just the run-config for one workflow into { nodes, feedbacks } maps
|
|
1015
1132
|
* (the inputs resolveWorkflow overlays on the template). Unconfigured => empties.
|
package/src/core/db.mjs
CHANGED
|
@@ -54,7 +54,7 @@ const OPEN_BACKOFF_MS = 15;
|
|
|
54
54
|
/** Latest schema version. Bump + append a new migration step when the DDL grows.
|
|
55
55
|
* Exported so migration tests assert "reached the module's current version"
|
|
56
56
|
* instead of hardcoding the number — a schema bump then touches no test file. */
|
|
57
|
-
export const SCHEMA_VERSION =
|
|
57
|
+
export const SCHEMA_VERSION = 30;
|
|
58
58
|
|
|
59
59
|
/** Absolute path to the database file: <worcaHome>/worca-cc.db. */
|
|
60
60
|
export function dbPath() {
|
|
@@ -166,6 +166,9 @@ CREATE UNIQUE INDEX idx_projects_name ON projects (name COLLATE NOCASE);
|
|
|
166
166
|
|
|
167
167
|
-- workspaces: named sets of 2+ projects (was workspaces.json header fields).
|
|
168
168
|
-- id is the frozen workspaceKey (wks-<slug>-<sha1[:8]>). name is CI-unique.
|
|
169
|
+
-- metrics_project (v30, added via INCREMENTAL_COLUMNS/ladder, NOT in this base
|
|
170
|
+
-- DDL — matches diff_comments.parent_id) holds the team-metrics home: one
|
|
171
|
+
-- member's absolute path, or NULL for no home configured.
|
|
169
172
|
CREATE TABLE workspaces (
|
|
170
173
|
id TEXT PRIMARY KEY,
|
|
171
174
|
name TEXT NOT NULL COLLATE NOCASE,
|
|
@@ -752,8 +755,12 @@ const INCREMENTAL_COLUMNS = {
|
|
|
752
755
|
workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
|
|
753
756
|
config_workflow_nodes: { ask_questions: 'INTEGER', subagent_model: 'TEXT' }, // v25: sub-agent model policy
|
|
754
757
|
ask_run_links: { comment_ids: 'TEXT' }, // v22: JSON array of dc_ ids pending at launch
|
|
758
|
+
artifacts: { step_key: 'TEXT', node_id: 'TEXT', cycle: 'INTEGER', created_at: 'TEXT' }, // per-step attribution
|
|
755
759
|
ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
|
|
756
760
|
mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
|
|
761
|
+
project_config: { human_in_loop: 'INTEGER NOT NULL DEFAULT 1' }, // v28: the Auto entry's human-in-the-loop switch
|
|
762
|
+
diff_comments: { parent_id: 'TEXT REFERENCES diff_comments(id) ON DELETE CASCADE' }, // v29: reply threads; NULL = thread root
|
|
763
|
+
workspaces: { metrics_project: 'TEXT' }, // v30: team-metrics home (member absolute path); NULL = no home
|
|
757
764
|
};
|
|
758
765
|
|
|
759
766
|
/** v23: per-loop-wire cycle budgets, the graph-engine twin of
|
|
@@ -1117,6 +1124,39 @@ function applySchemaV27(db) {
|
|
|
1117
1124
|
repairSchemaGaps(db, schemaGaps(db));
|
|
1118
1125
|
}
|
|
1119
1126
|
|
|
1127
|
+
/** v28 (Auto workflow, spec §6.1 / D16): project_config.human_in_loop (additive,
|
|
1128
|
+
* declared in INCREMENTAL_COLUMNS — this repairSchemaGaps call CREATES it on the
|
|
1129
|
+
* ladder path; reconcileSchema covers a DB another build stamped past 28, adding
|
|
1130
|
+
* the column but NEVER repeating the flip) and the ONE-TIME flip of every project's
|
|
1131
|
+
* remembered New-pipeline choice to the Auto entry. The flip lives on the ladder
|
|
1132
|
+
* only: a later choice is never overwritten by a reopen. */
|
|
1133
|
+
function applySchemaV28(db) {
|
|
1134
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1135
|
+
if (hasSqliteTable(db, 'project_config')) {
|
|
1136
|
+
db.prepare("UPDATE project_config SET active_workflow_id = 'wf_auto'").run();
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** v29 (diff-comment reply threads): diff_comments.parent_id — a plain additive
|
|
1141
|
+
* column declared in INCREMENTAL_COLUMNS, applySchemaV27's shape: this
|
|
1142
|
+
* repairSchemaGaps call CREATES it on the ladder path (a DB stamped exactly 28),
|
|
1143
|
+
* reconcileSchema covers the fast path. The type string carries the REFERENCES
|
|
1144
|
+
* clause verbatim into `ALTER TABLE … ADD COLUMN`, which SQLite accepts under
|
|
1145
|
+
* foreign_keys=ON because the default is NULL. Every existing row stays NULL = a
|
|
1146
|
+
* thread root; nothing is backfilled. */
|
|
1147
|
+
function applySchemaV29(db) {
|
|
1148
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** v30 (team metrics home): workspaces.metrics_project — a plain additive column
|
|
1152
|
+
* declared in INCREMENTAL_COLUMNS, applySchemaV29's shape: this repairSchemaGaps
|
|
1153
|
+
* call CREATES it on the ladder path (a DB stamped exactly 29), reconcileSchema
|
|
1154
|
+
* covers the fast path (a DB already stamped >= 30 by a divergent ladder). NULL
|
|
1155
|
+
* on every existing row = no team-metrics home configured yet. */
|
|
1156
|
+
function applySchemaV30(db) {
|
|
1157
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1120
1160
|
/** Move every stored pin on model id `from` (lower-case) to `to`. Each table
|
|
1121
1161
|
* is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
|
|
1122
1162
|
* fs->db import) reach this step without some of them. */
|
|
@@ -1500,6 +1540,9 @@ export function migrate(db) {
|
|
|
1500
1540
|
if (current < 25) applySchemaV25(db); // sub-agent model policy + recorded child model
|
|
1501
1541
|
if (current < 26) applySchemaV26(db); // Fable 5 pins -> Fable 5.1 (catalog swap)
|
|
1502
1542
|
if (current < 27) applySchemaV27(db); // ask_attachments.kind/mime (#398)
|
|
1543
|
+
if (current < 28) applySchemaV28(db); // Auto workflow: human_in_loop + flip to wf_auto
|
|
1544
|
+
if (current < 29) applySchemaV29(db); // diff-comment reply threads: parent_id
|
|
1545
|
+
if (current < 30) applySchemaV30(db); // team metrics: workspaces.metrics_project
|
|
1503
1546
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
1504
1547
|
db.exec('COMMIT');
|
|
1505
1548
|
} catch (err) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/core/diff-comments.mjs
|
|
2
|
-
// Internal, line-anchored comments on a run's persisted diff (diff_comments, v22
|
|
2
|
+
// Internal, line-anchored comments on a run's persisted diff (diff_comments, v22;
|
|
3
|
+
// reply threads via parent_id, v29).
|
|
3
4
|
// The ONE mutation module: the REST routes in ui/server.mjs and the Ask MCP tools
|
|
4
5
|
// (through src/core/ask/comment-deps.mjs) both write through here, so anchor
|
|
5
6
|
// validation, the protected-path floor, the body cap and the change notification
|
|
@@ -35,6 +36,7 @@ function rowToComment(r) {
|
|
|
35
36
|
body: r.body, author: r.author,
|
|
36
37
|
resolved: !!r.resolved, resolvedAt: r.resolved_at ?? null,
|
|
37
38
|
sentRunId: r.sent_run_id ?? null, createdAt: r.created_at,
|
|
39
|
+
parentId: r.parent_id ?? null,
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -89,7 +91,8 @@ export function listDiffComments(storeKey, pipelineId, { status = 'all', path =
|
|
|
89
91
|
ORDER BY path, line_no, rowid`).all(...vals).map(rowToComment);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
/** Unresolved counts keyed "<storeKey>/<pipelineId>"
|
|
94
|
+
/** Unresolved counts keyed "<storeKey>/<pipelineId>" — roots only: a reply is part
|
|
95
|
+
* of its thread, not an item. Newest-commented first and
|
|
93
96
|
* hard-capped: the endpoint fans out to every open tab on every poke, and an
|
|
94
97
|
* unbounded row-per-commented-run response is the one part of it that grows with
|
|
95
98
|
* history. 5000 is a backstop, not a paging story — /api/history is itself
|
|
@@ -98,7 +101,7 @@ export function unresolvedCounts() {
|
|
|
98
101
|
getDb();
|
|
99
102
|
const out = {};
|
|
100
103
|
for (const r of prepare(`SELECT store_key, pipeline_id, count(*) AS n, max(rowid) AS last
|
|
101
|
-
FROM diff_comments WHERE resolved = 0
|
|
104
|
+
FROM diff_comments WHERE resolved = 0 AND parent_id IS NULL
|
|
102
105
|
GROUP BY store_key, pipeline_id
|
|
103
106
|
ORDER BY last DESC LIMIT 5000`).all()) {
|
|
104
107
|
out[`${r.store_key}/${r.pipeline_id}`] = r.n;
|
|
@@ -108,6 +111,14 @@ export function unresolvedCounts() {
|
|
|
108
111
|
|
|
109
112
|
// ── writes ──────────────────────────────────────────────────────────────────
|
|
110
113
|
|
|
114
|
+
/** Trimmed body, or a DiffCommentError — the one place the cap is enforced. */
|
|
115
|
+
function cleanBody(body) {
|
|
116
|
+
const text = typeof body === 'string' ? body.trim() : '';
|
|
117
|
+
if (!text) throw new DiffCommentError('body is required');
|
|
118
|
+
if (text.length > COMMENT_BODY_MAX) throw new DiffCommentError(`body exceeds ${COMMENT_BODY_MAX} characters`);
|
|
119
|
+
return text;
|
|
120
|
+
}
|
|
121
|
+
|
|
111
122
|
/**
|
|
112
123
|
* Create one comment. `patchText` is the run's diff-patch.patch as READ BY THE
|
|
113
124
|
* CALLER (the server through readRunArtifactText, the MCP bundle through
|
|
@@ -123,9 +134,7 @@ export function addDiffComment({
|
|
|
123
134
|
throw new DiffCommentError('this run has no stored diff — comments cannot be created on it');
|
|
124
135
|
}
|
|
125
136
|
if (!COMMENT_AUTHORS.includes(author)) throw new DiffCommentError('author must be "user" or "ask"');
|
|
126
|
-
const text =
|
|
127
|
-
if (!text) throw new DiffCommentError('body is required');
|
|
128
|
-
if (text.length > COMMENT_BODY_MAX) throw new DiffCommentError(`body exceeds ${COMMENT_BODY_MAX} characters`);
|
|
137
|
+
const text = cleanBody(body);
|
|
129
138
|
|
|
130
139
|
let anchor;
|
|
131
140
|
try {
|
|
@@ -147,14 +156,46 @@ export function addDiffComment({
|
|
|
147
156
|
return getDiffComment(id);
|
|
148
157
|
}
|
|
149
158
|
|
|
150
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Reply inside a thread (D1–D3). The parent must be a ROOT; the reply copies the
|
|
161
|
+
* root's anchor and its resolved state, so every reader keeps working without a
|
|
162
|
+
* join: ordering (same path/line, later rowid), the status filters, the
|
|
163
|
+
* protected-path guard (same path/old_path) and the line_text snapshot.
|
|
164
|
+
* No patch is needed — a reply anchors to nothing new, so there is no anchor to
|
|
165
|
+
* resolve and nothing to resolve it against. (Not an archived-run affordance:
|
|
166
|
+
* archiving deletes a run's comments, pipeline-delete.mjs.)
|
|
167
|
+
* @throws {DiffCommentError}
|
|
168
|
+
*/
|
|
169
|
+
export function addDiffCommentReply({ parentId, body, author } = {}) {
|
|
170
|
+
const parent = getDiffComment(parentId);
|
|
171
|
+
if (!parent) throw new DiffCommentError('comment not found');
|
|
172
|
+
if (parent.parentId) throw new DiffCommentError('replies cannot be nested — reply to the thread\'s first comment');
|
|
173
|
+
if (!COMMENT_AUTHORS.includes(author)) throw new DiffCommentError('author must be "user" or "ask"');
|
|
174
|
+
const text = cleanBody(body);
|
|
175
|
+
const id = newCommentId();
|
|
176
|
+
const ts = now();
|
|
177
|
+
getDb();
|
|
178
|
+
prepare(`INSERT INTO diff_comments
|
|
179
|
+
(id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
|
|
180
|
+
body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, parent_id)
|
|
181
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`)
|
|
182
|
+
.run(id, parent.storeKey, parent.pipelineId, parent.projectKey, parent.path, parent.oldPath,
|
|
183
|
+
parent.side, parent.line, parent.lineText, text, author,
|
|
184
|
+
parent.resolved ? 1 : 0, parent.resolvedAt, ts, parent.id);
|
|
185
|
+
notify(parent.storeKey, parent.pipelineId);
|
|
186
|
+
return getDiffComment(id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Toggle a THREAD (D2). Returns the updated root, or null when the id is unknown.
|
|
190
|
+
* @throws {DiffCommentError} when `id` is a reply — replies mirror their root. */
|
|
151
191
|
export function setDiffCommentResolved(id, resolved = true) {
|
|
152
192
|
const before = getDiffComment(id);
|
|
153
193
|
if (!before) return null;
|
|
194
|
+
if (before.parentId) throw new DiffCommentError('replies cannot be resolved on their own — resolve the thread\'s first comment');
|
|
154
195
|
const on = resolved !== false;
|
|
155
196
|
getDb();
|
|
156
|
-
prepare('UPDATE diff_comments SET resolved = ?, resolved_at = ? WHERE id = ?')
|
|
157
|
-
.run(on ? 1 : 0, on ? now() : null, id);
|
|
197
|
+
prepare('UPDATE diff_comments SET resolved = ?, resolved_at = ? WHERE id = ? OR parent_id = ?')
|
|
198
|
+
.run(on ? 1 : 0, on ? now() : null, id, id);
|
|
158
199
|
notify(before.storeKey, before.pipelineId);
|
|
159
200
|
return getDiffComment(id);
|
|
160
201
|
}
|
|
@@ -216,6 +257,11 @@ export function stampSentRunId(commentIds, pipelineId) {
|
|
|
216
257
|
* Delete every comment of a run. NO transaction of its own — the CALLER owns it
|
|
217
258
|
* (db.mjs tx() is not re-entrant and throws on nesting, and archivePipeline calls
|
|
218
259
|
* this from inside its own tx). Used only by the archive path.
|
|
260
|
+
* @returns {number} rows the DELETE removed DIRECTLY. Since v29 a root takes its
|
|
261
|
+
* replies with it through the parent_id cascade, and SQLite does not count
|
|
262
|
+
* cascaded rows in `.changes` — so this UNDER-counts a run with threads and is
|
|
263
|
+
* not the number of comments that went away. No caller reads it; do not start
|
|
264
|
+
* without switching to a `SELECT count(*)` taken first.
|
|
219
265
|
*/
|
|
220
266
|
export function deleteCommentsForRun(pipelineId) {
|
|
221
267
|
getDb();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The ONE reader of an agent .md's leading YAML frontmatter. Four private copies
|
|
2
|
+
// existed (agent-registry's description reader, workflows.mjs' and plugin-store's
|
|
3
|
+
// tools readers, workflow-export's fence matcher); the three READERS now share
|
|
4
|
+
// this. Deliberately NOT a YAML parser: subagent frontmatter is `key: value`
|
|
5
|
+
// single-line scalars (name / description / tools / model) — block scalars
|
|
6
|
+
// degrade to '' (the indicator is never stored), unknown keys ride `fields`.
|
|
7
|
+
import { openSync, readSync, closeSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
/** Bytes read from the HEAD of an agent file. Every shipped frontmatter is < 2 KB;
|
|
10
|
+
* a fence that does not close inside the window reads as "no frontmatter" — the
|
|
11
|
+
* body (a 4–8 KB prompt) is never read by a frontmatter consumer. */
|
|
12
|
+
export const FRONTMATTER_HEAD_BYTES = 8192;
|
|
13
|
+
|
|
14
|
+
/** Leading fence: optional BOM, `---`, newline (LF or CRLF), inner YAML, newline,
|
|
15
|
+
* `---`, then end of line or end of text. m[1] = the inner YAML. */
|
|
16
|
+
export const FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
17
|
+
|
|
18
|
+
const KEY_LINE_RE = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*(.*)$/;
|
|
19
|
+
const BLOCK_SCALAR_RE = /^[>|][+-]?$/;
|
|
20
|
+
|
|
21
|
+
function scalar(raw) {
|
|
22
|
+
const v = raw.trim();
|
|
23
|
+
if (BLOCK_SCALAR_RE.test(v)) return ''; // folded/literal indicator, not the text
|
|
24
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
25
|
+
return v.slice(1, -1).trim();
|
|
26
|
+
}
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {unknown} text the file text (or its head)
|
|
32
|
+
* @returns {{name:string, description:string, tools:string[], model:string, fields:Record<string,string>}|null}
|
|
33
|
+
* null when the text carries no leading fence (or the fence never closes).
|
|
34
|
+
*/
|
|
35
|
+
export function parseFrontmatter(text) {
|
|
36
|
+
if (typeof text !== 'string') return null;
|
|
37
|
+
const m = FRONTMATTER_RE.exec(text);
|
|
38
|
+
if (!m) return null;
|
|
39
|
+
const fields = {};
|
|
40
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
41
|
+
const kv = KEY_LINE_RE.exec(line);
|
|
42
|
+
if (!kv || Object.hasOwn(fields, kv[1])) continue; // continuation lines / repeats are skipped
|
|
43
|
+
fields[kv[1]] = scalar(kv[2]);
|
|
44
|
+
}
|
|
45
|
+
const tools = (fields.tools || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
46
|
+
return { name: fields.name || '', description: fields.description || '', tools, model: fields.model || '', fields };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Everything after the fence; the whole text when there is none. */
|
|
50
|
+
export function stripFrontmatter(text) {
|
|
51
|
+
const s = typeof text === 'string' ? text : '';
|
|
52
|
+
const m = FRONTMATTER_RE.exec(s);
|
|
53
|
+
return m ? s.slice(m[0].length) : s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read + parse the frontmatter of a file, touching at most `maxBytes` of its
|
|
58
|
+
* head. Synchronous (the registry loader is synchronous). Missing file,
|
|
59
|
+
* unreadable file, no fence ⇒ null — never throws.
|
|
60
|
+
* @param {string} path
|
|
61
|
+
* @param {number} [maxBytes]
|
|
62
|
+
*/
|
|
63
|
+
export function readFrontmatterSync(path, maxBytes = FRONTMATTER_HEAD_BYTES) {
|
|
64
|
+
let fd = null;
|
|
65
|
+
try {
|
|
66
|
+
fd = openSync(path, 'r');
|
|
67
|
+
const buf = Buffer.alloc(maxBytes);
|
|
68
|
+
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
69
|
+
return parseFrontmatter(buf.subarray(0, n).toString('utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
} finally {
|
|
73
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* ignore */ } }
|
|
74
|
+
}
|
|
75
|
+
}
|