@bybrawe/opencode-loop 0.6.7 → 0.6.9
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/CHANGELOG.md +17 -0
- package/README.md +21 -9
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/examples/OPENCODE2-0.6.9.md +34 -0
- package/package.json +3 -3
- package/scripts/checklist-completion-test.mjs +104 -0
- package/scripts/comprehensive-test.mjs +9 -1
- package/scripts/desktop-command-diagnostics-test.mjs +97 -2
- package/scripts/desktop-command-diagnostics.mjs +70 -9
- package/scripts/loopd-test.mjs +1 -1
- package/scripts/loopd.mjs +4 -2
- package/scripts/native-cli-io-test.mjs +6 -3
- package/scripts/native-entry-test.mjs +16 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +61 -0
- package/scripts/opencode2-loop-canary.mjs +2 -1
- package/scripts/package-native-smoke.mjs +41 -7
- package/scripts/publish-workflow-test.mjs +3 -2
- package/scripts/run-admission-test.mjs +47 -0
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/src/index.js +187 -2
- package/src/native.js +208 -15
- package/src/server.js +212 -19
- package/src/source/core/args.js +3 -0
- package/src/source/core/jobs.js +1 -0
- package/src/source/core/state.js +33 -0
- package/src/source/opencode2/native-plugin.js +3 -0
- package/src/source/opencode2/native-policy.js +21 -0
- package/src/source/opencode2/native-runtime.js +1 -0
- package/src/source/opencode2/status.js +5 -1
- package/src/source/runtime/checklist-completion.js +113 -0
- package/src/source/runtime/run-admission.js +25 -0
- package/src/source/runtime/run-finalization.js +7 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { promises as fs, constants } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { createHash } from "node:crypto"
|
|
4
|
+
|
|
5
|
+
const MAX_CHECKLIST_BYTES = 256_000
|
|
6
|
+
const EXCLUDED_PROJECT_DIRS = new Set([".opencode", ".git", "node_modules"])
|
|
7
|
+
|
|
8
|
+
function unavailable(reason) {
|
|
9
|
+
return { status: "unavailable", reason, total: 0, pending: 0 }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function summarizeTaskChecklist(markdown) {
|
|
13
|
+
let total = 0
|
|
14
|
+
let pending = 0
|
|
15
|
+
let fence = null
|
|
16
|
+
let comment = false
|
|
17
|
+
|
|
18
|
+
for (const original of String(markdown || "").replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
|
19
|
+
// Only project tasks count, never examples in HTML comments or fenced code.
|
|
20
|
+
let line = ""
|
|
21
|
+
let offset = 0
|
|
22
|
+
while (offset < original.length) {
|
|
23
|
+
if (comment) {
|
|
24
|
+
const end = original.indexOf("-->", offset)
|
|
25
|
+
if (end < 0) break
|
|
26
|
+
comment = false
|
|
27
|
+
offset = end + 3
|
|
28
|
+
} else {
|
|
29
|
+
const start = original.indexOf("<!--", offset)
|
|
30
|
+
if (start < 0) {
|
|
31
|
+
line += original.slice(offset)
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
line += original.slice(offset, start)
|
|
35
|
+
comment = true
|
|
36
|
+
offset = start + 4
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const trimmed = line.trimStart()
|
|
41
|
+
const indentation = line.length - trimmed.length
|
|
42
|
+
const marker = trimmed.charAt(0)
|
|
43
|
+
let markerSize = 0
|
|
44
|
+
if (marker === "~" || marker.charCodeAt(0) === 96) {
|
|
45
|
+
while (trimmed.charAt(markerSize) === marker) markerSize++
|
|
46
|
+
}
|
|
47
|
+
if (fence) {
|
|
48
|
+
if (indentation <= 3 && markerSize >= fence.length && marker === fence.marker && !trimmed.slice(markerSize).trim()) fence = null
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (indentation <= 3 && markerSize >= 3) {
|
|
52
|
+
fence = { marker, length: markerSize }
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
// A quoted example must not be mistaken for an actionable checkbox.
|
|
56
|
+
if (/^\s*>/.test(line)) continue
|
|
57
|
+
const match = line.match(/^\s{0,24}[-+*]\s+\[([ xX])\](?:\s|$)/)
|
|
58
|
+
if (!match) continue
|
|
59
|
+
total++
|
|
60
|
+
if (match[1] === " ") pending++
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { status: total === 0 ? "unavailable" : pending === 0 ? "complete" : "pending",
|
|
64
|
+
reason: total === 0 ? "no-task-checkboxes" : "",
|
|
65
|
+
total, pending }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function inspectTaskChecklist(directory, progressFile) {
|
|
69
|
+
const name = String(progressFile || "").trim()
|
|
70
|
+
if (!name || name.includes("\u0000")) return unavailable("missing-progress-file")
|
|
71
|
+
if (path.isAbsolute(name) || path.win32.isAbsolute(name)) return unavailable("absolute-path")
|
|
72
|
+
const root = path.resolve(directory)
|
|
73
|
+
const target = path.resolve(root, name)
|
|
74
|
+
const relative = path.relative(root, target)
|
|
75
|
+
if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) {
|
|
76
|
+
return unavailable("outside-project")
|
|
77
|
+
}
|
|
78
|
+
const parts = relative.split(path.sep)
|
|
79
|
+
if (parts.some((part) => EXCLUDED_PROJECT_DIRS.has(part.toLowerCase()))) return unavailable("control-plane-path")
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const rootInfo = await fs.lstat(root)
|
|
83
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) return unavailable("unsafe-project-root")
|
|
84
|
+
let current = root
|
|
85
|
+
for (let index = 0; index < parts.length; index++) {
|
|
86
|
+
current = path.join(current, parts[index])
|
|
87
|
+
const info = await fs.lstat(current)
|
|
88
|
+
if (info.isSymbolicLink()) return unavailable("symlink-path")
|
|
89
|
+
if (index < parts.length - 1 && !info.isDirectory()) return unavailable("invalid-parent")
|
|
90
|
+
if (index === parts.length - 1 && (!info.isFile() || info.size > MAX_CHECKLIST_BYTES)) {
|
|
91
|
+
return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Do not follow a last-component symlink introduced between lstat and open.
|
|
95
|
+
const handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
|
|
96
|
+
try {
|
|
97
|
+
const info = await handle.stat()
|
|
98
|
+
if (!info.isFile() || info.size > MAX_CHECKLIST_BYTES) {
|
|
99
|
+
return unavailable(!info.isFile() ? "not-regular-file" : "file-too-large")
|
|
100
|
+
}
|
|
101
|
+
const content = await handle.readFile({ encoding: "utf8" })
|
|
102
|
+
const after = await fs.lstat(target)
|
|
103
|
+
if (!after.isFile() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino) {
|
|
104
|
+
return unavailable("file-changed-during-read")
|
|
105
|
+
}
|
|
106
|
+
return { ...summarizeTaskChecklist(content), digest: createHash("sha256").update(content).digest("hex") }
|
|
107
|
+
} finally {
|
|
108
|
+
await handle.close()
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return unavailable(error?.code === "ENOENT" ? "file-missing" : error?.code === "ELOOP" ? "symlink-path" : "file-unreadable")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -4,6 +4,7 @@ import { pathExists as defaultPathExists, writeState as defaultWriteState } from
|
|
|
4
4
|
import { appendLoopLog as defaultAppendLoopLog, runShellCommand as defaultRunShellCommand, notifyJob as defaultNotifyJob } from "../core/process.js"
|
|
5
5
|
import { toast as defaultToast } from "../opencode/host.js"
|
|
6
6
|
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
7
|
+
import { inspectTaskChecklist } from "./checklist-completion.js"
|
|
7
8
|
import { dueJobs as sharedDueJobs } from "./schedule-policy.js"
|
|
8
9
|
|
|
9
10
|
function requireFunction(value, label) {
|
|
@@ -82,6 +83,30 @@ export function createRunAdmissionRuntime(options = {}) {
|
|
|
82
83
|
)
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
if (job.untilChecklistComplete) {
|
|
87
|
+
const checklist = await inspectTaskChecklist(directory, job.progressFile)
|
|
88
|
+
job.checklistStatus = { status: checklist.status, total: checklist.total, pending: checklist.pending }
|
|
89
|
+
if (checklist.status === "unavailable") {
|
|
90
|
+
job.paused = true
|
|
91
|
+
job.pauseReason = "checklist-unavailable"
|
|
92
|
+
job.lastChecklistError = checklist.reason
|
|
93
|
+
await writeState(directory, sessionID, state)
|
|
94
|
+
await notifyJob(directory, job, "checklist_unavailable")
|
|
95
|
+
await toast(client, "Loop paused: checklist " + job.progressFile + " could not be validated (" + checklist.reason + ").", "warning")
|
|
96
|
+
await reschedule(directory, client, sessionID)
|
|
97
|
+
return { admitted: false, reason: "checklist_unavailable" }
|
|
98
|
+
}
|
|
99
|
+
delete job.lastChecklistError
|
|
100
|
+
if (checklist.status === "complete" && (!job.verifyCommand || (job.lastVerifyAt > 0 && job.lastVerifyCode === 0 && job.lastVerifyChecklistDigest === checklist.digest))) {
|
|
101
|
+
return await stopAndRemove(
|
|
102
|
+
directory, client, sessionID, state, job,
|
|
103
|
+
"checklist_complete",
|
|
104
|
+
"Loop stopped: " + job.progressFile + " checklist completed.",
|
|
105
|
+
"checklist-complete",
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
if (job.preflightCommand) {
|
|
86
111
|
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
87
112
|
if (runNowRequested) delete job.runNowRequestedAt
|
|
@@ -6,6 +6,7 @@ import { toast as defaultToast } from "../opencode/host.js"
|
|
|
6
6
|
import { writeGoalReport as defaultWriteGoalReport } from "./goal-runtime.js"
|
|
7
7
|
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
8
8
|
import { applyTerminalContinuationGuard as defaultApplyTerminalContinuationGuard } from "./terminal-guard.js"
|
|
9
|
+
import { inspectTaskChecklist } from "./checklist-completion.js"
|
|
9
10
|
|
|
10
11
|
function requireFunction(value, label) {
|
|
11
12
|
if (typeof value !== "function") throw new TypeError(`createRunFinalizationRuntime requires ${label}`)
|
|
@@ -49,8 +50,14 @@ export function createRunFinalizationRuntime(options = {}) {
|
|
|
49
50
|
if (verify.code === 0) {
|
|
50
51
|
job.failureCount = 0
|
|
51
52
|
job.lastVerifyFailure = ""
|
|
53
|
+
if (job.untilChecklistComplete) {
|
|
54
|
+
const inspected = await inspectTaskChecklist(directory, job.progressFile)
|
|
55
|
+
if (inspected.status !== "unavailable") job.lastVerifyChecklistDigest = inspected.digest
|
|
56
|
+
else delete job.lastVerifyChecklistDigest
|
|
57
|
+
}
|
|
52
58
|
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
53
59
|
} else {
|
|
60
|
+
delete job.lastVerifyChecklistDigest
|
|
54
61
|
job.failureCount = (job.failureCount || 0) + 1
|
|
55
62
|
job.lastVerifyFailure = (job.verifyCommand + "\nexit=" + verify.code + "\n" + verify.stdout + "\n" + verify.stderr).slice(0, 4000)
|
|
56
63
|
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning")
|