agentworkshop 0.5.3 → 0.5.5

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.
@@ -1,22 +1,4 @@
1
1
  [
2
- {
3
- "id": "rr-57bca111",
4
- "recipeId": "rc-ee30a7a0",
5
- "recipeName": "终审配方A",
6
- "productId": "pd-99159b45",
7
- "lineId": "ln-c6713e4d",
8
- "startedAt": "2026-08-29T15:13:03.707Z",
9
- "endedAt": "2026-08-29T15:14:14.609Z",
10
- "results": [
11
- {
12
- "templateRef": "dcw-temp-sp",
13
- "nodeId": "dw-c4da380a",
14
- "ok": true,
15
- "message": "写入并回读一致:182 → raw 1280,回读 182(标定后物理值 182)",
16
- "value": 182
17
- }
18
- ]
19
- },
20
2
  {
21
3
  "id": "rr-c8ccb1f5",
22
4
  "recipeId": "rc-3b094c50",
@@ -4731,5 +4713,32 @@
4731
4713
  "max": 188
4732
4714
  }
4733
4715
  ]
4716
+ },
4717
+ {
4718
+ "id": "rr-09acb43b",
4719
+ "recipeId": "rc-4d460065",
4720
+ "recipeName": "全功能配方-41bdm2",
4721
+ "productId": "pd-33f3607e",
4722
+ "lineId": "ln-69eaecb9",
4723
+ "startedAt": "2026-09-02T10:37:44.029Z",
4724
+ "endedAt": "2026-09-02T10:43:20.233Z",
4725
+ "results": [
4726
+ {
4727
+ "templateRef": "dcw-temp-sp",
4728
+ "nodeId": "dw-eeedb292",
4729
+ "ok": true,
4730
+ "message": "Mock PLC 写入成功:180 → raw 180,回读一致(标定后物理值 180)",
4731
+ "value": 180
4732
+ }
4733
+ ],
4734
+ "paramsSnapshot": [
4735
+ {
4736
+ "nodeId": "dw-eeedb292",
4737
+ "templateRef": "dcw-temp-sp",
4738
+ "value": 180,
4739
+ "min": 176,
4740
+ "max": 188
4741
+ }
4742
+ ]
4734
4743
  }
4735
4744
  ]
@@ -9862,5 +9862,53 @@
9862
9862
  "message": "Mock PLC 写入成功:182 → raw 182,回读一致(标定后物理值 182)",
9863
9863
  "recipeRunId": null,
9864
9864
  "at": "2026-09-02T08:46:51.079Z"
9865
+ },
9866
+ {
9867
+ "id": "wh-4b83484b",
9868
+ "nodeId": "dw-eeedb292",
9869
+ "nodeName": "FFX-温度设定",
9870
+ "param": "烘箱温度设定",
9871
+ "eng": 180,
9872
+ "raw": 180,
9873
+ "ok": true,
9874
+ "message": "Mock PLC 写入成功:180 → raw 180,回读一致(标定后物理值 180)",
9875
+ "recipeRunId": "rr-09acb43b",
9876
+ "at": "2026-09-02T10:37:44.035Z"
9877
+ },
9878
+ {
9879
+ "id": "wh-515c6f27",
9880
+ "nodeId": "dw-eeedb292",
9881
+ "nodeName": "FFX-温度设定",
9882
+ "param": "烘箱温度设定",
9883
+ "eng": 182,
9884
+ "raw": 182,
9885
+ "ok": true,
9886
+ "message": "Mock PLC 写入成功:182 → raw 182,回读一致(标定后物理值 182)",
9887
+ "recipeRunId": null,
9888
+ "at": "2026-09-02T10:37:51.026Z"
9889
+ },
9890
+ {
9891
+ "id": "wh-f337d292",
9892
+ "nodeId": "dw-eeedb292",
9893
+ "nodeName": "FFX-温度设定",
9894
+ "param": "烘箱温度设定",
9895
+ "eng": 180,
9896
+ "raw": 180,
9897
+ "ok": true,
9898
+ "message": "Mock PLC 写入成功:180 → raw 180,回读一致(标定后物理值 180)",
9899
+ "recipeRunId": null,
9900
+ "at": "2026-09-02T10:37:51.168Z"
9901
+ },
9902
+ {
9903
+ "id": "wh-f7919063",
9904
+ "nodeId": "dw-eeedb292",
9905
+ "nodeName": "FFX-温度设定",
9906
+ "param": "烘箱温度设定",
9907
+ "eng": 182,
9908
+ "raw": 182,
9909
+ "ok": true,
9910
+ "message": "Mock PLC 写入成功:182 → raw 182,回读一致(标定后物理值 182)",
9911
+ "recipeRunId": null,
9912
+ "at": "2026-09-02T10:39:20.083Z"
9865
9913
  }
