@hadooppei/hwcode 1.0.12 → 1.0.13
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/.pi/dist/lib/knowledge/review-worker.js +30 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +16 -4
- package/.pi/dist/lib/knowledge/store.js +60 -25
- package/.pi/dist/lib/runtime/session-state.js +9 -0
- package/.pi/dist/lib/workflows/state.js +159 -0
- package/.pi/dist/lib/working-directory.js +170 -0
- package/.pi/extensions/knowledge.ts +31 -9
- package/.pi/lib/knowledge/review-status.ts +6 -0
- package/.pi/lib/knowledge/review-worker.ts +25 -0
- package/.pi/lib/knowledge/session-scanner.ts +14 -3
- package/.pi/lib/knowledge/store.ts +63 -19
- package/.pi/lib/knowledge/types.ts +9 -0
- package/.pi/lib/knowledge/worker-protocol.ts +1 -0
- package/.pi/lib/working-directory.ts +10 -0
- package/package.json +1 -1
|
@@ -104,6 +104,25 @@ function finishReview(task, outcome, detail) {
|
|
|
104
104
|
reviewAttempts.delete(task.reviewKey);
|
|
105
105
|
publishReviewStatus();
|
|
106
106
|
}
|
|
107
|
+
function deferReview(task, reason) {
|
|
108
|
+
clearTimeout(activeDeadlineTimer);
|
|
109
|
+
activeDeadlineTimer = undefined;
|
|
110
|
+
const attempts = reviewAttempts.get(task.reviewKey) ?? 1;
|
|
111
|
+
if (attempts <= 1)
|
|
112
|
+
reviewAttempts.delete(task.reviewKey);
|
|
113
|
+
else
|
|
114
|
+
reviewAttempts.set(task.reviewKey, attempts - 1);
|
|
115
|
+
if (!reviewStatus)
|
|
116
|
+
return;
|
|
117
|
+
reviewStatus.activeReview = undefined;
|
|
118
|
+
reviewStatus.lastDeferred = {
|
|
119
|
+
reviewKey: task.reviewKey,
|
|
120
|
+
sessionKey: task.sessionKey,
|
|
121
|
+
deferredAt: new Date().toISOString(),
|
|
122
|
+
reason: boundedError(reason),
|
|
123
|
+
};
|
|
124
|
+
publishReviewStatus();
|
|
125
|
+
}
|
|
107
126
|
function removeLeaderMetadata(token) {
|
|
108
127
|
try {
|
|
109
128
|
const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8"));
|
|
@@ -355,6 +374,13 @@ function handleReviewResult(message) {
|
|
|
355
374
|
queueMicrotask(() => void scanForReview());
|
|
356
375
|
}
|
|
357
376
|
}
|
|
377
|
+
function handleReviewDeferred(message) {
|
|
378
|
+
const task = activeTask;
|
|
379
|
+
if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer)
|
|
380
|
+
return;
|
|
381
|
+
deferReview(task, message.reason);
|
|
382
|
+
activeTask = undefined;
|
|
383
|
+
}
|
|
358
384
|
const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
|
|
359
385
|
scanTimer.unref();
|
|
360
386
|
parentPort.on("message", (message) => {
|
|
@@ -373,6 +399,10 @@ parentPort.on("message", (message) => {
|
|
|
373
399
|
scheduleElection(0);
|
|
374
400
|
return;
|
|
375
401
|
}
|
|
402
|
+
if (message.type === "review_deferred") {
|
|
403
|
+
handleReviewDeferred(message);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
376
406
|
if (message.type === "review_result") {
|
|
377
407
|
handleReviewResult(message);
|
|
378
408
|
return;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
4
|
import { buildContextEntries, parseSessionEntries, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
6
|
+
import { findLatestWorkflowRoot } from "../working-directory.js";
|
|
6
7
|
import { knowledgeDeltaDigest } from "./extractor.js";
|
|
7
8
|
import { sanitizeKnowledgeText } from "./sanitize.js";
|
|
8
9
|
import { projectKnowledgeKey } from "./store.js";
|
|
@@ -98,15 +99,26 @@ function buildValidationContext(entries) {
|
|
|
98
99
|
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
|
|
99
100
|
}
|
|
100
101
|
function projectRootForSession(header, branch) {
|
|
102
|
+
const sessionRoot = resolve(header.cwd);
|
|
103
|
+
const workflowRootValue = findLatestWorkflowRoot(branch);
|
|
104
|
+
const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
|
|
105
|
+
let workingDirectory = sessionRoot;
|
|
101
106
|
for (const entry of branch.slice().reverse()) {
|
|
102
107
|
if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
|
|
103
108
|
|| !entry.data || typeof entry.data !== "object")
|
|
104
109
|
continue;
|
|
105
110
|
const cwd = entry.data.cwd;
|
|
106
|
-
if (typeof cwd
|
|
107
|
-
|
|
111
|
+
if (typeof cwd !== "string" || !cwd)
|
|
112
|
+
continue;
|
|
113
|
+
workingDirectory = resolve(cwd);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
|
|
117
|
+
return workflowRoot;
|
|
108
118
|
}
|
|
109
|
-
|
|
119
|
+
if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`))
|
|
120
|
+
return sessionRoot;
|
|
121
|
+
return workingDirectory;
|
|
110
122
|
}
|
|
111
123
|
export function discoverSessionFiles(root) {
|
|
112
124
|
const files = [];
|
|
@@ -137,16 +137,22 @@ function semanticDuplicateIndex(candidate, catalog) {
|
|
|
137
137
|
return bestIndex;
|
|
138
138
|
}
|
|
139
139
|
function unsupportedOperations(value) {
|
|
140
|
-
return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)]
|
|
141
|
-
.
|
|
140
|
+
return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)].map((match) => {
|
|
141
|
+
const suffix = value.slice(match.index + match[0].length, match.index + match[0].length + 500);
|
|
142
|
+
const service = /\bhcloud\s+([A-Za-z][A-Za-z0-9-]*)\s+--help\b/iu.exec(suffix)?.[1]?.toLowerCase();
|
|
143
|
+
return { operation: match[1].toLowerCase(), service };
|
|
144
|
+
});
|
|
142
145
|
}
|
|
143
146
|
function contradictsVerifiedFailures(body, validationText) {
|
|
144
147
|
const normalizedBody = body.toLowerCase();
|
|
145
|
-
for (const
|
|
146
|
-
const
|
|
148
|
+
for (const unsupported of unsupportedOperations(validationText)) {
|
|
149
|
+
const operationExpression = unsupported.service
|
|
150
|
+
? new RegExp(`\\b(?:hcloud\\s+)?${unsupported.service}\\b[^\\n]{0,120}\\b${unsupported.operation}\\b`, "u")
|
|
151
|
+
: new RegExp(`\\b${unsupported.operation}\\b`, "u");
|
|
152
|
+
const index = normalizedBody.search(operationExpression);
|
|
147
153
|
if (index < 0)
|
|
148
154
|
continue;
|
|
149
|
-
const context = normalizedBody.slice(Math.max(0, index - 80), index +
|
|
155
|
+
const context = normalizedBody.slice(Math.max(0, index - 80), index + 240);
|
|
150
156
|
if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context))
|
|
151
157
|
return true;
|
|
152
158
|
}
|
|
@@ -163,38 +169,58 @@ function asStringArray(value, maxItems) {
|
|
|
163
169
|
}
|
|
164
170
|
function normalizeCandidate(value, projectKey, validationText) {
|
|
165
171
|
if (!value || typeof value !== "object")
|
|
166
|
-
return
|
|
172
|
+
return { reason: "invalid-schema" };
|
|
167
173
|
const raw = value;
|
|
168
174
|
if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
|
|
169
175
|
|| typeof raw.body !== "string" || typeof raw.confidence !== "number")
|
|
170
|
-
return
|
|
176
|
+
return { reason: "invalid-schema" };
|
|
171
177
|
if (raw.durability !== "stable")
|
|
172
|
-
return
|
|
178
|
+
return { reason: "unstable" };
|
|
173
179
|
if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence)
|
|
174
|
-
return
|
|
180
|
+
return { reason: "low-confidence" };
|
|
181
|
+
if (raw.targetId !== undefined && raw.targetId !== null
|
|
182
|
+
&& (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId)))
|
|
183
|
+
return { reason: "unknown-target" };
|
|
175
184
|
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
176
185
|
const scope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
177
186
|
const body = compactKnowledgeText(raw.body, 20_000);
|
|
178
187
|
const evidence = asStringArray(raw.evidence, 8);
|
|
179
188
|
if (!body || evidence.length === 0)
|
|
180
|
-
return
|
|
189
|
+
return { reason: "missing-body-or-evidence" };
|
|
181
190
|
if (contradictsVerifiedFailures(body, validationText))
|
|
182
|
-
return
|
|
191
|
+
return { reason: "contradicts-verified-failure" };
|
|
183
192
|
const requestedTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
184
193
|
const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
|
|
185
194
|
&& body.split(/\n\s*\n/gu).length === 1
|
|
186
195
|
&& (explicitUserDirective || raw.confidence >= 0.9);
|
|
187
196
|
const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
|
|
188
|
-
return {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
return { candidate: {
|
|
198
|
+
key: compactKnowledgeText(raw.key, 160), targetId,
|
|
199
|
+
title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
|
|
200
|
+
summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
|
|
201
|
+
keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
|
|
202
|
+
scope, body, evidence, confidence: Math.min(1, raw.confidence),
|
|
203
|
+
storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
|
|
204
|
+
action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
|
|
205
|
+
explicitUserDirective, durability: "stable",
|
|
206
|
+
} };
|
|
207
|
+
}
|
|
208
|
+
function recordSkip(result, reason) {
|
|
209
|
+
result.skipped++;
|
|
210
|
+
result.skippedReasons ??= {};
|
|
211
|
+
result.skippedReasons[reason] = (result.skippedReasons[reason] ?? 0) + 1;
|
|
212
|
+
}
|
|
213
|
+
function writeRejectedPending(directory, reason, value) {
|
|
214
|
+
let serialized;
|
|
215
|
+
try {
|
|
216
|
+
serialized = JSON.stringify(value);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
serialized = String(value);
|
|
220
|
+
}
|
|
221
|
+
const candidate = sanitizeKnowledgeText(serialized).slice(0, 10_000);
|
|
222
|
+
const name = `${Date.now()}-rejected-${reason}-${randomUUID().slice(0, 8)}.json`;
|
|
223
|
+
atomicWrite(join(directory, "pending", name), `${JSON.stringify({ reason, candidate }, null, 2)}\n`);
|
|
198
224
|
}
|
|
199
225
|
function renderKnowledgeFile(candidate) {
|
|
200
226
|
if (candidate.storageHint === "rule")
|
|
@@ -324,19 +350,23 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
|
|
|
324
350
|
copyCurrentContent(current, temporary, home);
|
|
325
351
|
const catalog = structuredClone(current.catalog);
|
|
326
352
|
const result = { saved: 0, updated: 0, pending: 0, skipped: 0 };
|
|
353
|
+
const rejected = [];
|
|
327
354
|
for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
|
|
328
|
-
const
|
|
329
|
-
if (!candidate) {
|
|
330
|
-
result.
|
|
355
|
+
const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
|
|
356
|
+
if (!("candidate" in normalized)) {
|
|
357
|
+
recordSkip(result, normalized.reason);
|
|
358
|
+
rejected.push({ reason: normalized.reason, value });
|
|
331
359
|
continue;
|
|
332
360
|
}
|
|
361
|
+
const candidate = normalized.candidate;
|
|
333
362
|
const fingerprint = normalizedFingerprint(candidate);
|
|
334
363
|
const hash = contentHash(candidate);
|
|
335
364
|
const targetIndex = candidate.targetId
|
|
336
365
|
? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
|
|
337
366
|
: -1;
|
|
338
367
|
if (candidate.targetId && targetIndex < 0) {
|
|
339
|
-
result
|
|
368
|
+
recordSkip(result, "unknown-target");
|
|
369
|
+
rejected.push({ reason: "unknown-target", value });
|
|
340
370
|
continue;
|
|
341
371
|
}
|
|
342
372
|
let existingIndex = targetIndex >= 0
|
|
@@ -386,6 +416,11 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
|
|
|
386
416
|
result.saved++;
|
|
387
417
|
}
|
|
388
418
|
}
|
|
419
|
+
if (result.skipped > 0 && result.saved === 0 && result.updated === 0 && result.pending === 0) {
|
|
420
|
+
for (const item of rejected)
|
|
421
|
+
writeRejectedPending(temporary, item.reason, item.value);
|
|
422
|
+
result.pending += rejected.length;
|
|
423
|
+
}
|
|
389
424
|
catalog.updatedAt = new Date().toISOString();
|
|
390
425
|
const manifest = {
|
|
391
426
|
version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function findLatestState(entries, codec) {
|
|
2
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
3
|
+
const entry = entries[index];
|
|
4
|
+
if (entry.type !== "custom" || entry.customType !== codec.customType)
|
|
5
|
+
continue;
|
|
6
|
+
return { found: true, value: codec.decode(entry.data) };
|
|
7
|
+
}
|
|
8
|
+
return { found: false };
|
|
9
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { findLatestState } from "../runtime/session-state.js";
|
|
2
|
+
export const WORKFLOW_STATE_TYPE = "hwcode-workflow-state";
|
|
3
|
+
export const WORKFLOW_EXTERNAL_AUDIT_TYPE = "hwcode-workflow-external-approval";
|
|
4
|
+
function isCloudTemplateSource(value) {
|
|
5
|
+
if (!value || typeof value !== "object")
|
|
6
|
+
return false;
|
|
7
|
+
const data = value;
|
|
8
|
+
return typeof data.id === "string"
|
|
9
|
+
&& typeof data.name === "string"
|
|
10
|
+
&& typeof data.createdAt === "string"
|
|
11
|
+
&& typeof data.updatedAt === "string";
|
|
12
|
+
}
|
|
13
|
+
function isCloudRunnerSummary(value) {
|
|
14
|
+
if (!value || typeof value !== "object")
|
|
15
|
+
return false;
|
|
16
|
+
const data = value;
|
|
17
|
+
return typeof data.id === "string"
|
|
18
|
+
&& typeof data.name === "string"
|
|
19
|
+
&& typeof data.vendor === "string"
|
|
20
|
+
&& typeof data.region === "string"
|
|
21
|
+
&& typeof data.host === "string"
|
|
22
|
+
&& Number.isInteger(data.port)
|
|
23
|
+
&& typeof data.user === "string"
|
|
24
|
+
&& typeof data.remoteRoot === "string"
|
|
25
|
+
&& typeof data.hostKeyFingerprint === "string"
|
|
26
|
+
&& ["instance-role", "agency", "ssh-only"].includes(data.identityType);
|
|
27
|
+
}
|
|
28
|
+
function isTerraformRunState(value) {
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return false;
|
|
31
|
+
const data = value;
|
|
32
|
+
const summary = data.planSummary;
|
|
33
|
+
return typeof data.runId === "string"
|
|
34
|
+
&& typeof data.runnerId === "string"
|
|
35
|
+
&& ["synced", "validated", "planned", "applied", "failed"].includes(data.phase)
|
|
36
|
+
&& typeof data.sourceDigest === "string"
|
|
37
|
+
&& typeof data.remoteWorkspace === "string"
|
|
38
|
+
&& typeof data.startedAt === "string"
|
|
39
|
+
&& typeof data.updatedAt === "string"
|
|
40
|
+
&& (data.planDigest === undefined || typeof data.planDigest === "string")
|
|
41
|
+
&& (summary === undefined || (summary !== null && typeof summary === "object"
|
|
42
|
+
&& Object.values(summary).every((entry) => Number.isInteger(entry))));
|
|
43
|
+
}
|
|
44
|
+
function isCloudDetails(value) {
|
|
45
|
+
if (!value || typeof value !== "object")
|
|
46
|
+
return false;
|
|
47
|
+
const data = value;
|
|
48
|
+
return typeof data.vendor === "string"
|
|
49
|
+
&& typeof data.deployCurrentProject === "boolean"
|
|
50
|
+
&& typeof data.request === "string"
|
|
51
|
+
&& typeof data.allowNonDeleteChanges === "boolean"
|
|
52
|
+
&& Array.isArray(data.failedApproaches)
|
|
53
|
+
&& Array.isArray(data.successfulSteps)
|
|
54
|
+
&& (data.resources === undefined || (Array.isArray(data.resources) && data.resources.every((resource) => {
|
|
55
|
+
if (!resource || typeof resource !== "object")
|
|
56
|
+
return false;
|
|
57
|
+
const entry = resource;
|
|
58
|
+
return typeof entry.id === "string" && typeof entry.type === "string" && typeof entry.region === "string"
|
|
59
|
+
&& ["existing", "workflow-created"].includes(entry.ownership)
|
|
60
|
+
&& ["active", "deleted"].includes(entry.status)
|
|
61
|
+
&& typeof entry.updatedAt === "string";
|
|
62
|
+
})))
|
|
63
|
+
&& typeof data.terminalFailure === "boolean"
|
|
64
|
+
&& (data.artifactDirectory === undefined || typeof data.artifactDirectory === "string")
|
|
65
|
+
&& (data.templateGuidance === undefined || typeof data.templateGuidance === "string")
|
|
66
|
+
&& (data.terraformSourcePath === undefined || typeof data.terraformSourcePath === "string")
|
|
67
|
+
&& (data.sourceTemplate === undefined || isCloudTemplateSource(data.sourceTemplate))
|
|
68
|
+
&& (data.runner === undefined || isCloudRunnerSummary(data.runner))
|
|
69
|
+
&& (data.runnerPreference === undefined || ["automatic", "deferred", "existing"].includes(data.runnerPreference))
|
|
70
|
+
&& (data.terraformRun === undefined || isTerraformRunState(data.terraformRun));
|
|
71
|
+
}
|
|
72
|
+
export function decodeWorkflowState(value) {
|
|
73
|
+
if (!value || typeof value !== "object")
|
|
74
|
+
return undefined;
|
|
75
|
+
const data = value;
|
|
76
|
+
if (data.version === 2) {
|
|
77
|
+
if (!["active", "completed", "cancelled", "failed"].includes(data.status)
|
|
78
|
+
|| !["vibe", "sdd", "cloud"].includes(data.mode)
|
|
79
|
+
|| typeof data.root !== "string"
|
|
80
|
+
|| typeof data.phase !== "string"
|
|
81
|
+
|| typeof data.activatedAt !== "string"
|
|
82
|
+
|| typeof data.updatedAt !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
if (data.mode === "cloud" && !isCloudDetails(data.details))
|
|
85
|
+
return undefined;
|
|
86
|
+
if (data.mode === "sdd" && data.sdd !== undefined) {
|
|
87
|
+
const sdd = data.sdd;
|
|
88
|
+
if (!["discovery", "requirements", "design", "test-plan", "tasks", "tests", "implementation", "verification"].includes(sdd.phase)
|
|
89
|
+
|| !Array.isArray(sdd.approvals))
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
export const WORKFLOW_STATE_CODEC = {
|
|
97
|
+
customType: WORKFLOW_STATE_TYPE,
|
|
98
|
+
decode: decodeWorkflowState,
|
|
99
|
+
};
|
|
100
|
+
export function restoreWorkflowState(entries) {
|
|
101
|
+
return findLatestState(entries, WORKFLOW_STATE_CODEC).value;
|
|
102
|
+
}
|
|
103
|
+
export function activeWorkflow(entries) {
|
|
104
|
+
const state = restoreWorkflowState(entries);
|
|
105
|
+
return state?.status === "active" ? state : undefined;
|
|
106
|
+
}
|
|
107
|
+
export function cloudDetails(state) {
|
|
108
|
+
return state.mode === "cloud" ? state.details : undefined;
|
|
109
|
+
}
|
|
110
|
+
export function createWorkflowState(mode, root, phase = "activated") {
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
return {
|
|
113
|
+
version: 2,
|
|
114
|
+
status: "active",
|
|
115
|
+
mode,
|
|
116
|
+
root,
|
|
117
|
+
phase,
|
|
118
|
+
activatedAt: now,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
...(mode === "sdd" ? { sdd: { phase: "discovery", approvals: [] } } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function createCloudWorkflowState(root, vendor, deployCurrentProject, request, template, artifactDirectory) {
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
return {
|
|
126
|
+
version: 2,
|
|
127
|
+
status: "active",
|
|
128
|
+
mode: "cloud",
|
|
129
|
+
root,
|
|
130
|
+
phase: "connected",
|
|
131
|
+
activatedAt: now,
|
|
132
|
+
updatedAt: now,
|
|
133
|
+
details: {
|
|
134
|
+
vendor,
|
|
135
|
+
deployCurrentProject,
|
|
136
|
+
request,
|
|
137
|
+
allowNonDeleteChanges: false,
|
|
138
|
+
failedApproaches: [],
|
|
139
|
+
successfulSteps: [],
|
|
140
|
+
resources: [],
|
|
141
|
+
terminalFailure: false,
|
|
142
|
+
...(artifactDirectory ? { artifactDirectory } : {}),
|
|
143
|
+
...(template ? {
|
|
144
|
+
templateGuidance: template.guidance,
|
|
145
|
+
sourceTemplate: template.source,
|
|
146
|
+
} : {}),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function updateWorkflowState(state, changes) {
|
|
151
|
+
return { ...state, ...changes, version: 2, updatedAt: new Date().toISOString() };
|
|
152
|
+
}
|
|
153
|
+
export function workflowLabel(mode) {
|
|
154
|
+
if (mode === "vibe")
|
|
155
|
+
return "HWCode Vibe";
|
|
156
|
+
if (mode === "sdd")
|
|
157
|
+
return "HWCode SDD";
|
|
158
|
+
return "HWCode Cloud";
|
|
159
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { activeWorkflow } from "./workflows/state.js";
|
|
5
|
+
export const WORKING_DIRECTORY_STATE_TYPE = "hwcode-working-directory";
|
|
6
|
+
const workingDirectories = new Map();
|
|
7
|
+
function isWorkingDirectoryState(value) {
|
|
8
|
+
if (!value || typeof value !== "object")
|
|
9
|
+
return false;
|
|
10
|
+
const data = value;
|
|
11
|
+
return data.version === 1
|
|
12
|
+
&& typeof data.cwd === "string"
|
|
13
|
+
&& (data.previousCwd === undefined || typeof data.previousCwd === "string");
|
|
14
|
+
}
|
|
15
|
+
export function findPersistedWorkingDirectory(entries) {
|
|
16
|
+
for (const entry of [...entries].reverse()) {
|
|
17
|
+
if (entry.type !== "custom" || entry.customType !== WORKING_DIRECTORY_STATE_TYPE)
|
|
18
|
+
continue;
|
|
19
|
+
if (isWorkingDirectoryState(entry.data))
|
|
20
|
+
return entry.data;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
export function canonicalizeDirectory(path) {
|
|
25
|
+
const canonical = realpathSync(path);
|
|
26
|
+
if (!statSync(canonical).isDirectory())
|
|
27
|
+
throw new Error(`Not a directory: ${path}`);
|
|
28
|
+
return canonical;
|
|
29
|
+
}
|
|
30
|
+
function initialState(source) {
|
|
31
|
+
const persisted = findPersistedWorkingDirectory(source.getEntries());
|
|
32
|
+
if (persisted)
|
|
33
|
+
return { ...persisted, cwd: canonicalizeDirectory(persisted.cwd) };
|
|
34
|
+
return { version: 1, cwd: canonicalizeDirectory(source.getCwd()) };
|
|
35
|
+
}
|
|
36
|
+
export function getWorkingDirectoryState(source) {
|
|
37
|
+
return workingDirectories.get(source.getSessionId()) ?? initialState(source);
|
|
38
|
+
}
|
|
39
|
+
export function getWorkingDirectory(source) {
|
|
40
|
+
return getWorkingDirectoryState(source).cwd;
|
|
41
|
+
}
|
|
42
|
+
export function setWorkingDirectoryState(source, state) {
|
|
43
|
+
workingDirectories.set(source.getSessionId(), state);
|
|
44
|
+
}
|
|
45
|
+
export function resetWorkingDirectoryState(source) {
|
|
46
|
+
const state = initialState(source);
|
|
47
|
+
setWorkingDirectoryState(source, state);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
export function clearWorkingDirectoryState(source) {
|
|
51
|
+
workingDirectories.delete(source.getSessionId());
|
|
52
|
+
}
|
|
53
|
+
function expandHome(path, home) {
|
|
54
|
+
if (path === "~" || path === "$HOME" || path === "${HOME}")
|
|
55
|
+
return home;
|
|
56
|
+
if (path.startsWith("~/"))
|
|
57
|
+
return resolve(home, path.slice(2));
|
|
58
|
+
if (path.startsWith("$HOME/"))
|
|
59
|
+
return resolve(home, path.slice(6));
|
|
60
|
+
if (path.startsWith("${HOME}/"))
|
|
61
|
+
return resolve(home, path.slice(8));
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
export function resolveDirectoryArgument(base, argument, previousCwd, home = homedir()) {
|
|
65
|
+
if (argument === "-") {
|
|
66
|
+
if (!previousCwd)
|
|
67
|
+
throw new Error("No previous working directory is available.");
|
|
68
|
+
return canonicalizeDirectory(previousCwd);
|
|
69
|
+
}
|
|
70
|
+
const expanded = expandHome(argument || home, home);
|
|
71
|
+
return canonicalizeDirectory(isAbsolute(expanded) ? expanded : resolve(base, expanded));
|
|
72
|
+
}
|
|
73
|
+
export function resolveWorkingPath(base, path, home = homedir()) {
|
|
74
|
+
const expanded = expandHome(path, home);
|
|
75
|
+
return isAbsolute(expanded) ? expanded : resolve(base, expanded);
|
|
76
|
+
}
|
|
77
|
+
function readShellWord(command, start) {
|
|
78
|
+
let value = "";
|
|
79
|
+
let quote;
|
|
80
|
+
let index = start;
|
|
81
|
+
for (; index < command.length; index += 1) {
|
|
82
|
+
const character = command[index];
|
|
83
|
+
if (quote) {
|
|
84
|
+
if (character === quote) {
|
|
85
|
+
quote = undefined;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (character === "\\" && quote === '"' && index + 1 < command.length) {
|
|
89
|
+
index += 1;
|
|
90
|
+
value += command[index];
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
value += character;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (character === "'" || character === '"') {
|
|
97
|
+
quote = character;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (character === "\\" && index + 1 < command.length) {
|
|
101
|
+
index += 1;
|
|
102
|
+
value += command[index];
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (/\s/u.test(character) || character === ";" || character === "&")
|
|
106
|
+
break;
|
|
107
|
+
if ("|<>`".includes(character) || character === "$" && command[index + 1] === "(")
|
|
108
|
+
return undefined;
|
|
109
|
+
value += character;
|
|
110
|
+
}
|
|
111
|
+
if (quote || value.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
return { value, end: index };
|
|
114
|
+
}
|
|
115
|
+
/** Parse a leading persistent `cd`, optionally followed by `&&` or `;`. */
|
|
116
|
+
export function parseLeadingDirectoryChange(command) {
|
|
117
|
+
let index = 0;
|
|
118
|
+
while (/\s/u.test(command[index] ?? ""))
|
|
119
|
+
index += 1;
|
|
120
|
+
if (command.slice(index, index + 2) !== "cd")
|
|
121
|
+
return undefined;
|
|
122
|
+
const next = command[index + 2];
|
|
123
|
+
if (next && !/\s/u.test(next) && next !== ";" && command.slice(index + 2, index + 4) !== "&&") {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
index += 2;
|
|
127
|
+
while (/\s/u.test(command[index] ?? ""))
|
|
128
|
+
index += 1;
|
|
129
|
+
if (command.slice(index, index + 2) === "--") {
|
|
130
|
+
index += 2;
|
|
131
|
+
while (/\s/u.test(command[index] ?? ""))
|
|
132
|
+
index += 1;
|
|
133
|
+
}
|
|
134
|
+
let argument = "";
|
|
135
|
+
if (index < command.length && command[index] !== ";" && command.slice(index, index + 2) !== "&&") {
|
|
136
|
+
const word = readShellWord(command, index);
|
|
137
|
+
if (!word)
|
|
138
|
+
return undefined;
|
|
139
|
+
argument = word.value;
|
|
140
|
+
index = word.end;
|
|
141
|
+
}
|
|
142
|
+
while (/\s/u.test(command[index] ?? ""))
|
|
143
|
+
index += 1;
|
|
144
|
+
if (index >= command.length)
|
|
145
|
+
return { argument, remainder: "" };
|
|
146
|
+
if (command.slice(index, index + 2) === "&&")
|
|
147
|
+
index += 2;
|
|
148
|
+
else if (command[index] === ";")
|
|
149
|
+
index += 1;
|
|
150
|
+
else
|
|
151
|
+
return undefined;
|
|
152
|
+
return { argument, remainder: command.slice(index).trimStart() };
|
|
153
|
+
}
|
|
154
|
+
export function getActiveWorkflowRoot(entries) {
|
|
155
|
+
return activeWorkflow(entries)?.root;
|
|
156
|
+
}
|
|
157
|
+
export function findLatestWorkflowRoot(entries) {
|
|
158
|
+
for (const entry of [...entries].reverse()) {
|
|
159
|
+
if (entry.type !== "custom" || entry.customType !== "hwcode-workflow-state"
|
|
160
|
+
|| !entry.data || typeof entry.data !== "object")
|
|
161
|
+
continue;
|
|
162
|
+
const root = entry.data.root;
|
|
163
|
+
if (typeof root === "string" && root)
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
export function shellQuote(value) {
|
|
169
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
170
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { dirname } from "node:path";
|
|
3
|
+
import { dirname, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Worker } from "node:worker_threads";
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ import { matchKnowledge } from "../lib/knowledge/matcher.ts";
|
|
|
14
14
|
import { loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey } from "../lib/knowledge/store.ts";
|
|
15
15
|
import type { KnowledgeWorkerInput, KnowledgeWorkerOutput } from "../lib/knowledge/worker-protocol.ts";
|
|
16
16
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../lib/runtime/defaults.ts";
|
|
17
|
-
import { getWorkingDirectory } from "../lib/working-directory.ts";
|
|
17
|
+
import { findLatestWorkflowRoot, getWorkingDirectory } from "../lib/working-directory.ts";
|
|
18
18
|
|
|
19
19
|
interface ActiveReview {
|
|
20
20
|
requestId: string;
|
|
@@ -98,15 +98,29 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
98
98
|
try {
|
|
99
99
|
const ctx = runtime.context;
|
|
100
100
|
if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
|
|
101
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages())
|
|
101
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
102
|
+
post({
|
|
103
|
+
type: "review_deferred",
|
|
104
|
+
leaderToken: message.leaderToken,
|
|
105
|
+
requestId: message.requestId,
|
|
106
|
+
reason: "model-executor-is-busy",
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
102
110
|
const controller = new AbortController();
|
|
103
111
|
runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
|
|
104
112
|
timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
|
|
105
113
|
timeout.unref();
|
|
106
114
|
const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
|
|
115
|
+
const applicableCatalog = {
|
|
116
|
+
...snapshot.catalog,
|
|
117
|
+
items: snapshot.catalog.items.filter((entry) => (
|
|
118
|
+
entry.scope === "global" || entry.scope === `project:${message.task.projectKey}`
|
|
119
|
+
)),
|
|
120
|
+
};
|
|
107
121
|
const related: ExistingKnowledgeContext[] = matchKnowledge(
|
|
108
122
|
message.task.delta,
|
|
109
|
-
|
|
123
|
+
applicableCatalog,
|
|
110
124
|
KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
|
|
111
125
|
).map((entry, index) => {
|
|
112
126
|
const reference: ExistingKnowledgeContext = {
|
|
@@ -146,10 +160,12 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
146
160
|
.map((item) => item.text).join("\n");
|
|
147
161
|
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
|
|
148
162
|
} catch (error) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
163
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
164
|
+
if (reason === "model-executor-is-busy") {
|
|
165
|
+
post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
|
|
166
|
+
} else {
|
|
167
|
+
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
|
|
168
|
+
}
|
|
153
169
|
try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
|
|
154
170
|
} finally {
|
|
155
171
|
clearTimeout(timeout);
|
|
@@ -210,7 +226,13 @@ function configureForContext(ctx: ExtensionContext): void {
|
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
function currentProject(ctx: ExtensionContext): { root: string; key: string } {
|
|
213
|
-
const
|
|
229
|
+
const workingDirectory = resolve(getWorkingDirectory(ctx.sessionManager));
|
|
230
|
+
const workflowRootValue = findLatestWorkflowRoot(ctx.sessionManager.getEntries());
|
|
231
|
+
const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
|
|
232
|
+
const root = workflowRoot
|
|
233
|
+
&& (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))
|
|
234
|
+
? workflowRoot
|
|
235
|
+
: workingDirectory;
|
|
214
236
|
return { root, key: projectKnowledgeKey(root) };
|
|
215
237
|
}
|
|
216
238
|
|
|
@@ -23,6 +23,12 @@ export interface KnowledgeReviewStatus {
|
|
|
23
23
|
attempt: number;
|
|
24
24
|
startedAt: string;
|
|
25
25
|
};
|
|
26
|
+
lastDeferred?: {
|
|
27
|
+
reviewKey: string;
|
|
28
|
+
sessionKey: string;
|
|
29
|
+
deferredAt: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
};
|
|
26
32
|
lastReview?: {
|
|
27
33
|
reviewKey: string;
|
|
28
34
|
sessionKey: string;
|
|
@@ -113,6 +113,23 @@ function finishReview(
|
|
|
113
113
|
publishReviewStatus();
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function deferReview(task: KnowledgeReviewTask, reason: string): void {
|
|
117
|
+
clearTimeout(activeDeadlineTimer);
|
|
118
|
+
activeDeadlineTimer = undefined;
|
|
119
|
+
const attempts = reviewAttempts.get(task.reviewKey) ?? 1;
|
|
120
|
+
if (attempts <= 1) reviewAttempts.delete(task.reviewKey);
|
|
121
|
+
else reviewAttempts.set(task.reviewKey, attempts - 1);
|
|
122
|
+
if (!reviewStatus) return;
|
|
123
|
+
reviewStatus.activeReview = undefined;
|
|
124
|
+
reviewStatus.lastDeferred = {
|
|
125
|
+
reviewKey: task.reviewKey,
|
|
126
|
+
sessionKey: task.sessionKey,
|
|
127
|
+
deferredAt: new Date().toISOString(),
|
|
128
|
+
reason: boundedError(reason),
|
|
129
|
+
};
|
|
130
|
+
publishReviewStatus();
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
function removeLeaderMetadata(token: string): void {
|
|
117
134
|
try {
|
|
118
135
|
const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8")) as { leaderToken?: string };
|
|
@@ -332,6 +349,13 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
|
|
|
332
349
|
}
|
|
333
350
|
}
|
|
334
351
|
|
|
352
|
+
function handleReviewDeferred(message: Extract<KnowledgeWorkerInput, { type: "review_deferred" }>): void {
|
|
353
|
+
const task = activeTask;
|
|
354
|
+
if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer) return;
|
|
355
|
+
deferReview(task, message.reason);
|
|
356
|
+
activeTask = undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
335
359
|
const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
|
|
336
360
|
scanTimer.unref();
|
|
337
361
|
|
|
@@ -348,6 +372,7 @@ parentPort.on("message", (message: KnowledgeWorkerInput) => {
|
|
|
348
372
|
} else scheduleElection(0);
|
|
349
373
|
return;
|
|
350
374
|
}
|
|
375
|
+
if (message.type === "review_deferred") { handleReviewDeferred(message); return; }
|
|
351
376
|
if (message.type === "review_result") { handleReviewResult(message); return; }
|
|
352
377
|
if (message.type === "scan_now") { void scanForReview(); return; }
|
|
353
378
|
stopped = true;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
buildContextEntries, parseSessionEntries, type SessionEntry, type SessionHeader,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
9
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
10
|
+
import { findLatestWorkflowRoot } from "../working-directory.ts";
|
|
10
11
|
import { knowledgeDeltaDigest } from "./extractor.ts";
|
|
11
12
|
import { sanitizeKnowledgeText } from "./sanitize.ts";
|
|
12
13
|
import { projectKnowledgeKey } from "./store.ts";
|
|
@@ -105,13 +106,23 @@ function buildValidationContext(entries: SessionEntry[]): string {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
|
|
109
|
+
const sessionRoot = resolve(header.cwd);
|
|
110
|
+
const workflowRootValue = findLatestWorkflowRoot(branch);
|
|
111
|
+
const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
|
|
112
|
+
let workingDirectory = sessionRoot;
|
|
108
113
|
for (const entry of branch.slice().reverse()) {
|
|
109
114
|
if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
|
|
110
115
|
|| !entry.data || typeof entry.data !== "object") continue;
|
|
111
116
|
const cwd = (entry.data as Record<string, unknown>).cwd;
|
|
112
|
-
if (typeof cwd
|
|
117
|
+
if (typeof cwd !== "string" || !cwd) continue;
|
|
118
|
+
workingDirectory = resolve(cwd);
|
|
119
|
+
break;
|
|
113
120
|
}
|
|
114
|
-
|
|
121
|
+
if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
|
|
122
|
+
return workflowRoot;
|
|
123
|
+
}
|
|
124
|
+
if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`)) return sessionRoot;
|
|
125
|
+
return workingDirectory;
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
export function discoverSessionFiles(root: string): string[] {
|
|
@@ -11,7 +11,8 @@ import { userRuntimePaths } from "../runtime/paths.ts";
|
|
|
11
11
|
import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
|
|
12
12
|
import type {
|
|
13
13
|
KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
|
|
14
|
-
KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack,
|
|
14
|
+
KnowledgeReviewTask, KnowledgeScope, KnowledgeSkipReason, KnowledgeSnapshot, KnowledgeTrack,
|
|
15
|
+
PersistKnowledgeResult,
|
|
15
16
|
} from "./types.ts";
|
|
16
17
|
|
|
17
18
|
const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
|
|
@@ -151,17 +152,28 @@ function semanticDuplicateIndex(candidate: KnowledgeCandidate, catalog: Knowledg
|
|
|
151
152
|
return bestIndex;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
interface UnsupportedOperation {
|
|
156
|
+
operation: string;
|
|
157
|
+
service?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function unsupportedOperations(value: string): UnsupportedOperation[] {
|
|
161
|
+
return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)].map((match) => {
|
|
162
|
+
const suffix = value.slice(match.index + match[0].length, match.index + match[0].length + 500);
|
|
163
|
+
const service = /\bhcloud\s+([A-Za-z][A-Za-z0-9-]*)\s+--help\b/iu.exec(suffix)?.[1]?.toLowerCase();
|
|
164
|
+
return { operation: match[1]!.toLowerCase(), service };
|
|
165
|
+
});
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
function contradictsVerifiedFailures(body: string, validationText: string): boolean {
|
|
160
169
|
const normalizedBody = body.toLowerCase();
|
|
161
|
-
for (const
|
|
162
|
-
const
|
|
170
|
+
for (const unsupported of unsupportedOperations(validationText)) {
|
|
171
|
+
const operationExpression = unsupported.service
|
|
172
|
+
? new RegExp(`\\b(?:hcloud\\s+)?${unsupported.service}\\b[^\\n]{0,120}\\b${unsupported.operation}\\b`, "u")
|
|
173
|
+
: new RegExp(`\\b${unsupported.operation}\\b`, "u");
|
|
174
|
+
const index = normalizedBody.search(operationExpression);
|
|
163
175
|
if (index < 0) continue;
|
|
164
|
-
const context = normalizedBody.slice(Math.max(0, index - 80), index +
|
|
176
|
+
const context = normalizedBody.slice(Math.max(0, index - 80), index + 240);
|
|
165
177
|
if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context)) return true;
|
|
166
178
|
}
|
|
167
179
|
return false;
|
|
@@ -177,25 +189,29 @@ function asStringArray(value: unknown, maxItems: number): string[] {
|
|
|
177
189
|
.map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
|
|
181
|
-
|
|
192
|
+
type CandidateNormalization = { candidate: KnowledgeCandidate } | { reason: KnowledgeSkipReason };
|
|
193
|
+
|
|
194
|
+
function normalizeCandidate(value: unknown, projectKey: string, validationText: string): CandidateNormalization {
|
|
195
|
+
if (!value || typeof value !== "object") return { reason: "invalid-schema" };
|
|
182
196
|
const raw = value as Record<string, unknown>;
|
|
183
197
|
if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
|
|
184
|
-
|| typeof raw.body !== "string" || typeof raw.confidence !== "number") return
|
|
185
|
-
if (raw.durability !== "stable") return
|
|
186
|
-
if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return
|
|
198
|
+
|| typeof raw.body !== "string" || typeof raw.confidence !== "number") return { reason: "invalid-schema" };
|
|
199
|
+
if (raw.durability !== "stable") return { reason: "unstable" };
|
|
200
|
+
if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return { reason: "low-confidence" };
|
|
201
|
+
if (raw.targetId !== undefined && raw.targetId !== null
|
|
202
|
+
&& (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId))) return { reason: "unknown-target" };
|
|
187
203
|
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
188
204
|
const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
189
205
|
const body = compactKnowledgeText(raw.body, 20_000);
|
|
190
206
|
const evidence = asStringArray(raw.evidence, 8);
|
|
191
|
-
if (!body || evidence.length === 0) return
|
|
192
|
-
if (contradictsVerifiedFailures(body, validationText)) return
|
|
207
|
+
if (!body || evidence.length === 0) return { reason: "missing-body-or-evidence" };
|
|
208
|
+
if (contradictsVerifiedFailures(body, validationText)) return { reason: "contradicts-verified-failure" };
|
|
193
209
|
const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
194
210
|
const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
|
|
195
211
|
&& body.split(/\n\s*\n/gu).length === 1
|
|
196
212
|
&& (explicitUserDirective || raw.confidence >= 0.9);
|
|
197
213
|
const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
|
|
198
|
-
return {
|
|
214
|
+
return { candidate: {
|
|
199
215
|
key: compactKnowledgeText(raw.key, 160), targetId,
|
|
200
216
|
title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
|
|
201
217
|
summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
|
|
@@ -204,7 +220,21 @@ function normalizeCandidate(value: unknown, projectKey: string, validationText:
|
|
|
204
220
|
storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
|
|
205
221
|
action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
|
|
206
222
|
explicitUserDirective, durability: "stable",
|
|
207
|
-
};
|
|
223
|
+
} };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function recordSkip(result: PersistKnowledgeResult, reason: KnowledgeSkipReason): void {
|
|
227
|
+
result.skipped++;
|
|
228
|
+
result.skippedReasons ??= {};
|
|
229
|
+
result.skippedReasons[reason] = (result.skippedReasons[reason] ?? 0) + 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeRejectedPending(directory: string, reason: KnowledgeSkipReason, value: unknown): void {
|
|
233
|
+
let serialized: string;
|
|
234
|
+
try { serialized = JSON.stringify(value); } catch { serialized = String(value); }
|
|
235
|
+
const candidate = sanitizeKnowledgeText(serialized).slice(0, 10_000);
|
|
236
|
+
const name = `${Date.now()}-rejected-${reason}-${randomUUID().slice(0, 8)}.json`;
|
|
237
|
+
atomicWrite(join(directory, "pending", name), `${JSON.stringify({ reason, candidate }, null, 2)}\n`);
|
|
208
238
|
}
|
|
209
239
|
|
|
210
240
|
function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
|
|
@@ -328,15 +358,25 @@ export function commitKnowledgeReview(
|
|
|
328
358
|
copyCurrentContent(current, temporary, home);
|
|
329
359
|
const catalog: KnowledgeCatalog = structuredClone(current.catalog);
|
|
330
360
|
const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
|
|
361
|
+
const rejected: Array<{ reason: KnowledgeSkipReason; value: unknown }> = [];
|
|
331
362
|
for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
|
|
332
|
-
const
|
|
333
|
-
if (!candidate) {
|
|
363
|
+
const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
|
|
364
|
+
if (!("candidate" in normalized)) {
|
|
365
|
+
recordSkip(result, normalized.reason);
|
|
366
|
+
rejected.push({ reason: normalized.reason, value });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const candidate = normalized.candidate;
|
|
334
370
|
const fingerprint = normalizedFingerprint(candidate);
|
|
335
371
|
const hash = contentHash(candidate);
|
|
336
372
|
const targetIndex = candidate.targetId
|
|
337
373
|
? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
|
|
338
374
|
: -1;
|
|
339
|
-
if (candidate.targetId && targetIndex < 0) {
|
|
375
|
+
if (candidate.targetId && targetIndex < 0) {
|
|
376
|
+
recordSkip(result, "unknown-target");
|
|
377
|
+
rejected.push({ reason: "unknown-target", value });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
340
380
|
let existingIndex = targetIndex >= 0
|
|
341
381
|
? targetIndex
|
|
342
382
|
: catalog.items.findIndex((item) => item.fingerprint === fingerprint);
|
|
@@ -378,6 +418,10 @@ export function commitKnowledgeReview(
|
|
|
378
418
|
if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
|
|
379
419
|
else { catalog.items.push(entry); result.saved++; }
|
|
380
420
|
}
|
|
421
|
+
if (result.skipped > 0 && result.saved === 0 && result.updated === 0 && result.pending === 0) {
|
|
422
|
+
for (const item of rejected) writeRejectedPending(temporary, item.reason, item.value);
|
|
423
|
+
result.pending += rejected.length;
|
|
424
|
+
}
|
|
381
425
|
catalog.updatedAt = new Date().toISOString();
|
|
382
426
|
const manifest: KnowledgeManifest = {
|
|
383
427
|
version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
|
|
@@ -75,8 +75,17 @@ export interface PersistKnowledgeResult {
|
|
|
75
75
|
updated: number;
|
|
76
76
|
pending: number;
|
|
77
77
|
skipped: number;
|
|
78
|
+
skippedReasons?: Partial<Record<KnowledgeSkipReason, number>>;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
export type KnowledgeSkipReason =
|
|
82
|
+
| "invalid-schema"
|
|
83
|
+
| "unstable"
|
|
84
|
+
| "low-confidence"
|
|
85
|
+
| "missing-body-or-evidence"
|
|
86
|
+
| "contradicts-verified-failure"
|
|
87
|
+
| "unknown-target";
|
|
88
|
+
|
|
80
89
|
export interface KnowledgeReviewTask {
|
|
81
90
|
requestId: string;
|
|
82
91
|
reviewKey: string;
|
|
@@ -2,6 +2,7 @@ import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
|
|
|
2
2
|
|
|
3
3
|
export type KnowledgeWorkerInput =
|
|
4
4
|
| { type: "configure"; modelAvailable: boolean; sessionsRoot: string }
|
|
5
|
+
| { type: "review_deferred"; leaderToken: string; requestId: string; reason: string }
|
|
5
6
|
| { type: "review_result"; leaderToken: string; requestId: string; raw?: string; error?: string }
|
|
6
7
|
| { type: "scan_now" }
|
|
7
8
|
| { type: "stop" };
|
|
@@ -189,6 +189,16 @@ export function getActiveWorkflowRoot(entries: readonly SessionEntry[]): string
|
|
|
189
189
|
return activeWorkflow(entries)?.root;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
export function findLatestWorkflowRoot(entries: readonly SessionEntry[]): string | undefined {
|
|
193
|
+
for (const entry of [...entries].reverse()) {
|
|
194
|
+
if (entry.type !== "custom" || entry.customType !== "hwcode-workflow-state"
|
|
195
|
+
|| !entry.data || typeof entry.data !== "object") continue;
|
|
196
|
+
const root = (entry.data as Record<string, unknown>).root;
|
|
197
|
+
if (typeof root === "string" && root) return root;
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
export function shellQuote(value: string): string {
|
|
193
203
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
194
204
|
}
|