acmer-coach 2.3.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dev/strategy-check.mjs +25 -0
- package/dev/workspace-migration-check.mjs +34 -0
- package/dev.sh +1 -1
- package/index.js +56 -15
- package/lib/strategy.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -108,7 +108,10 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
|
|
|
108
108
|
把 AC、WA/TLE/RE 和比赛分钟画在同一条时间轴上。
|
|
109
109
|
|
|
110
110
|
每个 VP 账号都放在 `~/.dsh/knowledge/vp-accounts/<user_id>/`,token 单独保存,
|
|
111
|
-
|
|
111
|
+
同步游标、原始安全投影、回放缓存和 `VP_EVENTS.yaml` 都按账号分开。个人训练数据
|
|
112
|
+
`PROGRESS.yaml`、`CURRICULUM.yaml`、`SCHEDULE.yaml`、`TARGET.yaml` 始终留在
|
|
113
|
+
`~/.dsh/knowledge/`,切换 VP 账号不会换教练。2.3.1 会把旧版本误写进账号目录的个人文件按更新时间迁回;被替换的主目录文件会留下 `.pre-vp-account-<user_id>` 备份,原账号文件不删除。
|
|
114
|
+
服务端
|
|
112
115
|
返回的账号 id 与当前账号不一致时,客户端拒绝写入。同步和回放都不返回提交代码或 judge 输出。
|
|
113
116
|
`disconnect` 只删除本地 token,保留缓存,方便重新连接同一个账号。
|
|
114
117
|
|
package/dev/strategy-check.mjs
CHANGED
|
@@ -147,6 +147,31 @@ test('invalid target priorities are rejected instead of silently dropped', () =>
|
|
|
147
147
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
148
148
|
})
|
|
149
149
|
|
|
150
|
+
test('personal target stays shared while VP events stay account-scoped', () => {
|
|
151
|
+
const personal = mkdtempSync(join(tmpdir(), 'acmer-coach-personal-'))
|
|
152
|
+
const accountA = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-a-'))
|
|
153
|
+
const accountB = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-b-'))
|
|
154
|
+
const options = {
|
|
155
|
+
competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const a = createStrategyStore({ ...options, dataDir: personal, vpDir: accountA })
|
|
159
|
+
const b = createStrategyStore({ ...options, dataDir: personal, vpDir: accountB })
|
|
160
|
+
const target = { expectedRevision: 0, contest: '西安区域赛', date: '2026-10-17', result: '保铜争银', weeklyHours: 25,
|
|
161
|
+
priorities: [{ id: 'implementation-stability', weight: 100 }] }
|
|
162
|
+
assert.equal(a.target('set', target).ok, true)
|
|
163
|
+
assert.equal(a.vpImport([{ eventId: 'account-a', contest: 'VP A', date: '2026-10-10', durationMinutes: 300,
|
|
164
|
+
problems: [{ problemId: 'A', status: 'AC' }] }]).ok, true)
|
|
165
|
+
assert.equal(b.target('read').contest, '西安区域赛')
|
|
166
|
+
assert.equal(b.vpImport([]).total, 0)
|
|
167
|
+
assert.equal(existsSync(join(personal, 'TARGET.yaml')), true)
|
|
168
|
+
assert.equal(existsSync(join(accountA, 'VP_EVENTS.yaml')), true)
|
|
169
|
+
assert.equal(existsSync(join(accountB, 'VP_EVENTS.yaml')), false)
|
|
170
|
+
} finally {
|
|
171
|
+
for (const dir of [personal, accountA, accountB]) rmSync(dir, { recursive: true, force: true })
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
150
175
|
test('corrupt stored strategy data returns a structured refusal', () => {
|
|
151
176
|
const { dir, store } = fixture()
|
|
152
177
|
try {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
test('startup migrates legacy personal files out of an active VP account', async () => {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), 'acmer-coach-workspace-'))
|
|
9
|
+
const accountDir = join(root, 'vp-accounts', '6')
|
|
10
|
+
mkdirSync(accountDir, { recursive: true })
|
|
11
|
+
writeFileSync(join(root, 'VP_ACCOUNTS.json'), JSON.stringify({
|
|
12
|
+
version: 1,
|
|
13
|
+
activeAccountId: 6,
|
|
14
|
+
accounts: { 6: { account: { id: 6, handle: 'Mianiii', display_name: 'Mianiii' }, baseUrl: 'https://example.test' } },
|
|
15
|
+
}))
|
|
16
|
+
writeFileSync(join(root, 'TARGET.yaml'), 'version: 1\nrevision: 1\nupdated: 2026-10-10T20:55:57.385Z\ncontest: 旧目标\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n')
|
|
17
|
+
writeFileSync(join(accountDir, 'TARGET.yaml'), 'version: 1\nrevision: 1\nupdated: 2026-10-11T00:51:42.901Z\ncontest: 西安区域赛\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n')
|
|
18
|
+
const oldTargetTime = new Date('2026-10-10T20:55:57.385Z')
|
|
19
|
+
const newTargetTime = new Date('2026-10-11T00:51:42.901Z')
|
|
20
|
+
const { utimesSync } = await import('node:fs')
|
|
21
|
+
utimesSync(join(root, 'TARGET.yaml'), oldTargetTime, oldTargetTime)
|
|
22
|
+
utimesSync(join(accountDir, 'TARGET.yaml'), newTargetTime, newTargetTime)
|
|
23
|
+
process.env.COACH_DATA_DIR = root
|
|
24
|
+
try {
|
|
25
|
+
await import(`../index.js?workspace-migration=${Date.now()}`)
|
|
26
|
+
assert.equal(existsSync(join(root, 'TARGET.yaml')), true)
|
|
27
|
+
assert.equal(existsSync(join(accountDir, 'TARGET.yaml')), true)
|
|
28
|
+
assert.equal(existsSync(join(root, 'TARGET.yaml.pre-vp-account-6')), true)
|
|
29
|
+
assert.match((await import('node:fs')).readFileSync(join(root, 'TARGET.yaml'), 'utf8'), /contest: 西安区域赛/)
|
|
30
|
+
} finally {
|
|
31
|
+
delete process.env.COACH_DATA_DIR
|
|
32
|
+
rmSync(root, { recursive: true, force: true })
|
|
33
|
+
}
|
|
34
|
+
})
|
package/dev.sh
CHANGED
|
@@ -35,7 +35,7 @@ done
|
|
|
35
35
|
# 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
|
|
36
36
|
# 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
|
|
37
37
|
# 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
|
|
38
|
-
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/vp-client-check.mjs docs/长期课程机制.md dev.sh'
|
|
38
|
+
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs docs/长期课程机制.md dev.sh'
|
|
39
39
|
STALE=''
|
|
40
40
|
for f in $SYNC_FILES; do
|
|
41
41
|
for d in "${DSTS[@]}"; do
|
package/index.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
|
|
24
24
|
// 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
|
|
25
25
|
// 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
|
|
26
|
-
import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
|
|
26
|
+
import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync, existsSync } from 'node:fs'
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
import { homedir } from 'node:os'
|
|
29
29
|
import { parse, stringify } from 'yaml'
|
|
@@ -36,7 +36,7 @@ import { createVpClient } from './lib/vp-client.js'
|
|
|
36
36
|
const name = 'acmer-coach'
|
|
37
37
|
const inject = ['tools']
|
|
38
38
|
|
|
39
|
-
const VERSION = '2.
|
|
39
|
+
const VERSION = '2.3.1'
|
|
40
40
|
|
|
41
41
|
// 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
42
42
|
// 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
|
|
@@ -72,30 +72,66 @@ const TEACH_FLOOR_MIN = 10
|
|
|
72
72
|
const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
|
|
73
73
|
const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
|
|
74
74
|
const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
|
|
75
|
-
let
|
|
75
|
+
let ACTIVE_VP_DIR = DATA_DIR
|
|
76
76
|
let strategy
|
|
77
77
|
let curriculum
|
|
78
|
-
function createStores(
|
|
78
|
+
function createStores(vpDir) {
|
|
79
79
|
strategy = createStrategyStore({
|
|
80
|
-
dataDir
|
|
80
|
+
dataDir: DATA_DIR, vpDir,
|
|
81
|
+
competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
|
|
81
82
|
})
|
|
82
83
|
curriculum = createCurriculumStore({
|
|
83
|
-
dataDir, loadMap, loadProgress, loadSchedule,
|
|
84
|
+
dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
|
|
84
85
|
loadStrategyEvidence: () => strategy.evidence().evidence,
|
|
85
86
|
})
|
|
86
87
|
}
|
|
87
|
-
createStores(
|
|
88
|
+
createStores(ACTIVE_VP_DIR)
|
|
89
|
+
|
|
90
|
+
const PERSONAL_FILES = ['PROGRESS.yaml', 'CURRICULUM.yaml', 'SCHEDULE.yaml', 'TARGET.yaml']
|
|
91
|
+
|
|
92
|
+
// 2.3.0 曾把个人文件误写进 VP 账号目录。迁移时按 mtime 取较新的一份;
|
|
93
|
+
// 被替换的主目录文件留备份,原账号目录也保留,方便人工核对和恢复。
|
|
94
|
+
function migratePersonalWorkspace(accountId) {
|
|
95
|
+
if (!accountId) return []
|
|
96
|
+
const sourceDir = join(DATA_DIR, 'vp-accounts', String(accountId))
|
|
97
|
+
if (!existsSync(sourceDir)) return []
|
|
98
|
+
const migrated = []
|
|
99
|
+
for (const file of PERSONAL_FILES) {
|
|
100
|
+
const from = join(sourceDir, file)
|
|
101
|
+
const to = join(DATA_DIR, file)
|
|
102
|
+
if (!existsSync(from)) continue
|
|
103
|
+
if (!existsSync(to)) {
|
|
104
|
+
copyFileSync(from, to)
|
|
105
|
+
migrated.push(file)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const sourceStat = statSync(from)
|
|
109
|
+
const targetStat = statSync(to)
|
|
110
|
+
if (sourceStat.mtimeMs <= targetStat.mtimeMs) continue
|
|
111
|
+
const sourceBody = readFileSync(from)
|
|
112
|
+
const targetBody = readFileSync(to)
|
|
113
|
+
if (sourceBody.equals(targetBody)) continue
|
|
114
|
+
const backup = `${to}.pre-vp-account-${String(accountId)}`
|
|
115
|
+
if (!existsSync(backup)) copyFileSync(to, backup)
|
|
116
|
+
copyFileSync(from, to)
|
|
117
|
+
migrated.push(`${file}(更新)`)
|
|
118
|
+
}
|
|
119
|
+
return migrated
|
|
120
|
+
}
|
|
88
121
|
|
|
89
122
|
function switchCoachWorkspace(accountId = '') {
|
|
90
|
-
|
|
91
|
-
mkdirSync(
|
|
92
|
-
createStores(
|
|
93
|
-
return
|
|
123
|
+
ACTIVE_VP_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
|
|
124
|
+
mkdirSync(ACTIVE_VP_DIR, { recursive: true })
|
|
125
|
+
createStores(ACTIVE_VP_DIR)
|
|
126
|
+
return ACTIVE_VP_DIR
|
|
94
127
|
}
|
|
95
128
|
|
|
96
129
|
const vpClient = createVpClient({ dataDir: DATA_DIR })
|
|
97
130
|
const savedVp = vpClient.localStatus()
|
|
98
|
-
if (savedVp.connected)
|
|
131
|
+
if (savedVp.connected) {
|
|
132
|
+
migratePersonalWorkspace(savedVp.activeAccountId)
|
|
133
|
+
switchCoachWorkspace(savedVp.activeAccountId)
|
|
134
|
+
}
|
|
99
135
|
|
|
100
136
|
// 探针要的文件,各自代表一条后面的依赖:
|
|
101
137
|
// MAP.yaml 地图(coach_next 读它算候选)
|
|
@@ -187,7 +223,7 @@ function loadMap() {
|
|
|
187
223
|
//
|
|
188
224
|
// 游标显式存,不靠"已验证的最大深度"推 —— 推出来的东西没法手改,
|
|
189
225
|
// 而且他想复习某节时游标要能停在那儿。
|
|
190
|
-
const PROGRESS_PATH = () => join(
|
|
226
|
+
const PROGRESS_PATH = () => join(DATA_DIR, 'PROGRESS.yaml')
|
|
191
227
|
|
|
192
228
|
function emptyProgress() {
|
|
193
229
|
return { version: 1, cursor: '', updated: '', nodes: {}, pending: null }
|
|
@@ -326,7 +362,7 @@ function saveProgress(p) {
|
|
|
326
362
|
//
|
|
327
363
|
// `actual` 是**校准真实做题速度**的唯一来源 —— 计划 60 分钟实际 95 分钟,
|
|
328
364
|
// 攒几周才知道他的速度到底是多少。这条现在一条数据都没有,所以排程只能保守。
|
|
329
|
-
const SCHEDULE_PATH = () => join(
|
|
365
|
+
const SCHEDULE_PATH = () => join(DATA_DIR, 'SCHEDULE.yaml')
|
|
330
366
|
|
|
331
367
|
function emptySchedule() {
|
|
332
368
|
return { version: 1, updated: '', days: {} }
|
|
@@ -1539,7 +1575,11 @@ function registerPages(ctx) {
|
|
|
1539
1575
|
try {
|
|
1540
1576
|
let result
|
|
1541
1577
|
if (payload.op === 'start') result = await vpClient.start({ baseUrl: payload.baseUrl })
|
|
1542
|
-
else if (payload.op === 'finish') {
|
|
1578
|
+
else if (payload.op === 'finish') {
|
|
1579
|
+
result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl })
|
|
1580
|
+
migratePersonalWorkspace(result.activeAccountId)
|
|
1581
|
+
switchCoachWorkspace(result.activeAccountId)
|
|
1582
|
+
}
|
|
1543
1583
|
else if (payload.op === 'disconnect') { result = vpClient.disconnect(); switchCoachWorkspace('') }
|
|
1544
1584
|
else if (payload.op === 'sync') result = await vpClient.sync({})
|
|
1545
1585
|
else return sendJson(res, 400, { ok: false, reject: 'op 只能是 start/finish/disconnect/sync' })
|
|
@@ -1953,6 +1993,7 @@ function apply(ctx) {
|
|
|
1953
1993
|
if (action === 'start') result = await vpClient.start({ baseUrl: base })
|
|
1954
1994
|
else if (action === 'finish') {
|
|
1955
1995
|
result = await vpClient.finish({ code: args?.code, baseUrl: base })
|
|
1996
|
+
migratePersonalWorkspace(result.activeAccountId)
|
|
1956
1997
|
switchCoachWorkspace(result.activeAccountId)
|
|
1957
1998
|
} else if (action === 'disconnect') {
|
|
1958
1999
|
result = vpClient.disconnect()
|
package/lib/strategy.js
CHANGED
|
@@ -105,9 +105,9 @@ function normalizePostmortem(value) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
|
|
108
|
+
export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath, now = () => new Date() }) {
|
|
109
109
|
const targetPath = join(dataDir, 'TARGET.yaml')
|
|
110
|
-
const vpPath = join(
|
|
110
|
+
const vpPath = join(vpDir, 'VP_EVENTS.yaml')
|
|
111
111
|
const validateTarget = (doc) => {
|
|
112
112
|
const errors = []
|
|
113
113
|
if (doc.version !== 1) errors.push('version 必须是 1')
|
package/package.json
CHANGED