9866
9914
  ]
@@ -2026,5 +2026,26 @@
2026
2026
  "state": "ok",
2027
2027
  "lastError": null,
2028
2028
  "createdAt": "2026-09-02T08:44:01.557Z"
2029
+ },
2030
+ {
2031
+ "id": "dw-eeedb292",
2032
+ "templateRef": "dcw-temp-sp",
2033
+ "name": "FFX-温度设定",
2034
+ "driver": "mock",
2035
+ "enabled": true,
2036
+ "holdIntervalMs": null,
2037
+ "unit": "℃",
2038
+ "decimals": 1,
2039
+ "min": 150,
2040
+ "max": 200,
2041
+ "deviceBindingId": null,
2042
+ "driverConfig": {},
2043
+ "lineId": "ln-69eaecb9",
2044
+ "value": 182,
2045
+ "lastAckAt": "2026-09-02T10:39:20.083Z",
2046
+ "lastWriteAt": "2026-09-02T10:39:20.083Z",
2047
+ "state": "ok",
2048
+ "lastError": null,
2049
+ "createdAt": "2026-09-02T10:37:43.697Z"
2029
2050
  }
2030
2051
  ]
@@ -7,7 +7,7 @@
7
7
  "productId": "pd-3ad54846",
8
8
  "productName": "光学膜 BOPP",
9
9
  "startedAt": "2026-08-31T16:20:13.707Z",
10
- "taggedSamples": 33288
10
+ "taggedSamples": 34566
11
11
  },
12
12
  {
13
13
  "lineId": "ln-c377a6bf",
@@ -17,7 +17,7 @@
17
17
  "productId": "pd-7e69e0ee",
18
18
  "productName": "警告验证产品-k2xh",
19
19
  "startedAt": "2026-09-01T03:23:18.233Z",
20
- "taggedSamples": 13453
20
+ "taggedSamples": 14092
21
21
  },
22
22
  {
23
23
  "lineId": "ln-9224d3d7",
@@ -27,7 +27,7 @@
27
27
  "productId": "pd-910c0fa8",
28
28
  "productName": "新协议产品-9xhn",
29
29
  "startedAt": "2026-09-01T05:59:24.062Z",
30
- "taggedSamples": 16433
30
+ "taggedSamples": 17074
31
31
  },
32
32
  {
33
33
  "lineId": "ln-386c53ed",
@@ -37,7 +37,7 @@
37
37
  "productId": "pd-2d4f9357",
38
38
  "productName": "新协议产品-6ofk",
39
39
  "startedAt": "2026-09-01T06:05:53.063Z",
40
- "taggedSamples": 16722
40
+ "taggedSamples": 17363
41
41
  },
42
42
  {
43
43
  "lineId": "ln-5957f6db",
@@ -47,7 +47,7 @@
47
47
  "productId": "pd-a3ed5047",
48
48
  "productName": "新协议产品-x4hd",
49
49
  "startedAt": "2026-09-01T06:09:02.148Z",
50
- "taggedSamples": 16308
50
+ "taggedSamples": 16949
51
51
  },
52
52
  {
53
53
  "lineId": "ln-9ec42f08",
@@ -57,7 +57,7 @@
57
57
  "productId": "pd-5de729c0",
58
58
  "productName": "新协议产品-teti",
59
59
  "startedAt": "2026-09-01T06:13:44.315Z",
60
- "taggedSamples": 16287
60
+ "taggedSamples": 16928
61
61
  },
