@jeffusion/tianshu 0.0.0-stage → 0.1.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.
@@ -0,0 +1,56 @@
1
+ # npm 分发与 Codex 原生安装
2
+
3
+ ## 安装
4
+
5
+ 插件是 npmjs 上的 `@jeffusion/tianshu`。GitHub 只承载市场目录;Codex 根据目录直接获取 npm 包,不要求用户安装独立 CLI 或构建源码。
6
+
7
+ ```sh
8
+ codex plugin marketplace add jeffusion/tianshu
9
+ codex plugin add tianshu@jeffusion
10
+ ```
11
+
12
+ 要求 Node.js 24–26、npm、Python 3.12+ 与支持 npm 插件源的 Codex。当前验证范围是 macOS 桌面和 Linux/macOS 自动测试,Windows 尚未验收。npm 包不含安装脚本;插件生命周期由 Codex 管理。
13
+
14
+ 首次使用显式初始化业务台账,再检查本包的 MCP 和只读查询:
15
+
16
+ ```sh
17
+ npx --yes @jeffusion/tianshu@0.1.1 init
18
+ npx --yes @jeffusion/tianshu@0.1.1 doctor
19
+ ```
20
+
21
+ 已有配置只校验、不覆盖;指定不存在的 `--config` 会报错。doctor 检查当前 npm 包,不声称检查了 Codex 已安装版本或实际页面。重开 Codex、启用“天枢”、发送“@天枢 打开任务中心”,必要时展开到右侧。
22
+
23
+ ## 业务数据
24
+
25
+ 配置与台账在插件缓存之外。macOS 默认 `~/Library/Application Support/Tianshu`,Linux 默认 `$XDG_DATA_HOME/tianshu` 或 `~/.local/share/tianshu`;支持 `TIANSHU_HOME`。其中只有 `config.json` 与 `data/`,没有插件版本副本、安装状态或回滚日志。
26
+
27
+ 已有台账在默认 `config.json` 中填入原配置的 `sources` 与可选 `presentation_path`,保持数据库和证据的绝对路径;已有默认配置不要覆盖。也可以通过 `TIANSHU_CONFIG` 指定既有配置,但要确保启动 Codex 的环境和命令行相同。详见[配置说明](configuration.md)。插件查询缺失配置会报错,不自动建库。
28
+
29
+ ## 升级与卸载
30
+
31
+ ```sh
32
+ codex plugin marketplace upgrade jeffusion
33
+ codex plugin add tianshu@jeffusion
34
+ # 卸载插件;业务台账和验收记录保留
35
+ codex plugin remove tianshu@jeffusion
36
+ # 不再需要这个市场时
37
+ codex plugin marketplace remove jeffusion
38
+ ```
39
+
40
+ 市场将每次发行固定到明确的 npm 版本;刷新目录后使用 Codex 原生命令安装。版本选择和缓存由 Codex 负责;天枢不提供独立升级、回滚或卸载命令,也不自动清除数据。
41
+
42
+ ## 从旧的本地安装切换
43
+
44
+ `0.1.0` 曾附带独立安装管理,已经移除。先保留既有 config.json、数据库和证据,再用 Codex 移除旧入口 `tianshu@tianshu` 与市场 `tianshu`(仅在该旧入口确实存在时),随后按上面的命令安装 `tianshu@jeffusion`。不要删除整个天枢数据目录,不运行旧版的安装命令;新的诊断不会读取旧安装状态。旧 releases/、marketplace/、installation.json、operation.json 与 lifecycle.lock 不再使用;确认旧安装已退出后可单独清理,保留 config.json、data/ 及用户证据。
45
+
46
+ ## 发布(维护者)
47
+
48
+ 日常测试不递增版本。正式 npm 版本不可覆盖,因此本次修正发布为 `0.1.1`,不覆盖 `0.1.0`。
49
+
50
+ 1. 同步 package.json、锁文件与 `.agents/plugins/marketplace.json` 的版本,PR 审查与 Linux/macOS CI 通过后合入 main。
51
+ 2. `npm run check` 验证实际 tarball 的文件边界、无源码依赖、无安装脚本、配置路由、MCP/页面资源和数据保全。`npm run test:native -- --local` 验证真实 Codex 宿主发现工具。
52
+ 3. 明确触发 `publish.yml`,输入版本;工作流构建 `dist/package`,打包该目录,经 `npm` 环境审批使用 OIDC 发布到 npmjs,并附来源证明。工程根包为 private,防止误发开发工程。
53
+ 4. 发布后运行 `npm run test:native`,验证真实 Codex 从 npmjs 安装、宿主加载四个 MCP 工具、doctor、卸载及台账保全;该测试仅使用临时 CODEX_HOME 和临时业务数据。
54
+ 5. 当前桌面客户端另验页面和“带到聊天”;命令行或 MCP 成功不能代替此项。
55
+
56
+ 可信发布绑定 GitHub `jeffusion/tianshu`、`publish.yml`、`npm` 环境。不要保存长期 npm token,也不要在聊天发送验证码。发布前的本地原生安装验收不等于 npmjs 安装已验收。
package/ledger/cli.py ADDED
@@ -0,0 +1,31 @@
1
+ """Resolve an allowlisted ledger, then invoke the unchanged transactional engine."""
2
+ import argparse
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from config import load_config
8
+
9
+
10
+ def main():
11
+ parser=argparse.ArgumentParser(description=__doc__,allow_abbrev=False)
12
+ parser.add_argument('--config')
13
+ parser.add_argument('--source',required=True)
14
+ parser.add_argument('command')
15
+ parser.add_argument('--input')
16
+ args=parser.parse_args()
17
+ try:
18
+ config=load_config(args.config)
19
+ source=next((s for s in config['sources'] if s['id']==args.source),None)
20
+ if not source:
21
+ raise ValueError('未配置的台账来源')
22
+ arguments=[args.command]+(['--input',args.input] if args.input is not None else [])
23
+ return subprocess.call([sys.executable,str(Path(__file__).with_name('coordinator.py')),
24
+ '--db',source['path'],*arguments])
25
+ except (OSError,ValueError,TypeError) as error:
26
+ print(json.dumps({'ok':False,'error':str(error)},ensure_ascii=False))
27
+ return 1
28
+
29
+
30
+ if __name__=='__main__':
31
+ sys.exit(main())
@@ -0,0 +1,44 @@
1
+ """One explicit configuration contract for the MCP projection and coordinator CLI."""
2
+ import json
3
+ import os
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def config_path(value=None):
10
+ selected=value or os.environ.get('TIANSHU_CONFIG')
11
+ home=os.environ.get('TIANSHU_HOME') or (
12
+ Path.home()/'Library/Application Support/Tianshu' if sys.platform=='darwin'
13
+ else Path(os.environ.get('XDG_DATA_HOME') or Path.home()/'.local/share')/'tianshu')
14
+ path=Path(selected) if selected else Path(os.path.normpath(str(Path(home)/'config.json')))
15
+ if not path.is_absolute():
16
+ raise ValueError('配置路径必须为绝对路径')
17
+ return path
18
+
19
+
20
+ def load_config(value=None):
21
+ path=config_path(value)
22
+ raw=json.loads(path.read_text(encoding='utf-8'))
23
+ if not isinstance(raw,dict) or not isinstance(raw.get('sources'),list) or not raw['sources']:
24
+ raise ValueError('配置必须包含非空sources列表')
25
+ sources=[]
26
+ for source in raw['sources']:
27
+ if not isinstance(source,dict) or not isinstance(source.get('id'),str) or not re.fullmatch(r'[A-Za-z0-9_-]+',source['id']):
28
+ raise ValueError('无效来源ID')
29
+ if not isinstance(source.get('label'),str) or not source['label'].strip():
30
+ raise ValueError('来源label不能为空')
31
+ if not isinstance(source.get('path'),str) or not Path(source['path']).is_absolute():
32
+ raise ValueError('台账路径必须为绝对路径')
33
+ sources.append({k:source[k] for k in ('id','label','path')})
34
+ if len({s['id'] for s in sources})!=len(sources):
35
+ raise ValueError('来源ID重复')
36
+ result={'sources':sources}
37
+ presentation=raw.get('presentation_path')
38
+ if presentation is not None:
39
+ if not isinstance(presentation,str) or not Path(presentation).is_absolute():
40
+ raise ValueError('展示文案路径必须为绝对路径')
41
+ result['presentation']=json.loads(Path(presentation).read_text(encoding='utf-8'))
42
+ if not isinstance(result['presentation'],dict):
43
+ raise ValueError('展示文案必须为对象')
44
+ return result
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """Local ledger only. Never calls Codex, executes payloads, or grants permissions."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+
13
+ SCHEMA = """
14
+ CREATE TABLE IF NOT EXISTS tasks (
15
+ id TEXT PRIMARY KEY, revision INTEGER NOT NULL, state TEXT NOT NULL,
16
+ mandate_version INTEGER NOT NULL, data TEXT NOT NULL);
17
+ CREATE TABLE IF NOT EXISTS mandates (
18
+ task_id TEXT NOT NULL REFERENCES tasks(id), version INTEGER NOT NULL,
19
+ data TEXT NOT NULL, PRIMARY KEY(task_id, version));
20
+ CREATE TABLE IF NOT EXISTS dispatches (
21
+ id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id),
22
+ mandate_version INTEGER NOT NULL, state TEXT NOT NULL, data TEXT NOT NULL);
23
+ CREATE UNIQUE INDEX IF NOT EXISTS one_unresolved_dispatch ON dispatches(task_id)
24
+ WHERE state IN ('prepared','sent','uncertain');
25
+ CREATE TABLE IF NOT EXISTS observations (
26
+ id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id), data TEXT NOT NULL);
27
+ CREATE TABLE IF NOT EXISTS events (
28
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(id),
29
+ at TEXT NOT NULL, action TEXT NOT NULL, data TEXT NOT NULL);
30
+ PRAGMA user_version=1;
31
+ """
32
+ STATES = {
33
+ 'queued': {'running','waiting_human','waiting_external','failed','stopped'},
34
+ 'running': {'ready','waiting_human','waiting_external','failed','stopped'},
35
+ 'waiting_human': {'running','failed','stopped'},
36
+ 'waiting_external': {'running','failed','stopped'},
37
+ 'ready': {'accepted','running','waiting_human','failed','stopped'},
38
+ 'failed': {'running','stopped'}, 'accepted': set(), 'stopped': set(),
39
+ }
40
+
41
+
42
+ def encode(value):
43
+ return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
44
+
45
+
46
+ def require(condition, message):
47
+ if not condition:
48
+ raise ValueError(message)
49
+
50
+
51
+ def string(data, key):
52
+ value = data.get(key)
53
+ require(isinstance(value, str) and bool(value.strip()), f'{key}: nonempty string required')
54
+ return value
55
+
56
+
57
+ def strings(data, key):
58
+ value = data.get(key)
59
+ require(isinstance(value, list) and value and all(isinstance(v, str) and v.strip() for v in value),
60
+ f'{key}: nonempty string list required')
61
+ return value
62
+
63
+
64
+ def event(db, task, action, data):
65
+ db.execute('INSERT INTO events(task_id,at,action,data) VALUES(?,?,?,?)',
66
+ (task, datetime.now(timezone.utc).isoformat(), action, encode(data)))
67
+
68
+
69
+ def get_task(db, task_id, expected=None):
70
+ row = db.execute('SELECT * FROM tasks WHERE id=?', (task_id,)).fetchone()
71
+ require(row is not None, 'task not found')
72
+ task = dict(row)
73
+ task['data'] = json.loads(task['data'])
74
+ if expected is not None:
75
+ require(type(expected) is int and task['revision'] == expected, 'stale or invalid expected_revision; reread task')
76
+ return task
77
+
78
+
79
+ def live(task):
80
+ require(task['state'] not in ('accepted','stopped'), 'terminal task; create a new task for new work')
81
+
82
+
83
+ def save(db, task):
84
+ task['revision'] += 1
85
+ db.execute('UPDATE tasks SET revision=?,state=?,mandate_version=?,data=? WHERE id=?',
86
+ (task['revision'], task['state'], task['mandate_version'], encode(task['data']), task['id']))
87
+
88
+
89
+ def mandate(data):
90
+ return {'allowed': strings(data, 'allowed'), 'forbidden': strings(data, 'forbidden'),
91
+ 'authorization_source': string(data, 'authorization_source')}
92
+
93
+
94
+ def dispatch(db, dispatch_id, task_id):
95
+ row = db.execute('SELECT * FROM dispatches WHERE id=? AND task_id=?', (dispatch_id, task_id)).fetchone()
96
+ require(row is not None, 'dispatch not found for this task')
97
+ result = dict(row)
98
+ result['data'] = json.loads(result['data'])
99
+ return result
100
+
101
+
102
+ def deleted_task(db, task_id):
103
+ if not db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_deletions'").fetchone():
104
+ return None
105
+ row = db.execute('SELECT receipt FROM task_deletions WHERE task_id=?', (task_id,)).fetchone()
106
+ return json.loads(row[0]) if row else None
107
+
108
+
109
+ def preserve_rowid(db, table, allocate=False):
110
+ """Deletion must not rewind the row ordering used by existing incident boundaries."""
111
+ maximum = db.execute(f'SELECT COALESCE(MAX(rowid),0) FROM {table}').fetchone()[0]
112
+ if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_rowid_counters'").fetchone():
113
+ row = db.execute('SELECT high_water FROM task_rowid_counters WHERE name=?', (table,)).fetchone()
114
+ maximum = max(maximum, row[0] if row else 0)
115
+ value = maximum + int(allocate)
116
+ db.execute('INSERT INTO task_rowid_counters VALUES(?,?) ON CONFLICT(name) DO UPDATE SET high_water=excluded.high_water',
117
+ (table, value))
118
+ return value
119
+ return maximum + int(allocate)
120
+
121
+
122
+ def delete_task(db, data):
123
+ task_id = string(data, 'task_id')
124
+ require(type(data.get('expected_revision')) is int, 'expected_revision required for deletion')
125
+ string(data, 'reason')
126
+ string(data, 'authorization_source')
127
+ previous = deleted_task(db, task_id)
128
+ if previous:
129
+ require(previous['deleted_revision'] == data['expected_revision'], 'deleted revision mismatch')
130
+ return {**previous, 'already_deleted': True}
131
+ task = get_task(db, task_id, data['expected_revision'])
132
+ require(task['state'] in ('accepted','stopped'), 'only accepted or stopped tasks can be deleted')
133
+ require(not db.execute("SELECT 1 FROM dispatches WHERE task_id=? AND state IN ('prepared','sent','uncertain')", (task_id,)).fetchone(),
134
+ 'reconcile unresolved dispatch before deletion')
135
+ incident = task['data'].get('incident')
136
+ require(not incident or incident['status'] == 'resolved', 'resolve open incident before deletion')
137
+ monitor = task['data'].get('monitor')
138
+ if monitor:
139
+ require(monitor.get('status') == 'stopped' and monitor.get('evidence'), 'stop monitor with evidence before deletion')
140
+ # This records the coordinator's actual external check, not a runtime authorization proof.
141
+ if task['data'].get('execution'):
142
+ string(data, 'execution_stop_evidence')
143
+ snapshot = run(db, 'task-show', {'task_id': task_id})
144
+ archive_id = str(uuid.uuid4())
145
+ receipt = {'task_id': task_id, 'deleted_revision': task['revision'], 'deleted': True,
146
+ 'archive_id': archive_id, 'deleted_at': datetime.now(timezone.utc).isoformat(),
147
+ 'already_deleted': False}
148
+ db.execute('''CREATE TABLE IF NOT EXISTS task_deletions (
149
+ task_id TEXT PRIMARY KEY, archive_id TEXT NOT NULL UNIQUE,
150
+ receipt TEXT NOT NULL, snapshot TEXT NOT NULL, request TEXT NOT NULL)''')
151
+ db.execute('INSERT INTO task_deletions VALUES(?,?,?,?,?)',
152
+ (task_id, archive_id, encode(receipt), encode(snapshot), encode(data)))
153
+ db.execute('CREATE TABLE IF NOT EXISTS task_rowid_counters (name TEXT PRIMARY KEY, high_water INTEGER NOT NULL)')
154
+ for table in ('observations','dispatches'):
155
+ preserve_rowid(db, table)
156
+ # Explicit ordering keeps foreign keys intact. Archive and removal share one transaction.
157
+ for table in ('events','observations','dispatches','mandates'):
158
+ db.execute(f'DELETE FROM {table} WHERE task_id=?', (task_id,))
159
+ db.execute('DELETE FROM tasks WHERE id=?', (task_id,))
160
+ return receipt
161
+
162
+
163
+ def run(db, command, data):
164
+ if command == 'init':
165
+ return {'schema_version': 1}
166
+ if command == 'task-list':
167
+ return [get_task(db, r[0]) for r in db.execute('SELECT id FROM tasks ORDER BY id')]
168
+ task_id = string(data, 'task_id')
169
+ if command == 'task-delete':
170
+ return delete_task(db, data)
171
+ if command == 'task-create':
172
+ require(not deleted_task(db, task_id), 'deleted task id cannot be reused; use a new task id')
173
+ contents = {k: string(data, k) for k in ('objective','owner_thread_id','owner_host_id')}
174
+ contents['acceptance'] = strings(data, 'acceptance')
175
+ contents['project_id'] = data.get('project_id')
176
+ contents['execution'] = data.get('execution')
177
+ if contents['execution'] is not None:
178
+ require(isinstance(contents['execution'], dict), 'execution must be object')
179
+ string(contents['execution'], 'thread_id')
180
+ string(contents['execution'], 'host_id')
181
+ grant = mandate(data)
182
+ db.execute('INSERT INTO tasks VALUES(?,1,\'queued\',1,?)', (task_id, encode(contents)))
183
+ db.execute('INSERT INTO mandates VALUES(?,1,?)', (task_id, encode(grant)))
184
+ event(db, task_id, command, {'mandate_version': 1})
185
+ return get_task(db, task_id)
186
+ task = get_task(db, task_id)
187
+ if command == 'task-show':
188
+ task['mandates'] = [dict(r) | {'data': json.loads(r['data'])} for r in db.execute(
189
+ 'SELECT * FROM mandates WHERE task_id=? ORDER BY version', (task_id,))]
190
+ for table in ('dispatches','observations','events'):
191
+ order = 'seq' if table == 'events' else 'rowid'
192
+ task[table] = [dict(r) | {'data': json.loads(r['data'])} for r in db.execute(
193
+ f'SELECT * FROM {table} WHERE task_id=? ORDER BY {order}', (task_id,))]
194
+ return task
195
+ require('expected_revision' in data, 'expected_revision required for mutations')
196
+ task = get_task(db, task_id, data['expected_revision'])
197
+ if command != 'observation-record' and not (command == 'monitor-record' and data.get('status') == 'stopped'):
198
+ live(task)
199
+ result = None
200
+ if command == 'mandate-revise':
201
+ require(not db.execute("SELECT 1 FROM dispatches WHERE task_id=? AND state IN ('prepared','sent','uncertain')", (task_id,)).fetchone(),
202
+ 'reconcile unresolved dispatch before revising mandate')
203
+ grant = mandate(data)
204
+ task['mandate_version'] += 1
205
+ db.execute('INSERT INTO mandates VALUES(?,?,?)', (task_id, task['mandate_version'], encode(grant)))
206
+ elif command == 'dispatch-prepare':
207
+ require(task['state'] != 'failed', 'resume failed task explicitly before dispatch')
208
+ kind = string(data, 'kind')
209
+ require(kind in ('create_thread','send_message'), 'invalid kind')
210
+ require((kind == 'create_thread') == (task['data']['execution'] is None), 'kind conflicts with bound execution')
211
+ dispatch_id = string(data, 'dispatch_id')
212
+ payload = {'kind': kind, 'prompt': string(data, 'prompt'),
213
+ 'authorization_source': string(data, 'authorization_source'),
214
+ 'target': task['data']['execution'], 'project_id': task['data']['project_id']}
215
+ db.execute('INSERT INTO dispatches(rowid,id,task_id,mandate_version,state,data) VALUES(?,?,?,?,\'prepared\',?)',
216
+ (preserve_rowid(db, 'dispatches', allocate=True), dispatch_id, task_id, task['mandate_version'], encode(payload)))
217
+ result = {'dispatch_id': dispatch_id}
218
+ elif command == 'dispatch-mark':
219
+ item = dispatch(db, string(data, 'dispatch_id'), task_id)
220
+ status = string(data, 'state')
221
+ moves = {'prepared': {'sent','uncertain','cancelled'}, 'sent': {'observed','uncertain'},
222
+ 'uncertain': {'sent','observed','cancelled'}}
223
+ require(status in moves.get(item['state'], set()), 'invalid dispatch transition')
224
+ require(item['mandate_version'] == task['mandate_version'], 'dispatch uses stale mandate')
225
+ receipt = data.get('receipt')
226
+ require(isinstance(receipt, dict) and receipt, 'receipt object required')
227
+ if status == 'sent':
228
+ string(receipt, 'thread_id')
229
+ string(receipt, 'host_id')
230
+ if task['data']['execution'] is None:
231
+ task['data']['execution'] = {k: receipt[k] for k in ('thread_id','host_id')}
232
+ else:
233
+ require(all(receipt[k] == task['data']['execution'][k] for k in ('thread_id','host_id')), 'receipt target mismatch')
234
+ if status == 'observed':
235
+ require(task['data']['execution'] is not None, 'bind actual receipt using sent first')
236
+ obs = db.execute('SELECT data FROM observations WHERE id=? AND task_id=?',
237
+ (string(receipt, 'observation_id'), task_id)).fetchone()
238
+ require(obs is not None, 'observation required')
239
+ require(json.loads(obs[0]).get('dispatch_id') == item['id'], 'observation does not confirm this dispatch')
240
+ if status == 'cancelled':
241
+ string(receipt, 'reconciliation')
242
+ item['data']['receipt'] = receipt
243
+ db.execute('UPDATE dispatches SET state=?,data=? WHERE id=?', (status, encode(item['data']), item['id']))
244
+ elif command == 'observation-record':
245
+ require(task['data']['execution'] is not None, 'task has no execution binding')
246
+ require(data.get('host_id') == task['data']['execution']['host_id'] and
247
+ data.get('thread_id') == task['data']['execution']['thread_id'], 'observation target mismatch')
248
+ contents = {k: string(data,k) for k in ('host_id','thread_id','turn_id','summary')}
249
+ contents['evidence'] = strings(data, 'evidence')
250
+ contents['cursor'] = data.get('cursor')
251
+ contents['dispatch_id'] = data.get('dispatch_id')
252
+ if contents['dispatch_id']:
253
+ dispatch(db, contents['dispatch_id'], task_id)
254
+ observation_id = data.get('observation_id', str(uuid.uuid4()))
255
+ require(isinstance(observation_id, str) and observation_id.strip(), 'observation_id required')
256
+ db.execute('INSERT INTO observations(rowid,id,task_id,data) VALUES(?,?,?,?)',
257
+ (preserve_rowid(db, 'observations', allocate=True), observation_id, task_id, encode(contents)))
258
+ task['data']['last_observation_id'] = observation_id
259
+ if 'cursor' in data:
260
+ require(data['cursor'] is None or isinstance(data['cursor'], str), 'cursor must be string or null')
261
+ task['data']['cursor'] = data['cursor']
262
+ result = {'observation_id': observation_id}
263
+ elif command == 'incident-record':
264
+ previous = task['data'].get('incident')
265
+ require(not previous or previous['status'] == 'resolved', 'resolve existing incident before recording another')
266
+ category = string(data, 'category')
267
+ require(category in ('direction_drift','authorization_gap','execution_failure','observation_unavailable'), 'invalid incident category')
268
+ incident_id = string(data, 'incident_id')
269
+ require(not previous or previous['id'] != incident_id, 'use a new incident id')
270
+ task['data']['incident'] = {'id': incident_id, 'category': category, 'status': 'open',
271
+ 'summary': string(data, 'summary'), 'evidence': strings(data, 'evidence'),
272
+ 'observation_boundary': db.execute('SELECT COALESCE(MAX(rowid),0) FROM observations').fetchone()[0],
273
+ 'dispatch_boundary': db.execute('SELECT COALESCE(MAX(rowid),0) FROM dispatches').fetchone()[0],
274
+ 'at': datetime.now(timezone.utc).isoformat()}
275
+ elif command == 'correction-link':
276
+ incident = task['data'].get('incident')
277
+ require(incident and incident['id'] == data.get('incident_id') and incident['status'] == 'open', 'open matching incident required')
278
+ item = dispatch(db, string(data, 'dispatch_id'), task_id)
279
+ dispatch_order = db.execute('SELECT rowid FROM dispatches WHERE id=?', (item['id'],)).fetchone()[0]
280
+ require(dispatch_order > incident['dispatch_boundary'], 'correction must be prepared after this incident')
281
+ require(item['data']['kind'] == 'send_message' and item['mandate_version'] == task['mandate_version'], 'correction must target current bound chat and mandate')
282
+ require(item['state'] != 'cancelled', 'cancelled correction cannot be linked')
283
+ old = incident.get('correction_dispatch_id')
284
+ old_item = dispatch(db, old, task_id) if old else None
285
+ if old_item and old != item['id']:
286
+ require(old_item['state'] in ('cancelled','observed'), 'reconcile existing correction before replacing')
287
+ old_order = db.execute('SELECT rowid FROM dispatches WHERE id=?', (old,)).fetchone()[0]
288
+ require(dispatch_order > old_order, 'replacement correction must be newer than current correction')
289
+ incident.setdefault('correction_history', []).append({'dispatch_id':old,
290
+ 'reason':string(data,'replacement_reason'), 'evidence':strings(data,'replacement_evidence')})
291
+ incident['correction_dispatch_id'] = item['id']
292
+ elif command == 'incident-resolve':
293
+ incident = task['data'].get('incident')
294
+ require(incident and incident['id'] == data.get('incident_id') and incident['status'] == 'open', 'open matching incident required')
295
+ resolution = string(data, 'resolution')
296
+ require(resolution in ('corrected','dismissed','observation_restored'), 'invalid resolution')
297
+ require(not db.execute("SELECT 1 FROM dispatches WHERE task_id=? AND state IN ('prepared','sent','uncertain')", (task_id,)).fetchone(), 'reconcile unresolved dispatch before resolving')
298
+ if resolution != 'dismissed':
299
+ obs = db.execute('SELECT rowid,data FROM observations WHERE task_id=? AND id=?',
300
+ (task_id, string(data, 'observation_id'))).fetchone()
301
+ require(obs is not None, 'resolution requires observed evidence')
302
+ require(obs['rowid'] > incident['observation_boundary'], 'resolution requires a new observation after incident')
303
+ if resolution == 'corrected':
304
+ item = dispatch(db, incident.get('correction_dispatch_id'), task_id)
305
+ require(item['state'] == 'observed' and item['mandate_version'] == task['mandate_version'], 'correction must be read back under current mandate')
306
+ require(json.loads(obs['data']).get('dispatch_id') == item['id'], 'resolution observation must confirm linked correction')
307
+ else:
308
+ require(incident['category'] == 'observation_unavailable', 'observation restoration applies only to observation incidents')
309
+ incident.update({'status':'resolved', 'resolution':resolution,
310
+ 'verified_by':string(data,'verified_by'), 'verification_evidence':strings(data,'verification_evidence'),
311
+ 'resolved_at':datetime.now(timezone.utc).isoformat()})
312
+ elif command == 'monitor-record':
313
+ status = string(data, 'status')
314
+ require(status in ('watching','waiting_decision','stopped'), 'invalid monitor status')
315
+ if status == 'watching':
316
+ require(task['data']['execution'] is not None, 'monitor requires a bound execution chat')
317
+ require(not task['data'].get('incident') or task['data']['incident']['status'] == 'resolved', 'resolve open incident before resuming monitor')
318
+ string(data, 'automation_id')
319
+ string(data, 'authorization_source')
320
+ task['data']['monitor'] = {'status':status, 'automation_id':data.get('automation_id'),
321
+ 'reason':string(data,'reason'), 'evidence':strings(data,'evidence'),
322
+ 'at':datetime.now(timezone.utc).isoformat()}
323
+ elif command == 'task-transition':
324
+ status = string(data, 'state')
325
+ require(status in STATES[task['state']], 'invalid task transition')
326
+ string(data, 'reason')
327
+ if status in ('accepted','stopped'):
328
+ require(not db.execute("SELECT 1 FROM dispatches WHERE task_id=? AND state IN ('prepared','sent','uncertain')", (task_id,)).fetchone(),
329
+ 'reconcile dispatch before terminal transition')
330
+ if status == 'accepted':
331
+ require(not task['data'].get('incident') or task['data']['incident']['status'] == 'resolved', 'resolve open incident before acceptance')
332
+ require(task['data'].get('last_observation_id'), 'acceptance requires observation')
333
+ strings(data, 'verification_evidence')
334
+ string(data, 'verified_by')
335
+ if status == 'stopped':
336
+ string(data, 'stop_authorization_source')
337
+ string(data, 'execution_stop_evidence')
338
+ task['state'] = status
339
+ else:
340
+ raise ValueError('unknown command')
341
+ save(db, task)
342
+ event(db, task_id, command, data)
343
+ return {'task': task, **(result or {})}
344
+
345
+
346
+ def main():
347
+ parser = argparse.ArgumentParser(description=__doc__)
348
+ parser.add_argument('--db', required=True, help='absolute database path; only init creates it')
349
+ parser.add_argument('command', choices=('init','task-create','task-list','task-show','mandate-revise',
350
+ 'dispatch-prepare','dispatch-mark','observation-record','task-transition',
351
+ 'incident-record','correction-link','incident-resolve','monitor-record','task-delete'))
352
+ parser.add_argument('--input', help='UTF-8 JSON object file; - reads stdin')
353
+ args = parser.parse_args()
354
+ try:
355
+ path = Path(args.db)
356
+ require(path.is_absolute(), '--db must be absolute')
357
+ data = {}
358
+ if args.input:
359
+ data = json.load(sys.stdin) if args.input == '-' else json.loads(Path(args.input).read_text(encoding='utf-8'))
360
+ require(isinstance(data, dict), 'input must be a JSON object')
361
+ os.umask(0o077)
362
+ if args.command == 'init':
363
+ require(not path.exists(), 'database already exists; init will not reset it')
364
+ path.parent.mkdir(parents=True, exist_ok=True)
365
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
366
+ os.close(fd)
367
+ else:
368
+ require(path.is_file(), 'database missing; use init explicitly')
369
+ db = sqlite3.connect(path.as_uri() + '?mode=rw', uri=True, timeout=5, isolation_level=None)
370
+ db.row_factory = sqlite3.Row
371
+ db.execute('PRAGMA foreign_keys=ON')
372
+ if args.command == 'init':
373
+ db.executescript('BEGIN IMMEDIATE;\n' + SCHEMA + '\nCOMMIT;')
374
+ require(db.execute('PRAGMA user_version').fetchone()[0] == 1, 'unsupported database schema')
375
+ try:
376
+ db.execute('BEGIN IMMEDIATE')
377
+ output = run(db, args.command, data)
378
+ db.execute('COMMIT')
379
+ except Exception:
380
+ if db.in_transaction:
381
+ db.execute('ROLLBACK')
382
+ raise
383
+ finally:
384
+ db.close()
385
+ print(encode({'ok': True, 'result': output}))
386
+ return 0
387
+ except (ValueError, OSError, sqlite3.Error, json.JSONDecodeError) as exc:
388
+ print(encode({'ok': False, 'error': str(exc)}))
389
+ return 1
390
+
391
+
392
+ if __name__ == '__main__':
393
+ sys.exit(main())
@@ -0,0 +1,105 @@
1
+ """Read-only task projections. Paths come only from the operator's allowlist."""
2
+ import argparse
3
+ import json
4
+ import sqlite3
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from config import load_config
9
+
10
+
11
+ def text(value, maximum=3000):
12
+ return value[:maximum] if isinstance(value, str) else ''
13
+
14
+
15
+ def items(value):
16
+ return [text(v) for v in value[:20] if isinstance(v, str)] if isinstance(value, list) else []
17
+
18
+
19
+ def read_source(source):
20
+ path = Path(source['path'])
21
+ if not path.is_absolute() or not path.is_file():
22
+ raise ValueError('台账不存在')
23
+ db = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=2)
24
+ db.row_factory = sqlite3.Row
25
+ try:
26
+ db.execute('PRAGMA query_only=ON')
27
+ db.execute('BEGIN')
28
+ if db.execute('PRAGMA user_version').fetchone()[0] != 1:
29
+ raise ValueError('不支持的台账版本')
30
+ rows = db.execute('SELECT * FROM tasks ORDER BY rowid DESC LIMIT 200').fetchall()
31
+ result = []
32
+ for row in rows:
33
+ data = json.loads(row['data'])
34
+ grant = db.execute('SELECT data FROM mandates WHERE task_id=? AND version=?',
35
+ (row['id'], row['mandate_version'])).fetchone()
36
+ grant = json.loads(grant[0]) if grant else {}
37
+ latest = db.execute('SELECT data FROM observations WHERE task_id=? ORDER BY rowid DESC LIMIT 1', (row['id'],)).fetchone()
38
+ observation = json.loads(latest[0]) if latest else {}
39
+ last_event = db.execute('SELECT at FROM events WHERE task_id=? ORDER BY seq DESC LIMIT 1', (row['id'],)).fetchone()
40
+ observation_event = db.execute("SELECT at FROM events WHERE task_id=? AND action='observation-record' ORDER BY seq DESC LIMIT 1", (row['id'],)).fetchone()
41
+ transitions = db.execute("SELECT at,data FROM events WHERE task_id=? AND action='task-transition' ORDER BY seq DESC LIMIT 6", (row['id'],)).fetchall()
42
+ verification = None
43
+ if row['state'] == 'accepted':
44
+ for transition in transitions:
45
+ record = json.loads(transition['data'])
46
+ if record.get('state') == 'accepted':
47
+ verification = {'at': transition['at'], 'by': text(record.get('verified_by')),
48
+ 'evidence': items(record.get('verification_evidence'))}
49
+ break
50
+ execution = data.get('execution') or {}
51
+ raw_incident = data.get('incident') or {}
52
+ incident = {k: text(raw_incident.get(k)) for k in ('id','category','status','summary','at','resolution','resolved_at','verified_by','correction_dispatch_id')} if raw_incident else None
53
+ if incident:
54
+ incident['evidence'] = items(raw_incident.get('evidence'))
55
+ incident['verification_evidence'] = items(raw_incident.get('verification_evidence'))
56
+ raw_monitor = data.get('monitor') or {}
57
+ monitor = {k: text(raw_monitor.get(k)) for k in ('status','automation_id','reason','at')} if raw_monitor else None
58
+ result.append({
59
+ 'key': source['id'] + ':' + row['id'], 'id': text(row['id'],200),
60
+ 'source': source['id'], 'source_label': source['label'],
61
+ 'objective': text(data.get('objective')), 'state': text(row['state'],50),
62
+ 'revision': row['revision'], 'mandate_version': row['mandate_version'],
63
+ 'host_id': text(execution.get('host_id'),200), 'thread_id': text(execution.get('thread_id'),200),
64
+ 'acceptance': items(data.get('acceptance')), 'allowed': items(grant.get('allowed')),
65
+ 'forbidden': items(grant.get('forbidden')), 'summary': text(observation.get('summary')),
66
+ 'evidence': items(observation.get('evidence')), 'updated_at': last_event[0] if last_event else None,
67
+ 'observed_at': observation_event[0] if observation_event else None,
68
+ 'verification': verification,
69
+ 'incident': incident, 'monitor': monitor,
70
+ 'transitions': [{'at': e['at'], 'state': text(json.loads(e['data']).get('state'),50),
71
+ 'reason': text(json.loads(e['data']).get('reason'))} for e in reversed(transitions)],
72
+ })
73
+ return result
74
+ finally:
75
+ db.close()
76
+
77
+
78
+ def snapshot(config):
79
+ tasks, sources = [], []
80
+ for source in config['sources']:
81
+ try:
82
+ rows = read_source(source)
83
+ for task in rows:
84
+ display = config.get('presentation', {}).get(task['key'], {})
85
+ if display.get('revision') == task['revision']:
86
+ task['display'] = {k: text(display.get(k), 1200) for k in
87
+ ('title', 'project', 'final_summary', 'final_at')}
88
+ task['display']['evidence'] = items(display.get('evidence'))
89
+ tasks.extend(rows)
90
+ sources.append({'id': source['id'], 'label': source['label'], 'ok': True, 'count': len(rows)})
91
+ except (OSError, ValueError, sqlite3.Error, TypeError, KeyError):
92
+ sources.append({'id': source['id'], 'label': source['label'], 'ok': False,
93
+ 'error': '台账暂不可读,请检查本地配置或稍后刷新'})
94
+ return {'tasks': tasks, 'sources': sources, 'read_at': datetime.now(timezone.utc).isoformat(), 'readonly': True}
95
+
96
+
97
+ if __name__ == '__main__':
98
+ parser=argparse.ArgumentParser(description=__doc__)
99
+ parser.add_argument('--config')
100
+ args=parser.parse_args()
101
+ try:
102
+ print(json.dumps(snapshot(load_config(args.config)),ensure_ascii=False))
103
+ except (OSError,ValueError,TypeError) as error:
104
+ print(json.dumps({'ok':False,'error':str(error)},ensure_ascii=False))
105
+ sys.exit(1)