@minonl/dia 0.0.0-stage → 0.0.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/bin/dia.js +5 -0
- package/dist/cli.js +7408 -0
- package/docs/acp-integration.md +136 -0
- package/docs/architecture.md +166 -0
- package/docs/cli.md +117 -0
- package/docs/dependency-registry.md +105 -0
- package/docs/deps.jsonl +7 -0
- package/docs/ecosystem-integration.md +155 -0
- package/docs/ecosystem-positioning.md +95 -0
- package/docs/entrypoints.md +83 -0
- package/docs/evolution-process.md +298 -0
- package/docs/history/framework-bootstrap.md +109 -0
- package/docs/host-control-surface.md +213 -0
- package/docs/index.md +80 -0
- package/docs/kit-install.md +339 -0
- package/docs/migrations/kb-golden.md +32 -0
- package/docs/migrations/llm-migration.md +49 -0
- package/docs/migrations/no-python.md +52 -0
- package/docs/migrations/yan-agent-current.md +52 -0
- package/docs/migrations/yan-agent-next.md +168 -0
- package/docs/migrations/yan-kb-parity.md +159 -0
- package/docs/migrations/yan-kb.md +120 -0
- package/docs/model-baseline-sidecar.md +220 -0
- package/docs/model-behavior.md +229 -0
- package/docs/monorepo.md +83 -0
- package/docs/overview.md +126 -0
- package/docs/patching.md +91 -0
- package/docs/persona-install.md +264 -0
- package/docs/platforms/index.md +66 -0
- package/docs/platforms/registry.json +54 -0
- package/docs/pricing.md +43 -0
- package/docs/proposals.md +76 -0
- package/docs/research/benchmarks-survey.md +96 -0
- package/docs/research/collaboration-theory.md +85 -0
- package/docs/research/dsh-superagent.md +97 -0
- package/docs/research/frameworks-survey-2.md +75 -0
- package/docs/research/h0/report.md +135 -0
- package/docs/research/h0/sample.json +34 -0
- package/docs/research/model-boundary-patterns.md +99 -0
- package/docs/research/opencode-session-scoping.md +84 -0
- package/docs/review/reuse-review.md +105 -0
- package/docs/review/verification-2026-10-28.md +49 -0
- package/docs/security/audits/2026-11-04.json +44 -0
- package/docs/security/credentials.md +95 -0
- package/docs/security/public-release.md +108 -0
- package/docs/security-sandbox.md +157 -0
- package/docs/status.md +158 -0
- package/docs/team-automation.md +169 -0
- package/docs/team.md +112 -0
- package/docs/versioning.md +237 -0
- package/docs/vision.md +161 -0
- package/package.json +45 -4
- package/packages/job/README.md +98 -0
- package/packages/job/bin/dia-job +1283 -0
- package/packages/job/config.example +5 -0
- package/packages/job/install.sh +37 -0
- package/packages/job/lib/config.sh +18 -0
- package/packages/job/lib/json.mjs +130 -0
- package/packages/job/lib/policy.sh +103 -0
- package/packages/job/package.json +10 -0
- package/packages/job/shim/yan-task +24 -0
- package/packages/job/tests/bootstrap.test.sh +115 -0
- package/packages/job/tests/install.test.sh +30 -0
- package/packages/job/tests/linux.test.sh +130 -0
- package/packages/job/tests/p2.test.sh +46 -0
- package/packages/job/tests/policy.test.sh +38 -0
- package/packages/wt/bin/dia-wt +1148 -0
- package/packages/wt/tests/check.test.sh +126 -0
- package/packages/wt/tests/clean.test.sh +126 -0
- package/packages/wt/tests/prune-merged.test.sh +169 -0
- package/packages/wt/tests/route.test.sh +112 -0
- package/packages/wt/tests/scope.test.sh +69 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dia-job 安装:复制到全局目录 + 建稳定 CLI 链接;不依赖任何项目 checkout。
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
HOME_DIR="${DIA_JOB_HOME:-$HOME/.local/share/dia-job}"
|
|
6
|
+
BIN_DIR="${DIA_JOB_BIN_DIR:-$HOME/.local/bin}"
|
|
7
|
+
DRY=0; FORCE=0
|
|
8
|
+
while [ $# -gt 0 ]; do
|
|
9
|
+
case "$1" in
|
|
10
|
+
--home) HOME_DIR="$2"; shift 2 ;;
|
|
11
|
+
--bin) BIN_DIR="$2"; shift 2 ;;
|
|
12
|
+
--dry-run) DRY=1; shift ;;
|
|
13
|
+
--force) FORCE=1; shift ;;
|
|
14
|
+
-h|--help) sed -n '2,5p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
15
|
+
*) echo "usage: install.sh [--home DIR] [--bin DIR] [--force] [--dry-run]" >&2; exit 2 ;;
|
|
16
|
+
esac
|
|
17
|
+
done
|
|
18
|
+
|
|
19
|
+
run() { if [ "$DRY" = 1 ]; then printf '[dry-run] %s\n' "$*"; else "$@"; fi; }
|
|
20
|
+
for f in bin/dia-job lib/config.sh lib/policy.sh lib/json.mjs; do
|
|
21
|
+
[ -f "$SRC/$f" ] || { echo "缺文件:$SRC/$f" >&2; exit 1; }
|
|
22
|
+
done
|
|
23
|
+
run mkdir -p "$HOME_DIR/bin" "$HOME_DIR/lib" "$BIN_DIR" "${XDG_CONFIG_HOME:-$HOME/.config}/dia-job"
|
|
24
|
+
for f in bin/dia-job lib/config.sh lib/policy.sh lib/json.mjs; do
|
|
25
|
+
run cp "$SRC/$f" "$HOME_DIR/$f"
|
|
26
|
+
done
|
|
27
|
+
run chmod +x "$HOME_DIR/bin/dia-job"
|
|
28
|
+
run ln -sfn "$HOME_DIR/bin/dia-job" "$BIN_DIR/dia-job"
|
|
29
|
+
|
|
30
|
+
# 配置示例:只在不存在时提示,不覆盖真实配置
|
|
31
|
+
CFG="${XDG_CONFIG_HOME:-$HOME/.config}/dia-job/config"
|
|
32
|
+
if [ ! -f "$CFG" ]; then
|
|
33
|
+
if [ "$DRY" = 1 ]; then echo "[dry-run] 可创建 ${CFG}(示例见 $SRC/config.example)"; else
|
|
34
|
+
printf '# dia-job global config; see %s/config.example\n' "$SRC" > "$CFG"
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
echo "dia-job installed: $HOME_DIR/bin/dia-job -> $BIN_DIR/dia-job"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dia-job 全局配置:环境变量优先;否则读 $DIA_JOB_CONFIG 或 ~/.config/dia-job/config
|
|
3
|
+
# 文件格式:纯 KEY=value;只接受 DIA_JOB_* 键;不 source、不执行文件内容。
|
|
4
|
+
dia_job_load_config() {
|
|
5
|
+
local f="${DIA_JOB_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/dia-job/config}"
|
|
6
|
+
[ -f "$f" ] || return 0
|
|
7
|
+
local k v
|
|
8
|
+
while IFS='=' read -r k v || [ -n "${k:-}" ]; do
|
|
9
|
+
k="${k%%[[:space:]]*}"
|
|
10
|
+
case "$k" in DIA_JOB_*) ;; *) continue ;; esac
|
|
11
|
+
v="${v%$'\r'}"
|
|
12
|
+
case "$v" in
|
|
13
|
+
\"*\") v="${v#\"}"; v="${v%\"}" ;;
|
|
14
|
+
\'*\') v="${v#\'}"; v="${v%\'}" ;;
|
|
15
|
+
esac
|
|
16
|
+
if [ -z "${!k:-}" ]; then export "$k=$v"; fi
|
|
17
|
+
done < "$f"
|
|
18
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin Node adapter for bash. Uses built-in JSON only; no parser/package.
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const [cmd, ...a] = process.argv.slice(2);
|
|
6
|
+
const read = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
7
|
+
const write = (p, d) => {
|
|
8
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
9
|
+
writeFileSync(tmp, JSON.stringify(d, null, 2) + "\n");
|
|
10
|
+
renameSync(tmp, p);
|
|
11
|
+
};
|
|
12
|
+
const tasks = (d) => d.tasks || {};
|
|
13
|
+
const label = (k, v, prefix) => v.label || prefix + k.replace(/^yan-/, "");
|
|
14
|
+
const out = (s) => process.stdout.write(s);
|
|
15
|
+
|
|
16
|
+
switch (cmd) {
|
|
17
|
+
case "labels": { // <reg> <prefix>
|
|
18
|
+
const [reg, prefix] = a;
|
|
19
|
+
if (!existsSync(reg)) break;
|
|
20
|
+
for (const [k, v] of Object.entries(tasks(read(reg)))) out(label(k, v, prefix) + "\n");
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
case "entries": { // <reg> <root> <prefix>
|
|
24
|
+
const [reg, root, prefix] = a;
|
|
25
|
+
if (!existsSync(reg)) break;
|
|
26
|
+
for (const [k, v] of Object.entries(tasks(read(reg)))) {
|
|
27
|
+
const iv = v.interval, cal = v.calendar || "-";
|
|
28
|
+
const sched = iv && iv !== "-" ? iv : (v.weekdays && v.weekdays !== "-" ? `${cal} [${v.weekdays}]` : cal);
|
|
29
|
+
out([k, label(k, v, prefix), v.type || "periodic", sched, v.log || `${root}/tasks/${k}.log`].join("|") + "\n");
|
|
30
|
+
}
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case "state-last": { // <file>
|
|
34
|
+
try {
|
|
35
|
+
const d = JSON.parse(readFileSync(a[0], "utf8").trim().split("\n").pop());
|
|
36
|
+
const t = new Date((d.ts || 0) * 1000);
|
|
37
|
+
const z = (n) => String(n).padStart(2, "0");
|
|
38
|
+
out(` 上次运行: ${z(t.getMonth() + 1)}-${z(t.getDate())} ${z(t.getHours())}:${z(t.getMinutes())}:${z(t.getSeconds())} · exit=${d.exit}\n`);
|
|
39
|
+
} catch { break; }
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case "log": { // <reg> <name> <default>
|
|
43
|
+
const [reg, name, def] = a;
|
|
44
|
+
const t = existsSync(reg) ? tasks(read(reg))[name] : null;
|
|
45
|
+
out((t && t.log) || def);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "field": { // <reg> <name> <key>
|
|
49
|
+
const [reg, name, key] = a;
|
|
50
|
+
const t = existsSync(reg) ? tasks(read(reg))[name] : null;
|
|
51
|
+
out(t && t[key] != null ? String(t[key]) : "");
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case "get": { // <reg> <name>
|
|
55
|
+
const [reg, name] = a;
|
|
56
|
+
const t = existsSync(reg) ? tasks(read(reg))[name] : null;
|
|
57
|
+
out(JSON.stringify(t || {}));
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case "set": { // <reg> <name> <json>
|
|
61
|
+
const [reg, name, payload] = a;
|
|
62
|
+
const d = existsSync(reg) ? read(reg) : { tasks: {} };
|
|
63
|
+
d.tasks = tasks(d); d.tasks[name] = JSON.parse(payload); write(reg, d);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "del": { // <reg> <name>
|
|
67
|
+
const [reg, name] = a;
|
|
68
|
+
if (!existsSync(reg)) break;
|
|
69
|
+
const d = read(reg); delete tasks(d)[name]; write(reg, d);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "rows": { // <reg> -> name \t cmd
|
|
73
|
+
if (!existsSync(a[0])) break;
|
|
74
|
+
for (const [k, v] of Object.entries(tasks(read(a[0])))) out(`${k}\t${v.cmd || ""}\n`);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "fields": { // <reg> <name> -> cmd \t interval \t calendar \t weekdays \t type
|
|
78
|
+
if (!existsSync(a[0])) break;
|
|
79
|
+
const t = tasks(read(a[0]))[a[1]] || {};
|
|
80
|
+
out([t.cmd || "", t.interval || "-", t.calendar || "-", t.weekdays || "-", t.type || "periodic"].join("\t"));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "selfcheck-rows": { // <reg> <prefix>
|
|
84
|
+
const [reg, prefix] = a;
|
|
85
|
+
if (!existsSync(reg)) break;
|
|
86
|
+
for (const [k, v] of Object.entries(tasks(read(reg)))) out(`${k}\t${label(k, v, prefix)}\t${v.adopted ? 1 : 0}\n`);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "backfill": { // <reg> <dry> <force> <tsv>
|
|
90
|
+
const [reg, dry, force, tsv] = a;
|
|
91
|
+
const d = read(reg), ts = tasks(d);
|
|
92
|
+
let add = 0, skip = 0, eph = 0;
|
|
93
|
+
for (const line of readFileSync(tsv, "utf8").split("\n")) {
|
|
94
|
+
if (!line.trim()) continue;
|
|
95
|
+
const [name, cls, layer] = line.split("\t");
|
|
96
|
+
const t = ts[name]; if (!t) continue;
|
|
97
|
+
if (cls === "ephemeral") { out(` ⚠️ ${name}: 存量路径被判定为 ephemeral(worktree)—— 不自动改,请人工核对后重装\n`); eph++; }
|
|
98
|
+
if ("path_class" in t && force !== "1") { skip++; continue; }
|
|
99
|
+
t.path_class = cls; if (layer) t.layer = layer;
|
|
100
|
+
out(` ✚ ${name}: path_class=${cls}${layer ? ` layer=${layer}` : ""}\n`); add++;
|
|
101
|
+
}
|
|
102
|
+
if (add && dry !== "1") write(reg, d);
|
|
103
|
+
out(`── 补判 ${add} 条 · 已有值跳过 ${skip} 条 · ephemeral 待人工 ${eph} 条${dry === "1" ? "(dry-run 未写入)" : ""} ──\n`);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "own-json": out(JSON.stringify({ ownTasks: a }) + "\n"); break;
|
|
107
|
+
case "expected": { // <manifest> <reg> <platform>
|
|
108
|
+
const [manifest, reg, plat] = a;
|
|
109
|
+
try {
|
|
110
|
+
const exp = (read(manifest).tasks || []); const have = new Set(Object.keys(tasks(read(reg))));
|
|
111
|
+
const miss = exp.filter((t) => (t.platforms || [plat]).includes(plat) && !have.has(t.name)).map((t) => t.name);
|
|
112
|
+
out(miss.join(" "));
|
|
113
|
+
} catch { break; }
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "bootstrap-rows": { // <reg>
|
|
117
|
+
if (!existsSync(a[0])) break;
|
|
118
|
+
for (const [n, t] of Object.entries(tasks(read(a[0])))) out([n, t.cmd || "", t.interval || "-", t.calendar || "-", t.desc || "", t.weekdays || "-", t.type || "periodic", t.path_class || ""].join("\t") + "\n");
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "json": { // <key=value...> -> JSON object (true/false become booleans)
|
|
122
|
+
const d = {};
|
|
123
|
+
for (const kv of a) {
|
|
124
|
+
const i = kv.indexOf("="); const v = kv.slice(i + 1);
|
|
125
|
+
d[kv.slice(0, i)] = v === "true" ? true : v === "false" ? false : v;
|
|
126
|
+
}
|
|
127
|
+
out(JSON.stringify(d)); break;
|
|
128
|
+
}
|
|
129
|
+
default: process.stderr.write(`json.mjs: unknown command ${cmd}\n`); process.exit(2);
|
|
130
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dia-job 路径策略(项目无关;不依赖 yan-agent/platform.sh)
|
|
3
|
+
# 目标:判断注册命令的目标是否稳定;worktree/ephemeral 必须拒绝。
|
|
4
|
+
# 输出:dia_job_cmd_path_class 设置 YP_CLASS / YP_CMD_TARGET / YP_MAIN / YP_REASON(兼容旧调用方)。
|
|
5
|
+
|
|
6
|
+
dia_job_data_root() {
|
|
7
|
+
printf '%s' "${DIA_JOB_ROOT:-${YAN_DATA_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/dia-job}}"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# 主 checkout:git-common-dir 的父目录;裸仓/非仓明确失败。
|
|
11
|
+
dia_job_main_checkout_of() {
|
|
12
|
+
local d="${1:-}" common abs
|
|
13
|
+
if [ -z "$d" ]; then printf 'dia_job_main_checkout_of: 缺少参数(目录)\n' >&2; return 2; fi
|
|
14
|
+
[ -d "$d" ] || { printf 'dia_job_main_checkout_of: 目录不存在:%s\n' "$d" >&2; return 1; }
|
|
15
|
+
if [ "$(git -C "$d" rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]; then
|
|
16
|
+
printf 'dia_job_main_checkout_of: 不是 git 工作区(裸仓或非仓):%s\n' "$d" >&2
|
|
17
|
+
return 1
|
|
18
|
+
fi
|
|
19
|
+
common="$(git -C "$d" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || {
|
|
20
|
+
printf 'dia_job_main_checkout_of: 无法解析 git-common-dir:%s\n' "$d" >&2; return 1; }
|
|
21
|
+
[ -n "$common" ] || { printf 'dia_job_main_checkout_of: git-common-dir 为空:%s\n' "$d" >&2; return 1; }
|
|
22
|
+
if [ "${common#/}" = "$common" ]; then
|
|
23
|
+
common="$(cd "$d" && cd "$common" 2>/dev/null && pwd -P)" || {
|
|
24
|
+
printf 'dia_job_main_checkout_of: 无法定位 git-common-dir(旧版 git 回退失败):%s\n' "$d" >&2; return 1; }
|
|
25
|
+
fi
|
|
26
|
+
case "$(basename "$common")" in
|
|
27
|
+
.git) : ;;
|
|
28
|
+
*) printf 'dia_job_main_checkout_of: 意外的 git-common-dir:%s\n' "$common" >&2; return 1 ;;
|
|
29
|
+
esac
|
|
30
|
+
abs="$(cd "$(dirname "$common")" 2>/dev/null && pwd -P)" || {
|
|
31
|
+
printf 'dia_job_main_checkout_of: 无法规范化路径:%s\n' "$common" >&2; return 1; }
|
|
32
|
+
printf '%s' "$abs"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# 提取注册命令中的首个绝对路径;对命令替换只识别全局稳定入口目录。
|
|
36
|
+
dia_job_extract_target() {
|
|
37
|
+
local cmd="${1:-}" entry refhit
|
|
38
|
+
[ -n "$cmd" ] || return 0
|
|
39
|
+
entry="${DIA_JOB_ENTRY_DIR:-$(dia_job_data_root)/bin}"
|
|
40
|
+
case "$cmd" in
|
|
41
|
+
*'$('*)
|
|
42
|
+
# 只提取引号内、位于稳定入口目录下的路径;不执行命令替换
|
|
43
|
+
refhit="$(printf '%s' "$cmd" | grep -oE "'[^']+'" | tr -d "'" | while IFS= read -r x; do
|
|
44
|
+
case "$x" in "$entry"/*) printf '%s' "$x"; break ;; esac
|
|
45
|
+
done)"
|
|
46
|
+
[ -n "$refhit" ] && printf '%s' "$refhit"
|
|
47
|
+
return 0 ;;
|
|
48
|
+
esac
|
|
49
|
+
case "$cmd" in
|
|
50
|
+
*'$('*|*'`'*|*'>'*|*'<'*|*';'*|*'|'*|*'&'*) return 0 ;;
|
|
51
|
+
esac
|
|
52
|
+
bash -c '
|
|
53
|
+
c="$1"
|
|
54
|
+
bash -n <<<"$c" 2>/dev/null || exit 0
|
|
55
|
+
eval "set -- $c" 2>/dev/null || exit 0
|
|
56
|
+
for a in "$@"; do case "$a" in /*) printf "%s" "$a"; break;; esac; done
|
|
57
|
+
' _ "$cmd" 2>/dev/null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dia_job_path_stability() {
|
|
61
|
+
local p="${1:-}"
|
|
62
|
+
YP_CLASS="unknown"; YP_MAIN=""; YP_REASON=""
|
|
63
|
+
if [ -z "$p" ]; then YP_CLASS="none"; YP_REASON="无路径(如 adopt 的任务无 cmd)"; return 0; fi
|
|
64
|
+
case "$p" in
|
|
65
|
+
*-worktree--*|*/.worktrees/*)
|
|
66
|
+
YP_CLASS="ephemeral"; YP_REASON="指向 worktree(临时目录)—— 清理后必悬空"; return 0 ;;
|
|
67
|
+
esac
|
|
68
|
+
case "$p" in /*) : ;; *) YP_CLASS="unknown"; YP_REASON="相对路径,无法判定稳定性"; return 0 ;; esac
|
|
69
|
+
case "$p" in
|
|
70
|
+
"${DIA_JOB_ENTRY_DIR:-$(dia_job_data_root)/bin}"/*)
|
|
71
|
+
YP_CLASS="ref-entry"; YP_REASON="全局稳定入口(DIA_JOB_ROOT/bin)"; return 0 ;;
|
|
72
|
+
esac
|
|
73
|
+
local dir="$p"
|
|
74
|
+
[ -d "$dir" ] || dir="$(dirname "$p")"
|
|
75
|
+
while [ -n "$dir" ] && [ "$dir" != "/" ] && [ ! -d "$dir" ]; do dir="$(dirname "$dir")"; done
|
|
76
|
+
local p_real; p_real="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)/$(basename "$p")"
|
|
77
|
+
[ -n "$p_real" ] || p_real="$p"
|
|
78
|
+
local wt main_wt
|
|
79
|
+
main_wt="$(dia_job_main_checkout_of "$dir" 2>/dev/null || true)"
|
|
80
|
+
if [ -n "$main_wt" ]; then
|
|
81
|
+
while IFS= read -r wt; do
|
|
82
|
+
[ -n "$wt" ] || continue
|
|
83
|
+
[ "$wt" = "$main_wt" ] && continue
|
|
84
|
+
case "$p_real" in
|
|
85
|
+
"$wt"/*|"$wt")
|
|
86
|
+
YP_CLASS="ephemeral"; YP_MAIN="$main_wt"
|
|
87
|
+
YP_REASON="命中 git worktree 清单中的非主 checkout(${wt})—— 清理后必悬空"; return 0 ;;
|
|
88
|
+
esac
|
|
89
|
+
done < <(git -C "$dir" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
|
|
90
|
+
fi
|
|
91
|
+
if [ -n "$main_wt" ]; then
|
|
92
|
+
case "$p_real" in "$main_wt"/*) YP_REASON="位于主 checkout $main_wt 之下" ;; *) YP_REASON="同一仓(主 checkout ${main_wt})" ;; esac
|
|
93
|
+
YP_CLASS="main-checkout"; YP_MAIN="$main_wt"; return 0
|
|
94
|
+
fi
|
|
95
|
+
YP_CLASS="non-repo-abs"; YP_REASON="绝对路径,但不在任何 git 仓内"; return 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dia_job_cmd_path_class() {
|
|
99
|
+
YP_CMD_TARGET="$(dia_job_extract_target "${1:-}")"
|
|
100
|
+
if [ -z "${1:-}" ]; then YP_CLASS="none"; YP_REASON="无 cmd(adopt 的任务)"; return 0; fi
|
|
101
|
+
if [ -z "$YP_CMD_TARGET" ]; then YP_CLASS="unknown"; YP_REASON="cmd 中未提取到绝对路径"; return 0; fi
|
|
102
|
+
dia_job_path_stability "$YP_CMD_TARGET"
|
|
103
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dia/job",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "dia scheduler adapter: launchd / cron / schtasks",
|
|
6
|
+
"bin": { "dia-job": "bin/dia-job" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bash tests/bootstrap.test.sh && bash tests/p2.test.sh && bash tests/policy.test.sh && bash tests/install.test.sh && bash tests/linux.test.sh"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# yan-task 兼容 shim:只转发到已安装的 dia-job;拒绝 worktree 路径(yan-ref 纪律)。
|
|
3
|
+
set -u
|
|
4
|
+
SELF="${BASH_SOURCE[0]:-$0}"
|
|
5
|
+
while [ -L "$SELF" ]; do
|
|
6
|
+
d="$(cd -P "$(dirname "$SELF")" >/dev/null 2>&1 && pwd)"; t="$(readlink "$SELF")"
|
|
7
|
+
case "$t" in /*) SELF="$t" ;; *) SELF="$d/$t" ;; esac
|
|
8
|
+
done
|
|
9
|
+
case "$SELF" in
|
|
10
|
+
*-worktree--*|*/.worktrees/*) echo "yan-task shim: 拒绝从 worktree 运行(${SELF})" >&2; exit 4 ;;
|
|
11
|
+
esac
|
|
12
|
+
|
|
13
|
+
BIN="${DIA_JOB_BIN:-}"
|
|
14
|
+
if [ -z "$BIN" ]; then
|
|
15
|
+
for c in "$HOME/.local/bin/dia-job" /opt/homebrew/bin/dia-job /usr/local/bin/dia-job; do
|
|
16
|
+
[ -x "$c" ] && BIN="$c" && break
|
|
17
|
+
done
|
|
18
|
+
fi
|
|
19
|
+
[ -n "$BIN" ] || BIN="$(command -v dia-job 2>/dev/null || true)"
|
|
20
|
+
[ -n "$BIN" ] || { echo "yan-task shim: 未找到 dia-job;先跑 dia-job 的 install.sh" >&2; exit 3; }
|
|
21
|
+
case "$BIN" in
|
|
22
|
+
*-worktree--*|*/.worktrees/*) echo "yan-task shim: 拒绝 worktree 里的 dia-job(${BIN})" >&2; exit 4 ;;
|
|
23
|
+
esac
|
|
24
|
+
exec "$BIN" "$@"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# yan-task-bootstrap.test.sh —— `yan-task bootstrap` 字段解析回归(2026-10-16 实测事故)
|
|
3
|
+
#
|
|
4
|
+
# **被修的 bug**:原实现 `print(n, cmd, interval, calendar, desc)` + `while IFS=' ' read`,
|
|
5
|
+
# 而 **cmd 里含空格**(macOS 数据根 `Application Support/…`,desc 也含空格)⇒ 字段错位 ⇒
|
|
6
|
+
# cmd 被截断成 `bash` ⇒ 门控判 unknown ⇒ **每个任务 rc=4** ⇒ bootstrap(设计的**兜底恢复入口**)
|
|
7
|
+
# 在 macOS 上**完全不可用**(现场:四个 yan 定时任务全掉线,跑 bootstrap 一个都恢复不了)。
|
|
8
|
+
# 另修:分派行 `bootstrap) cmd_bootstrap` **没 shift/透传** ⇒ `--dry-run` 永远进不去。
|
|
9
|
+
#
|
|
10
|
+
# 本测试用 `--dry-run`(只打印解析结果、不装 launchd)⇒ **无需 root/launchd 即可回归**;
|
|
11
|
+
# 数据根**故意带空格**(复现事故现场)。用法:bash scripts/yan-task-bootstrap.test.sh
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
|
|
14
|
+
# tests must not inherit the developer global config
|
|
15
|
+
export DIA_JOB_CONFIG=/dev/null
|
|
16
|
+
|
|
17
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
YT="$HERE/../bin/dia-job"
|
|
19
|
+
TD="$(mktemp -d)"
|
|
20
|
+
DR="$TD/yan agent data" # ⚠️ 故意带空格(事故现场)
|
|
21
|
+
mkdir -p "$DR/tasks"
|
|
22
|
+
export YAN_DATA_ROOT="$DR"
|
|
23
|
+
|
|
24
|
+
PASS=0; FAIL=0
|
|
25
|
+
ok(){ echo " ✓ $1"; PASS=$((PASS+1)); }
|
|
26
|
+
bad(){ echo " ✗ $1"; FAIL=$((FAIL+1)); }
|
|
27
|
+
|
|
28
|
+
# 夹具:两个任务,cmd **都含空格路径**(一个引导层直连、一个业务层走指针)
|
|
29
|
+
CMD1="bash '$DR/repos/yan-agent/scripts/yan-task' selfcheck"
|
|
30
|
+
CMD2="CODE=\"\$(bash '$DR/bin/yan-ref' resolve)\" || { CODE='$DR/repos/yan-agent'; echo \"[warn] x\" >&2; }; bash \"\$CODE/scripts/yan-worktree\" check --all --quiet"
|
|
31
|
+
node - "$DR/tasks/registry.json" "$CMD1" "$CMD2" <<'JS'
|
|
32
|
+
const fs = require("node:fs");
|
|
33
|
+
const [p, c1, c2] = process.argv.slice(2);
|
|
34
|
+
fs.writeFileSync(p, JSON.stringify({tasks: {
|
|
35
|
+
"demo-selfcheck": {type: "periodic", backend: "launchd", label: "local.dia-job-demo-selfcheck", cmd: c1, interval: "300", calendar: "-", desc: "演示 引导层(含 空格)"},
|
|
36
|
+
"demo-business": {type: "periodic", backend: "launchd", label: "local.dia-job-demo-business", cmd: c2, interval: "-", calendar: "14:10", desc: "演示 业务层(含 空格)"},
|
|
37
|
+
}}, null, 2) + "\n");
|
|
38
|
+
JS
|
|
39
|
+
|
|
40
|
+
echo "== 1. --dry-run 只打印、不重建(分派行必须 shift+透传)=="
|
|
41
|
+
OUT="$(bash "$YT" bootstrap --dry-run 2>&1)"
|
|
42
|
+
printf '%s' "$OUT" | grep -q "重建" && bad "dry-run 竟然真的重建了" || ok "未执行重建(dry-run 语义正确)"
|
|
43
|
+
[ "$(printf '%s' "$OUT" | grep -c '^DRYRUN')" = "2" ] && ok "打印 2 行(= 任务数)" || { bad "行数不对"; printf '%s\n' "$OUT" | sed 's/^/ | /'; }
|
|
44
|
+
|
|
45
|
+
echo "== 2. **含空格的 cmd 不被截断**(核心回归:原实现会截成 \`bash\`)=="
|
|
46
|
+
# ⚠️ 用 node -e(不要与 heredoc 抢 stdin)
|
|
47
|
+
if printf '%s' "$OUT" | node -e '
|
|
48
|
+
const fs = require("node:fs");
|
|
49
|
+
const [c1, c2] = process.argv.slice(1);
|
|
50
|
+
const rows = {};
|
|
51
|
+
for (const ln of fs.readFileSync(0, "utf8").split("\n")) {
|
|
52
|
+
const f = ln.split("\t");
|
|
53
|
+
if (f.length >= 5 && f[0] === "DRYRUN") rows[f[1]] = f[2];
|
|
54
|
+
}
|
|
55
|
+
let bad = 0;
|
|
56
|
+
for (const [name, want] of Object.entries({"demo-selfcheck": c1, "demo-business": c2})) {
|
|
57
|
+
if (rows[name] === want) console.log(` ✓ ${name}: cmd 完整保留(${want.length} 字符)`);
|
|
58
|
+
else { console.log(` ✗ ${name}: cmd 被改动`); bad++; }
|
|
59
|
+
}
|
|
60
|
+
process.exit(bad ? 1 : 0);
|
|
61
|
+
' "$CMD1" "$CMD2"; then
|
|
62
|
+
PASS=$((PASS+2))
|
|
63
|
+
else
|
|
64
|
+
FAIL=$((FAIL+1))
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
echo "== 3. 无 registry ⇒ 优雅返回 0 =="
|
|
68
|
+
DRE="$TD/empty-root"; mkdir -p "$DRE"
|
|
69
|
+
YAN_DATA_ROOT="$DRE" bash "$YT" bootstrap >/dev/null 2>&1 && ok "exit 0(无 registry 不报错)" || bad "无 registry 应返回 0"
|
|
70
|
+
|
|
71
|
+
echo "== 4. selfcheck:**adopted 不计缺失**(2026-10-16 修);普通未加载仍计入 =="
|
|
72
|
+
# 两个任务都指向不存在的 label(= 未加载):一个 adopted、一个普通
|
|
73
|
+
node - "$DR/tasks/registry.json" <<'JS'
|
|
74
|
+
const fs = require("node:fs");
|
|
75
|
+
const p = process.argv[2];
|
|
76
|
+
fs.writeFileSync(p, JSON.stringify({tasks: {
|
|
77
|
+
"demo-adopted": {type:"periodic", backend:"launchd", label:"local.dia-job-demo-nonexistent-adopted", cmd:"bash '/tmp/x.sh'", interval:"300", calendar:"-", desc:"演示 adopted", adopted:true},
|
|
78
|
+
"demo-normal": {type:"periodic", backend:"launchd", label:"local.dia-job-demo-nonexistent-normal", cmd:"bash '/tmp/x.sh'", interval:"300", calendar:"-", desc:"演示 normal"},
|
|
79
|
+
}}, null, 2) + "\n");
|
|
80
|
+
JS
|
|
81
|
+
# 让"生效指针"检查通过:否则 selfcheck 会因**指针断链**多报 1 项「缺失」
|
|
82
|
+
# (那是 doctor/selfcheck 的另一职责,非本用例要测的"adopted 是否计入任务缺失")
|
|
83
|
+
mkdir -p "$DR/bin"
|
|
84
|
+
printf '#!/bin/bash\ncase "${1:-}" in check) exit 0;; resolve) echo "%s/repos/yan-agent";; *) exit 0;; esac\n' "$DR" > "$DR/bin/yan-ref"
|
|
85
|
+
chmod +x "$DR/bin/yan-ref"
|
|
86
|
+
printf '{"worktree":"%s/repos/yan-agent","commit":"0000000"}\n' "$DR" > "$DR/active-ref.json"
|
|
87
|
+
OUT2="$(bash "$YT" selfcheck --dry-run 2>&1)"; RC2=$?
|
|
88
|
+
printf '%s' "$OUT2" | grep -q "adopted" && ok "adopted 未加载 ⇒ 明确标注「adopted … 不计缺失」" || { bad "adopted 未标注"; printf '%s\n' "$OUT2" | sed 's/^/ | /'; }
|
|
89
|
+
printf '%s' "$OUT2" | grep -q "缺失 1" && ok "缺失计数=1(**adopted 未计入**,普通仍计入)" || { bad "缺失计数不对"; printf '%s\n' "$OUT2" | sed 's/^/ | /'; }
|
|
90
|
+
[ "$RC2" = 1 ] && ok "exit 1(仅因普通任务未加载)" || bad "exit 应为 1(实得 ${RC2})"
|
|
91
|
+
printf '%s' "$OUT2" | grep -q "未找到命令" && bad "**参数被当命令执行**(反引号命令替换回归!)" || ok "带参不报「未找到命令」(反引号 bug 已修)"
|
|
92
|
+
|
|
93
|
+
echo "== 5. YAN_TASK_NO_LAUNCHD=1 ⇒ 测试跑 install **不碰真实 launchd 域**(2026-10-16 事故隔离)=="
|
|
94
|
+
# cmd 指向**git 仓内**(否则被门控判 non-repo-abs 拒掉,测不到 launchd 那步)
|
|
95
|
+
mkdir -p "$DR/repos/yan-agent/scripts"; [ -d "$DR/repos/yan-agent/.git" ] || { git -C "$DR/repos/yan-agent" init -q; printf x > "$DR/repos/yan-agent/f"; git -C "$DR/repos/yan-agent" add -A; git -C "$DR/repos/yan-agent" -c user.email=t@t -c user.name=t commit -qm i; }
|
|
96
|
+
printf '#!/bin/bash\n' > "$DR/repos/yan-agent/scripts/x.sh"
|
|
97
|
+
OUT5="$(YAN_TASK_NO_LAUNCHD=1 bash "$YT" install demo-isolated --cmd "bash '$DR/repos/yan-agent/scripts/x.sh'" --desc '隔离演示' 2>&1)"; RC5=$?
|
|
98
|
+
[ "$RC5" = 0 ] && ok "install 成功(rc=0)" || { bad "install 失败(rc=${RC5})"; printf '%s\n' "$OUT5" | sed 's/^/ | /'; }
|
|
99
|
+
printf '%s' "$OUT5" | grep -qE "已跳过 (launchd 加载|crontab 写入|systemd 加载)" && ok "明确声明跳过调度写入(launchd/crontab/systemd)" || { bad "未声明跳过"; printf '%s\n' "$OUT5" | sed 's/^/ | /'; }
|
|
100
|
+
printf '%s' "$OUT5" | grep -q "launchctl bootstrap 失败" && bad "仍在尝试真实 launchctl" || ok "未触发真实 launchctl 调用"
|
|
101
|
+
|
|
102
|
+
echo "== 6. 静态契约:launchd 写入点**必须都在隔离守卫下**(2026-10-16 实测踩到"守卫打错行")=="
|
|
103
|
+
# install 的 bootout/tootstrap 必须在 if _launchd_off 分支内;uninstall 的 bootout 必须带守卫前缀。
|
|
104
|
+
# 教训:`replace(...,1)` 命中了 install 里的**同名行** ⇒ uninstall 未隔离 ⇒ 定点复现里真实任务被 bootout ✗
|
|
105
|
+
_n_bootout="$(grep -c 'launchctl bootout' "$YT" || true)" # 不计缩进(uninstall 那处前缀是守卫)
|
|
106
|
+
_n_guarded="$(grep -c '_launchd_off || launchctl bootout' "$YT" || true)"
|
|
107
|
+
[ "$_n_bootout" = "2" ] && ok "bootout 调用点 = 2(install + uninstall)" || bad "bootout 调用点数异常:$_n_bootout"
|
|
108
|
+
[ "$_n_guarded" = "1" ] && ok "其中 uninstall 那处带 _launchd_off 守卫" || bad "守卫缺失/重复:$_n_guarded"
|
|
109
|
+
grep -q 'if _launchd_off; then' "$YT" && ok "install 侧走 if _launchd_off 分支" || bad "install 侧未见隔离分支"
|
|
110
|
+
grep -q 'launchctl bootstrap' "$YT" && ! grep -q '_launchd_off || launchctl bootstrap' "$YT" && ok "bootstrap 未被冗余守卫(已在 if 分支内)" || bad "bootstrap 守卫位置可疑"
|
|
111
|
+
|
|
112
|
+
echo
|
|
113
|
+
echo "── PASS $PASS · FAIL $FAIL ──"
|
|
114
|
+
rm -rf "$TD"
|
|
115
|
+
[ "$FAIL" = 0 ]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# install.sh:全局目录 + 稳定链接 + 拒绝项目路径依赖
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
# tests must not inherit the developer global config
|
|
6
|
+
export DIA_JOB_CONFIG=/dev/null
|
|
7
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/.." && pwd)"
|
|
8
|
+
TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
|
|
9
|
+
PASS=0; FAIL=0; ok(){ echo " ✓ $1"; PASS=$((PASS+1)); }; bad(){ echo " ✗ $1"; FAIL=$((FAIL+1)); }
|
|
10
|
+
|
|
11
|
+
HOME_DIR="$TD/home"; BIN_DIR="$TD/bin"
|
|
12
|
+
DIA_JOB_HOME="$HOME_DIR" DIA_JOB_BIN_DIR="$BIN_DIR" bash "$ROOT/install.sh" >/dev/null 2>&1 || bad "install 失败"
|
|
13
|
+
[ -x "$HOME_DIR/bin/dia-job" ] && ok "复制 binary" || bad "缺 binary"
|
|
14
|
+
[ -L "$BIN_DIR/dia-job" ] && ok "稳定链接" || bad "缺链接"
|
|
15
|
+
[ "$(readlink "$BIN_DIR/dia-job")" = "$HOME_DIR/bin/dia-job" ] && ok "链接指向全局目录" || bad "链接目标错"
|
|
16
|
+
[ -f "$HOME_DIR/lib/policy.sh" ] && ok "复制 policy" || bad "缺 policy"
|
|
17
|
+
[ -f "$HOME_DIR/lib/json.mjs" ] && ok "复制 json adapter" || bad "缺 json adapter"
|
|
18
|
+
# 重复安装幂等
|
|
19
|
+
DIA_JOB_HOME="$HOME_DIR" DIA_JOB_BIN_DIR="$BIN_DIR" bash "$ROOT/install.sh" >/dev/null 2>&1 && ok "重复安装 rc=0" || bad "重复安装失败"
|
|
20
|
+
# 从全局安装跑 list
|
|
21
|
+
mkdir -p "$TD/root"
|
|
22
|
+
out="$(DIA_JOB_ROOT="$TD/root" "$BIN_DIR/dia-job" list 2>&1)"; printf '%s' "$out" | grep -q "$TD/root" && ok "安装后可运行" || bad "运行失败:$out"
|
|
23
|
+
# shim 找到安装的 binary
|
|
24
|
+
out="$(HOME="$TD/h" DIA_JOB_BIN="$BIN_DIR/dia-job" bash "$ROOT/shim/yan-task" list 2>&1)"; printf '%s' "$out" | grep -q "平台" && ok "shim 转发" || bad "shim 失败:$out"
|
|
25
|
+
# shim 拒绝 worktree 路径
|
|
26
|
+
WT="$TD/repo-worktree--x"; mkdir -p "$WT"; cp "$ROOT/shim/yan-task" "$WT/yan-task"; chmod +x "$WT/yan-task"
|
|
27
|
+
WT_OUT="$(HOME="$TD/h" DIA_JOB_BIN="$BIN_DIR/dia-job" bash "$WT/yan-task" list 2>&1)"; WT_RC=$?
|
|
28
|
+
# 断言 rc=4;失败时把 rc 与输出打出来(此前只报"未拒绝",CI 上无法定位,2026-10-30 实测)
|
|
29
|
+
[ "$WT_RC" = 4 ] && ok "shim 拒绝 worktree" || { bad "worktree 未拒绝(rc=${WT_RC})"; printf '%s\n' "$WT_OUT" | sed 's/^/ | /'; }
|
|
30
|
+
echo; echo "── PASS $PASS · FAIL $FAIL ──"; [ "$FAIL" = 0 ]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# linux.test.sh —— Linux/WSL 后端(periodic=crontab、service=systemd --user)的行为约束
|
|
3
|
+
#
|
|
4
|
+
# 为什么要有这份(2026-10-30):Linux 后端此前只有 `return 3 未实现` ⇒ 一旦落地,
|
|
5
|
+
# 「装了但不会跑」是最坏的失败模式(cron 没有进程状态、systemd 不 active 也照样"注册成功")。
|
|
6
|
+
# ⇒ 本测试用**假的 crontab / systemctl**(沙箱 PATH 前置)覆盖:写入形态、幂等、不可表达拒绝、
|
|
7
|
+
# 在场判定(status/selfcheck/doctor)、卸载清理、service→systemd 分派。
|
|
8
|
+
#
|
|
9
|
+
# 平台边界:只在 linux/wsl 上跑(macOS 上 launchd 分支走既有测试)。
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
export DIA_JOB_CONFIG=/dev/null
|
|
12
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/.." && pwd)"
|
|
13
|
+
JOB="$ROOT/bin/dia-job"
|
|
14
|
+
TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
|
|
15
|
+
PASS=0; FAIL=0; ok(){ echo " ✓ $1"; PASS=$((PASS+1)); }; bad(){ echo " ✗ $1"; FAIL=$((FAIL+1)); }
|
|
16
|
+
|
|
17
|
+
case "$(uname -s)" in Linux) ;; *) echo "(非 Linux,跳过 linux.test.sh)"; exit 0 ;; esac
|
|
18
|
+
|
|
19
|
+
# ── 沙箱:fake crontab / systemctl ─────────────────────────────────────────
|
|
20
|
+
FAKE="$TD/fakebin"; mkdir -p "$FAKE"
|
|
21
|
+
export CRON_TABLE="$TD/crontab.txt"; : > "$CRON_TABLE"
|
|
22
|
+
cat > "$FAKE/crontab" <<'EOS'
|
|
23
|
+
#!/bin/bash
|
|
24
|
+
# 假 crontab:-l 打印表;`-` 从 stdin 覆盖表
|
|
25
|
+
if [ "${1:-}" = "-l" ]; then cat "$CRON_TABLE"; exit 0; fi
|
|
26
|
+
if [ "${1:-}" = "-" ]; then cat > "$CRON_TABLE"; exit 0; fi
|
|
27
|
+
exit 2
|
|
28
|
+
EOS
|
|
29
|
+
cat > "$FAKE/systemctl" <<'EOS'
|
|
30
|
+
#!/bin/bash
|
|
31
|
+
# 假 systemctl:记录调用;is-active/is-enabled 依据 $SVC_STATE_DIR/<unit>
|
|
32
|
+
printf '%s\n' "$*" >> "$SYSTEMCTL_LOG"
|
|
33
|
+
u="${!#}"; d="$SVC_STATE_DIR"
|
|
34
|
+
case "$*" in
|
|
35
|
+
*show-environment*) exit 0 ;;
|
|
36
|
+
*daemon-reload*) exit 0 ;;
|
|
37
|
+
*enable*) mkdir -p "$d"; : > "$d/$u"; exit 0 ;;
|
|
38
|
+
*disable*) rm -f "$d/$u"; exit 0 ;;
|
|
39
|
+
*is-active*) [ -f "$d/$u" ] && { echo active; exit 0; }; echo inactive; exit 3 ;;
|
|
40
|
+
*is-enabled*) [ -f "$d/$u" ] && { echo enabled; exit 0; }; echo disabled; exit 1 ;;
|
|
41
|
+
*is-active\ --quiet*|*--quiet*) [ -f "$d/$u" ] && exit 0 || exit 3 ;;
|
|
42
|
+
*start*|*restart*) mkdir -p "$d"; : > "$d/$u"; exit 0 ;;
|
|
43
|
+
*MainPID*) echo 4242; exit 0 ;;
|
|
44
|
+
*) exit 0 ;;
|
|
45
|
+
esac
|
|
46
|
+
EOS
|
|
47
|
+
cat > "$FAKE/systemctl-is-active" <<'EOS'
|
|
48
|
+
#!/bin/bash
|
|
49
|
+
exit 0
|
|
50
|
+
EOS
|
|
51
|
+
chmod +x "$FAKE"/*
|
|
52
|
+
export SYSTEMCTL_LOG="$TD/systemctl.log"; : > "$SYSTEMCTL_LOG"
|
|
53
|
+
export SVC_STATE_DIR="$TD/svcstate"; mkdir -p "$SVC_STATE_DIR"
|
|
54
|
+
|
|
55
|
+
HOME_DIR="$TD/home"; ROOT_DIR="$TD/root"; mkdir -p "$HOME_DIR" "$ROOT_DIR"
|
|
56
|
+
# ⚠️ XDG_CONFIG_HOME 必须一起沙箱化:CI(GitHub runner)会预置它 ⇒ 不覆盖的话
|
|
57
|
+
# unit 会写到 runner 的 ~/.config 而不是测试沙箱(本地没设该变量 ⇒ 本地假绿,CI 红)
|
|
58
|
+
run() { HOME="$HOME_DIR" XDG_CONFIG_HOME="$HOME_DIR/.config" DIA_JOB_ROOT="$ROOT_DIR" \
|
|
59
|
+
DIA_JOB_CRONTAB_BIN="$FAKE/crontab" DIA_JOB_SYSTEMCTL_BIN="$FAKE/systemctl" \
|
|
60
|
+
PATH="$FAKE:$PATH" "$JOB" "$@"; }
|
|
61
|
+
reg_field() { HOME="$HOME_DIR" DIA_JOB_ROOT="$ROOT_DIR" node "$ROOT/lib/json.mjs" field "$ROOT_DIR/tasks/registry.json" "$1" "$2" 2>/dev/null; }
|
|
62
|
+
|
|
63
|
+
echo "── periodic:interval → crontab ──"
|
|
64
|
+
out="$(run install hello --cmd 'echo hi' --interval 300 --desc '测试任务' --allow-unstable 2>&1)"; rc=$?
|
|
65
|
+
[ "$rc" = 0 ] && ok "install rc=0" || bad "install rc=${rc}:$out"
|
|
66
|
+
line="$(grep -F '# dia-job:hello' "$CRON_TABLE")"
|
|
67
|
+
printf '%s' "$line" | grep -q '^\*/5 \* \* \* \*' && ok "interval 300 → */5 分钟" || bad "cron 表达式错:$line"
|
|
68
|
+
printf '%s' "$line" | grep -q 'PATH=' && ok "行内含 PATH(cron 最小环境)" || bad "缺 PATH:$line"
|
|
69
|
+
printf '%s' "$line" | grep -q '/bin/bash "' && ok "行内用 /bin/bash 跑 wrapper" || bad "缺 wrapper 调用:$line"
|
|
70
|
+
WRAPPER="$ROOT_DIR/tasks/hello.sh"
|
|
71
|
+
[ -x "$WRAPPER" ] && ok "wrapper 已生成且可执行" || bad "缺 wrapper:$WRAPPER"
|
|
72
|
+
grep -q 'export DIA_JOB_ROOT' "$WRAPPER" && ok "wrapper 导出 DIA_JOB_ROOT" || bad "wrapper 未导出数据根"
|
|
73
|
+
[ "$(reg_field hello backend)" = cron ] && ok "registry backend=cron" || bad "registry backend=$(reg_field hello backend)"
|
|
74
|
+
|
|
75
|
+
run install hello --cmd 'echo hi' --interval 300 --allow-unstable >/dev/null 2>&1
|
|
76
|
+
[ "$(grep -cF '# dia-job:hello' "$CRON_TABLE")" = 1 ] && ok "重复 install 幂等(单条)" || bad "重复 install 产生了多条"
|
|
77
|
+
|
|
78
|
+
echo "── periodic:calendar + weekdays ──"
|
|
79
|
+
run install daily --cmd 'echo d' --calendar 09:30 --weekdays 1-5 --allow-unstable --desc "日历任务" >/dev/null 2>&1
|
|
80
|
+
grep -F '# dia-job:daily' "$CRON_TABLE" | grep -q '^30 9 \* \* 1,2,3,4,5' && ok "calendar+weekdays → 30 9 * * 1,2,3,4,5" \
|
|
81
|
+
|| bad "calendar 表达式错:$(grep -F '# dia-job:daily' "$CRON_TABLE")"
|
|
82
|
+
|
|
83
|
+
echo "── periodic:不可表达要明确拒绝(不许"看着装好了")──"
|
|
84
|
+
before="$(wc -l < "$CRON_TABLE")"
|
|
85
|
+
run install tooofter --cmd 'echo x' --interval 30 --allow-unstable --desc "太短" >/dev/null 2>&1; rc=$?
|
|
86
|
+
[ "$rc" = 2 ] && ok "interval 30s(<1min)→ rc=2" || bad "interval 30s 期望 rc=2,实得 $rc"
|
|
87
|
+
[ "$(wc -l < "$CRON_TABLE")" = "$before" ] && ok "拒绝后 crontab 未变" || bad "拒绝后仍写了 crontab"
|
|
88
|
+
run install oneshot --cmd 'echo x' --allow-unstable --desc '无调度(等价 RunAtLoad)' >/dev/null 2>&1; rc=$?
|
|
89
|
+
[ "$rc" = 0 ] && ok "无调度 → @reboot(对齐 macOS RunAtLoad)" || bad "无调度期望 rc=0,实得 $rc"
|
|
90
|
+
grep -F '# dia-job:oneshot' "$CRON_TABLE" | grep -q '^@reboot ' && ok "写的是 @reboot 条目" || bad "@reboot 条目缺失:$(grep -F oneshot "$CRON_TABLE")"
|
|
91
|
+
|
|
92
|
+
echo "── 在场判定:status / selfcheck / doctor ──"
|
|
93
|
+
out="$(run status 2>&1)"; printf '%s' "$out" | grep -q 'scheduled' && ok "status 显示 scheduled" || bad "status 未见 scheduled:$out"
|
|
94
|
+
grep -vF '# dia-job:hello' "$CRON_TABLE" > "$TD/t" && cp "$TD/t" "$CRON_TABLE"
|
|
95
|
+
out="$(run selfcheck --dry-run 2>&1)"; rc=$?
|
|
96
|
+
[ "$rc" = 1 ] && ok "条目被删 → selfcheck --dry-run rc=1" || bad "selfcheck dry-run 期望 rc=1,实得 ${rc}:$out"
|
|
97
|
+
printf '%s' "$out" | grep -q 'hello' && ok "selfcheck 点名 hello" || bad "selfcheck 未点名:$out"
|
|
98
|
+
run selfcheck >/dev/null 2>&1
|
|
99
|
+
grep -qF '# dia-job:hello' "$CRON_TABLE" && ok "selfcheck 自愈:条目重建" || bad "selfcheck 未重建条目"
|
|
100
|
+
out="$(run selfcheck 2>&1)"; rc=$?
|
|
101
|
+
[ "$rc" = 0 ] && ok "自愈后 selfcheck rc=0" || bad "自愈后 selfcheck rc=${rc}:$out"
|
|
102
|
+
|
|
103
|
+
echo "── service:systemd --user ──"
|
|
104
|
+
out="$(run install svc --cmd 'bash /tmp/svc.sh' --type service --allow-unstable --desc '常驻测试' 2>&1)"; rc=$?
|
|
105
|
+
[ "$rc" = 0 ] && ok "service install rc=0" || bad "service install rc=${rc}:$out"
|
|
106
|
+
UNIT="$HOME_DIR/.config/systemd/user/local.dia-job-svc.service"
|
|
107
|
+
[ -f "$UNIT" ] && ok "unit 落到 systemd --user 目录" || bad "缺 unit:$UNIT"
|
|
108
|
+
grep -q 'Restart=always' "$UNIT" && ok "Restart=always(≈ KeepAlive)" || bad "unit 缺 Restart=always"
|
|
109
|
+
grep -q "ExecStart=/bin/bash \"$ROOT_DIR/tasks/svc.sh\"" "$UNIT" && ok "ExecStart 指向 wrapper" || bad "ExecStart 错:$(grep ExecStart "$UNIT")"
|
|
110
|
+
grep -q -- '--user enable' "$SYSTEMCTL_LOG" && ok "systemctl --user enable 被调用" || bad "未 enable:$(cat "$SYSTEMCTL_LOG")"
|
|
111
|
+
[ "$(reg_field svc backend)" = systemd ] && ok "registry backend=systemd" || bad "registry backend=$(reg_field svc backend)"
|
|
112
|
+
out="$(run status 2>&1)"; printf '%s' "$out" | grep -q 'active' && ok "status 显示 active" || bad "status 未见 active:$out"
|
|
113
|
+
|
|
114
|
+
echo "── 卸载 ──"
|
|
115
|
+
run uninstall svc >/dev/null 2>&1
|
|
116
|
+
[ -f "$UNIT" ] && bad "service 卸载后 unit 仍在" || ok "service 卸载删 unit"
|
|
117
|
+
grep -q -- '--user disable' "$SYSTEMCTL_LOG" && ok "systemctl --user disable 被调用" || bad "未 disable"
|
|
118
|
+
run uninstall hello >/dev/null 2>&1
|
|
119
|
+
grep -qF '# dia-job:hello' "$CRON_TABLE" && bad "periodic 卸载后 crontab 条目仍在" || ok "periodic 卸载删条目"
|
|
120
|
+
[ -f "$ROOT_DIR/tasks/hello.sh" ] && bad "卸载后 wrapper 仍在" || ok "卸载删 wrapper"
|
|
121
|
+
[ -n "$(reg_field hello backend)" ] && bad "卸载后 registry 仍有条目" || ok "卸载删 registry 条目"
|
|
122
|
+
grep -qF '# dia-job:daily' "$CRON_TABLE" && ok "他人条目未被连带删除" || bad "误删了其它条目"
|
|
123
|
+
|
|
124
|
+
echo "── doctor(Linux 分支)──"
|
|
125
|
+
out="$(run doctor 2>&1)"; rc=$?
|
|
126
|
+
printf '%s' "$out" | grep -q 'crontab 可用' && ok "doctor 报 crontab 可用" || bad "doctor 缺 crontab 检查:$out"
|
|
127
|
+
printf '%s' "$out" | grep -q 'systemctl --user 可用' && ok "doctor 报 systemd 可用" || bad "doctor 缺 systemd 检查:$out"
|
|
128
|
+
[ "$rc" = 0 ] || [ "$rc" = 1 ] && ok "doctor 退出码在 0/1(非崩溃)" || bad "doctor rc=$rc"
|
|
129
|
+
|
|
130
|
+
echo; echo "── PASS $PASS · FAIL $FAIL ──"; [ "$FAIL" = 0 ]
|