62
62
  {
63
63
  "lineId": "ln-69e37924",
@@ -67,7 +67,7 @@
67
67
  "productId": "pd-45eb8d43",
68
68
  "productName": "新协议产品-fg8t",
69
69
  "startedAt": "2026-09-01T06:16:47.469Z",
70
- "taggedSamples": 16320
70
+ "taggedSamples": 16961
71
71
  },
72
72
  {
73
73
  "lineId": "ln-c269e692",
@@ -77,7 +77,7 @@
77
77
  "productId": "pd-b3bd0191",
78
78
  "productName": "新协议产品-w2sp",
79
79
  "startedAt": "2026-09-01T06:56:05.893Z",
80
- "taggedSamples": 16371
80
+ "taggedSamples": 17012
81
81
  },
82
82
  {
83
83
  "lineId": "ln-229d1637",
@@ -87,7 +87,7 @@
87
87
  "productId": "pd-06b896f9",
88
88
  "productName": "新协议产品-lp4h",
89
89
  "startedAt": "2026-09-01T06:57:29.288Z",
90
- "taggedSamples": 16243
90
+ "taggedSamples": 16884
91
91
  },
92
92
  {
93
93
  "lineId": "ln-9546f91f",
@@ -97,7 +97,7 @@
97
97
  "productId": "pd-53e55078",
98
98
  "productName": "新协议产品-jri6",
99
99
  "startedAt": "2026-09-01T07:00:25.059Z",
100
- "taggedSamples": 15666
100
+ "taggedSamples": 16307
101
101
  },
102
102
  {
103
103
  "lineId": "ln-087aad5b",
@@ -107,7 +107,7 @@
107
107
  "productId": "pd-e7e969af",
108
108
  "productName": "新协议产品-4c2d",
109
109
  "startedAt": "2026-09-01T07:09:07.307Z",
110
- "taggedSamples": 14657
110
+ "taggedSamples": 15299
111
111
  },
112
112
  {
113
113
  "lineId": "ln-97d44f8a",
@@ -117,7 +117,7 @@
117
117
  "productId": "pd-28a3ab2c",
118
118
  "productName": "新协议产品-b2ni",
119
119
  "startedAt": "2026-09-01T07:13:44.912Z",
120
- "taggedSamples": 14506
120
+ "taggedSamples": 15148
121
121
  },
122
122
  {
123
123
  "lineId": "ln-a9551770",
@@ -127,7 +127,7 @@
127
127
  "productId": "pd-0c6a64c8",
128
128
  "productName": "新协议产品-uwkf",
129
129
  "startedAt": "2026-09-01T07:15:40.113Z",
130
- "taggedSamples": 14151
130
+ "taggedSamples": 14793
131
131
  },
132
132
  {
133
133
  "lineId": "ln-844ad82f",
@@ -137,7 +137,7 @@
137
137
  "productId": "pd-6932b24f",
138
138
  "productName": "新协议产品-kk6l",
139
139
  "startedAt": "2026-09-01T09:06:55.728Z",
140
- "taggedSamples": 12306
140
+ "taggedSamples": 12948
141
141
  },
142
142
  {
143
143
  "lineId": "ln-af002514",
@@ -147,7 +147,7 @@
147
147
  "productId": "pd-6348a595",
148
148
  "productName": "0.8mm 光学膜",
149
149
  "startedAt": "2026-09-01T14:06:28.766Z",
150
- "taggedSamples": 4488
150
+ "taggedSamples": 5772
151
151
  },
152
152
  {
153
153
  "lineId": "ln-0eccf6ba",
@@ -157,7 +157,7 @@
157
157
  "productId": "pd-4b71be77",
158
158
  "productName": "哨兵产品-mtjku36w",
159
159
  "startedAt": "2026-09-02T04:09:11.482Z",
160
- "taggedSamples": 891
160
+ "taggedSamples": 1213
161
161
  },
162
162
  {
163
163
  "lineId": "ln-e984246e",
@@ -167,7 +167,7 @@
167
167
  "productId": "pd-bb1e0bed",
168
168
  "productName": "产品C-mtjkwyhu",
169
169
  "startedAt": "2026-09-02T04:11:26.583Z",
170
- "taggedSamples": 890
170
+ "taggedSamples": 1212
171
171
  },
172
172
  {
173
173
  "lineId": "ln-1fe0b5bc",
@@ -177,7 +177,7 @@
177
177
  "productId": "pd-9f2d9507",
178
178
  "productName": "产品D-mtjkzcfr",
179
179
  "startedAt": "2026-09-02T04:13:17.902Z",
180
- "taggedSamples": 889
180
+ "taggedSamples": 1211
181
181
  },
182
182
  {
183
183
  "lineId": "ln-d826473e",
@@ -187,7 +187,7 @@
187
187
  "productId": "pd-d230255e",
188
188
  "productName": "产品E-mtjl43pv",
189
189
  "startedAt": "2026-09-02T04:16:59.891Z",
190
- "taggedSamples": 888
190
+ "taggedSamples": 1210
191
191
  },
192
192
  {
193
193
  "lineId": "ln-156abb71",
@@ -197,7 +197,7 @@
197
197
  "productId": "pd-6065b4ba",
198
198
  "productName": "产品F-mtjl60qf",
199
199
  "startedAt": "2026-09-02T04:18:29.340Z",
200
- "taggedSamples": 887
200
+ "taggedSamples": 1209
201
201
  },
202
202
  {
203
203
  "lineId": "ln-bac43927",
@@ -207,7 +207,7 @@
207
207
  "productId": "pd-52f54e33",
208
208
  "productName": "产品G-mtjl98nl",
209
209
  "startedAt": "2026-09-02T04:20:59.682Z",
210
- "taggedSamples": 886
210
+ "taggedSamples": 1208
211
211
  },
212
212
  {
213
213
  "lineId": "ln-f223008a",
@@ -217,6 +217,6 @@
217
217
  "productId": "pd-d919d4bf",
218
218
  "productName": "产品-mtjo000v",
219
219
  "startedAt": "2026-09-02T05:37:50.743Z",
220
- "taggedSamples": 885
220
+ "taggedSamples": 1207
221
221
  }
222
222
  ]
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * 备份定时插件(S6,production-readiness-plan)。
3
3
  *
4
- * 对 data/ 下三库(workshop/users/daq-timeseries)每日 VACUUM INTO 快照到
4
+ * 对 data/ 下三库(workshop/users/daq-timeseries)每日 serialize 镜像快照到
5
5
  * data/backups/,按 BACKUP_KEEP(默认 7)轮转;调度模式复用 ws.ts 保留期清理的
6
6
  * setInterval + globalThis key 防 HMR 重复 + unref()。dev 与生产均生效
7
7
  * (备份无副作用,始终开启;BACKUP_DISABLED=1 可关)。
8
8
  */
9
9
  import { DatabaseSync } from 'node:sqlite'
10
- import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
10
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
11
11
  import { resolve } from 'node:path'
12
12
  import { ensureDataDir } from '@/shared/config/home.mjs'
13
13
 
@@ -15,7 +15,23 @@ const g = globalThis as typeof globalThis & { __awBackupTimer?: NodeJS.Timeout,
15
15
 
16
16
  const DB_FILES = ['workshop.sqlite', 'users.sqlite', 'daq-timeseries.sqlite'] as const
17
17
 
18
- function backupOnce(dataDir: string): void {
18
+ /**
19
+ * 单库在线快照(hardening ST-2):node:sqlite serialize() 一致性数据库镜像 → 落盘。
20
+ * 纯读操作,不与主写者抢锁(VACUUM INTO 会与写事务竞争 SQLITE_BUSY,已实测);
21
+ * 产物为标准 SQLite 文件,与历史 .bak 结构兼容。失败只记录不抛出。
22
+ */
23
+ async function backupOne(src: string, target: string): Promise<void> {
24
+ const db = new DatabaseSync(src)
25
+ try {
26
+ const image = db.serialize()
27
+ writeFileSync(target, image)
28
+ }
29
+ finally {
30
+ db.close()
31
+ }
32
+ }
33
+
34
+ async function backupOnce(dataDir: string): Promise<void> {
19
35
  const backupDir = resolve(dataDir, 'backups')
20
36
  mkdirSync(backupDir, { recursive: true })
21
37
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
@@ -24,14 +40,8 @@ function backupOnce(dataDir: string): void {
24
40
  if (!existsSync(src)) continue
25
41
  const target = resolve(backupDir, `${file}.${stamp}.bak`)
26
42
  try {
27
- const db = new DatabaseSync(src)
28
- try {
29
- // VACUUM INTO:在线一致性快照(WAL 下安全),不阻塞长事务
30
- db.exec(`VACUUM INTO '${target.replace(/\\/g, '/')}'`)
31
- }
32
- finally {
33
- db.close()
34
- }
43
+ await backupOne(src, target)
44
+ console.log('[backup]', file, '快照完成(serialize)')
35
45
  }
36
46
  catch (err) {
37
47
  console.error(`[backup] ${file} 快照失败:`, err instanceof Error ? err.message : err)
@@ -7,6 +7,9 @@
7
7
  * 依赖异常中止进程,绝不能被护栏吞掉。
8
8
  */
9
9
  const SOCKET_NOISE = /ECONNRESET|EPIPE|ECONNABORTED|ETIMEDOUT|ERR_STREAM_PREMATURE_CLOSE/
10
+ // SQLite 忙/锁(hardening ST-1):多写竞争的瞬态错误,记录并继续;
11
+ // 真正的库损坏/磁盘错误不经此路径,仍走致命语义。
12
+ const SQLITE_BUSY = /SQLITE_BUSY|SQLITE_LOCKED|database is locked/i
10
13
 
11
14
  function isSocketNoise(reason: unknown): boolean {
12
15
  if (reason == null) return false
@@ -22,11 +25,16 @@ function describe(reason: unknown): string {
22
25
  }
23
26
 
24
27
  export default defineNitroPlugin(() => {
28
+ const isTransientDbBusy = (reason: unknown): boolean => SQLITE_BUSY.test(describe(reason))
25
29
  process.on('unhandledRejection', (reason) => {
26
30
  if (isSocketNoise(reason)) {
27
31
  console.warn(`[stability-guard] socket noise suppressed: ${describe(reason).slice(0, 120)}`)
28
32
  return
29
33
  }
34
+ if (isTransientDbBusy(reason)) {
35
+ console.warn(`[stability-guard] sqlite busy(瞬态,不退出): ${describe(reason).slice(0, 120)}`)
36
+ return
37
+ }
30
38
  console.error(`[stability-guard] fatal unhandledRejection, exiting:\n${describe(reason)}`)
31
39
  process.exit(1)
32
40
  })
@@ -35,8 +43,12 @@ export default defineNitroPlugin(() => {
35
43
  console.warn(`[stability-guard] socket noise suppressed: ${describe(err).slice(0, 120)}`)
36
44
  return
37
45
  }
46
+ if (isTransientDbBusy(err)) {
47
+ console.warn(`[stability-guard] sqlite busy(瞬态,不退出): ${describe(err).slice(0, 120)}`)
48
+ return
49
+ }
38
50
  console.error(`[stability-guard] fatal uncaughtException, exiting:\n${describe(err)}`)
39
51
  process.exit(1)
40
52
  })
41
- console.warn('[stability-guard] armed (socket disconnects → log & continue; real errors → exit 1)')
53
+ console.warn('[stability-guard] armed (socket 断连与 sqlite 瞬态忙 → 记录继续;真实错误 → exit 1)')
42
54
  })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 保留期定时插件(hardening GROW-1)。
3
+ *
4
+ * 每 24h 对 workshop.sqlite 的 messages/audit_log/approval_history 做分批保留
5
+ * 清理(策略见 db/retention.ts;RETENTION_DISABLED=1 可整体关闭)。
6
+ * 调度模式同 backup.ts:setInterval + globalThis key 防 HMR 重复 + unref()。
7
+ * 启动 90s 后首跑(避开启动风暴,且错峰于 backup 的 30s 首备)。
8
+ */
9
+ import { DatabaseSync } from 'node:sqlite'
10
+ import { existsSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+ import { runRetentionSweep } from '@/server/services/workshop/db/retention'
13
+ import { ensureDataDir } from '@/shared/config/home.mjs'
14
+
15
+ const g = globalThis as typeof globalThis & { __awRetentionTimer?: NodeJS.Timeout }
16
+
17
+ function sweepOnce(dataDir: string): void {
18
+ const dbPath = resolve(dataDir, 'workshop.sqlite')
19
+ if (!existsSync(dbPath)) return
20
+ const db = new DatabaseSync(dbPath)
21
+ try {
22
+ const results = runRetentionSweep(db)
23
+ for (const r of results) {
24
+ if (r.removed > 0) console.log(`[retention] ${r.table} 清理 ${r.removed} 行(>${r.days}d)`)
25
+ }
26
+ }
27
+ finally {
28
+ db.close()
29
+ }
30
+ }
31
+
32
+ export default function retentionPlugin() {
33
+ if (process.env.RETENTION_DISABLED === '1' || process.env.AW_RETENTION_DISABLED === '1') return
34
+ if (g.__awRetentionTimer) return
35
+ const dataDir = ensureDataDir()
36
+
37
+ const first = setTimeout(() => {
38
+ try {
39
+ sweepOnce(dataDir)
40
+ }
41
+ catch (err) {
42
+ console.error('[retention] 首轮清理失败:', err instanceof Error ? err.message : err)
43
+ }
44
+ }, 90_000)
45
+ first.unref?.()
46
+
47
+ const timer = setInterval(() => {
48
+ try {
49
+ sweepOnce(dataDir)
50
+ }
51
+ catch (err) {
52
+ console.error('[retention] 定时清理失败:', err instanceof Error ? err.message : err)
53
+ }
54
+ }, 24 * 3_600_000)
55
+ timer.unref?.()
56
+ g.__awRetentionTimer = timer
57
+ }
@@ -476,12 +476,14 @@ class DaqController {
476
476
  controllerState(): AepDaqControllerState & { produced?: number, consumed?: number, dropped?: number, samplesStored?: number, tsdbDropped?: number } {
477
477
  // 丢弃 = 队列层真实丢弃(inproc 拥塞/mqtt 断连)+ 消费侧乱序迟到帧(诚实可见)
478
478
  const queueLost = g_queueLost()
479
+ // 单次遍历(hardening PERF-1):status 高频路径不再对全表多次 all()
480
+ const nodes = this.repo.all()
479
481
  return {
480
482
  running: this.running,
481
483
  defaultIntervalMs: this.defaultIntervalMs,
482
484
  defaultPublishIntervalMs: this.defaultPublishIntervalMs,
483
- nodesTotal: this.repo.all().length,
484
- nodesOnline: this.onlineCount(),
485
+ nodesTotal: nodes.length,
486
+ nodesOnline: this.running ? nodes.filter(n => n.enabled).length : 0,
485
487
  produced: this.producedCount,
486
488
  consumed: this.consumedCount,
487
489
  dropped: Math.max(0, this.producedCount - this.consumedCount) + queueLost + this.lateDropped,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 增长治理保留策略(hardening GROW-1)。
3
+ *
4
+ * 为无保留机制的只增表做分批清理(每批 ≤5000,循环到 0,避免长事务持锁):
5
+ * messages 默认 30d (AW_MESSAGES_RETENTION_D)
6
+ * audit_log 默认 90d (AW_AUDIT_RETENTION_D;合规观察期,从严保留)
7
+ * approval_history 默认 180d (AW_APPROVAL_RETENTION_D;历史裁决主张不可变,放宽)
8
+ * AW_RETENTION_DISABLED=1 整体逃生门。时间列均为 TEXT ISO,可字典序比较。
9
+ * channel_events 与 daq_samples 已有各自保留机制,此处不重复覆盖。
10
+ */
11
+ type DatabaseSyncLike = {
12
+ prepare: (sql: string) => { run: (...args: unknown[]) => { changes: number | bigint } }
13
+ }
14
+
15
+ const DAY_MS = 86_400_000
16
+ const BATCH = 5000
17
+
18
+ const TARGETS = [
19
+ { table: 'messages', column: 'created_at', days: 30, envKey: 'AW_MESSAGES_RETENTION_D' },
20
+ { table: 'audit_log', column: 'at', days: 90, envKey: 'AW_AUDIT_RETENTION_D' },
21
+ { table: 'approval_history', column: 'created_at', days: 180, envKey: 'AW_APPROVAL_RETENTION_D' },
22
+ ] as const
23
+
24
+ function retentionDays(envKey: string, fallback: number): number {
25
+ const n = Number(process.env[envKey])
26
+ return Number.isFinite(n) && n > 0 ? n : fallback
27
+ }
28
+
29
+ export interface RetentionResult { table: string, removed: number, days: number }
30
+
31
+ /** 对单库执行一轮保留清理;返回各表删除行数(调用方负责 try/catch 与日志)。 */
32
+ export function runRetentionSweep(db: DatabaseSyncLike): RetentionResult[] {
33
+ if (process.env.AW_RETENTION_DISABLED === '1') return []
34
+ const out: RetentionResult[] = []
35
+ for (const t of TARGETS) {
36
+ const days = retentionDays(t.envKey, t.days)
37
+ const before = new Date(Date.now() - days * DAY_MS).toISOString()
38
+ let removed = 0
39
+ for (;;) {
40
+ const res = db.prepare(
41
+ `DELETE FROM ${t.table} WHERE rowid IN (SELECT rowid FROM ${t.table} WHERE ${t.column} < ? LIMIT ${BATCH})`,
42
+ ).run(before)
43
+ const n = Number(res.changes)
44
+ removed += n
45
+ if (n < BATCH) break
46
+ }
47
+ out.push({ table: t.table, removed, days })
48
+ }
49
+ return out
50
+ }
@@ -218,10 +218,12 @@ class DcwController {
218
218
  }
219
219
 
220
220
  controllerState() {
221
+ // 单次遍历(hardening PERF-1):status 高频路径不再对全表多次 all()
222
+ const nodes = this.repo.all()
221
223
  return {
222
224
  running: this.running,
223
- nodesTotal: this.repo.all().length,
224
- nodesOnline: this.running ? this.repo.all().filter(n => n.enabled).length : 0,
225
+ nodesTotal: nodes.length,
226
+ nodesOnline: this.running ? nodes.filter(n => n.enabled).length : 0,
225
227
  writesTotal: this.writesTotal,
226
228
  writesFailed: this.writesFailed,
227
229
  }
@@ -7,11 +7,18 @@
7
7
  * - 缓存:mtime 感知(文件修改即刻生效,dev/HMR 友好;stat 开销可忽略)
8
8
  * - host-tools.json:结构化工具定义(name/label/description/parameters),
9
9
  * JSON 承载 schema,与叙事型 md 并置同一目录统一管理
10
- * - 目录解析:AW_PROMPTS_DIR 环境变量 → process.cwd()/.AgentWorkShop/prompts;
10
+ * - 目录解析(hardening 后的规约,与配置根优先级同族):
11
+ * ① AW_PROMPTS_DIR 环境变量(显式覆盖,兼容旧约定)
12
+ * ② 项目内 <cwd 向上>/.AgentWorkShop/prompts 真实存在 → 项目内优先
13
+ * ③ 否则 ~/.AgentWorkShop/prompts —— 首次解析时从包内资产播种初始文件
14
+ * (只补缺失文件,绝不覆盖已有内容 → 用户在 Home 的定制即权威版本);
15
+ * 播种源 = AW_PACKAGE_ROOT(启动器注入的包根)/.AgentWorkShop/prompts
11
16
  * 启动即校验(缺文件 fail-fast,防静默降级为无 prompt 裸跑)
12
17
  */
13
- import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs'
14
- import { join, resolve } from 'node:path'
18
+ import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, mkdirSync } from 'node:fs'
19
+ import { dirname, join, resolve } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { awHome, findLocalConfigRoot } from '../../../../shared/config/home.mjs'
15
22
  import { AppError } from '../../../utils/errors'
16
23
 
17
24
  /** 模板变量值(字符串化注入) */
@@ -24,15 +31,61 @@ interface CacheEntry {
24
31
 
25
32
  const cache = new Map<string, CacheEntry>()
26
33
 
27
- /** prompts 目录(AW_PROMPTS_DIR 覆盖 → cwd 相对) */
34
+ /** 包内资产 prompts 目录(播种源;不存在返回 null) */
35
+ function packagedPromptsDir(): string | null {
36
+ const packageRoot = process.env.AW_PACKAGE_ROOT
37
+ ? resolve(process.env.AW_PACKAGE_ROOT)
38
+ : resolve(dirname(fileURLToPath(import.meta.url)), '../../../..')
39
+ const candidate = join(packageRoot, '.AgentWorkShop', 'prompts')
40
+ return existsSync(candidate) ? candidate : null
41
+ }
42
+
43
+ /** 首次解析时把包内初始 prompts 播种到 ~/.AgentWorkShop/prompts(只补缺,不覆盖) */
44
+ function seedHomePrompts(homePrompts: string): void {
45
+ const src = packagedPromptsDir()
46
+ if (!src || resolve(src) === resolve(homePrompts)) return
47
+ mkdirSync(homePrompts, { recursive: true })
48
+ let seeded = 0
49
+ for (const f of readdirSync(src)) {
50
+ const dst = join(homePrompts, f)
51
+ if (existsSync(dst)) continue
52
+ try {
53
+ copyFileSync(join(src, f), dst)
54
+ seeded++
55
+ }
56
+ catch { /* 单文件失败不阻断播种 */ }
57
+ }
58
+ if (seeded > 0) console.log(`[prompts] 已向 ${homePrompts} 播种 ${seeded} 个初始提示词文件`)
59
+ }
60
+
61
+ /**
62
+ * prompts 目录解析:显式覆盖 > 项目内(存在即用) > ~/.AgentWorkShop/prompts(自动播种)。
63
+ * 失败 fail-fast —— 外置 prompt 为必需,防静默降级为无 prompt 裸跑。
64
+ */
28
65
  export function promptsDir(): string {
29
- const base = process.env.AW_PROMPTS_DIR
30
- ? resolve(process.env.AW_PROMPTS_DIR)
31
- : resolve(process.cwd(), '.AgentWorkShop', 'prompts')
32
- if (!existsSync(base)) {
33
- throw new AppError(500, 'PROMPTS_DIR_MISSING', `prompts 目录不存在: ${base}(外置 prompt 为必需;可用 AW_PROMPTS_DIR 指定)`)
66
+ // ① 显式覆盖(兼容既有部署约定)
67
+ if (process.env.AW_PROMPTS_DIR) {
68
+ const base = resolve(process.env.AW_PROMPTS_DIR)
69
+ if (!existsSync(base)) {
70
+ throw new AppError(500, 'PROMPTS_DIR_MISSING', `prompts 目录不存在: ${base}(AW_PROMPTS_DIR 指定)`)
71
+ }
72
+ return base
73
+ }
74
+
75
+ // ~/.AgentWorkShop/prompts:默认兜底目录,首次解析自动播种初始文件(只补缺,不覆盖用户定制)
76
+ const homePrompts = join(awHome(process.env), 'prompts')
77
+ if (!existsSync(homePrompts)) seedHomePrompts(homePrompts)
78
+
79
+ // ② 项目内 ./.AgentWorkShop/prompts 真实存在 → 项目内优先
80
+ const localConfigRoot = findLocalConfigRoot(process.cwd())
81
+ if (localConfigRoot) {
82
+ const candidate = join(localConfigRoot, 'prompts')
83
+ if (existsSync(candidate)) return candidate
34
84
  }
35
- return base
85
+
86
+ // ③ ~/.AgentWorkShop/prompts
87
+ if (existsSync(homePrompts)) return homePrompts
88
+ throw new AppError(500, 'PROMPTS_DIR_MISSING', `prompts 目录不存在(已尝试自动播种): ${homePrompts};可用 AW_PROMPTS_DIR 指定`)
36
89
  }
37
90
 
38
91
  /** 读取 md 原文(mtime 缓存;文件缺失 fail-fast) */