@zq-silk/yui 0.6.3 → 0.6.4

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 CHANGED
@@ -881,94 +881,19 @@ atomic storage switch, purely to make that single switch recoverable — it is n
881
881
  a general backup/restore facility.)
882
882
 
883
883
  See [ARCHITECTURE.md](./ARCHITECTURE.md) for persistence and scheduling details.
884
- The reusable, user-driven acceptance plan is documented in
885
- [Operator routing and long-running Task E2E testing](./docs/testing/operator-routing-e2e-plan.md).
886
-
887
884
  ## Development
888
885
 
889
886
  ```sh
890
887
  npm ci
891
- npm run test:core
892
- ```
893
-
894
- `npm run test:core` builds once and runs the same small, explicit core matrix as
895
- CI. During development, add the smallest affected test files for the behavior
896
- being changed.
897
-
898
- `npm test` (and `make test` / `make check`) remains the full **deterministic**
899
- diagnostic suite. Run it on demand for unusually cross-cutting changes or
900
- regression investigation; it is not a routine merge requirement. It never
901
- launches a real model or touches the global `yui` binary, a shared `YUI_HOME`,
902
- or a running production Session. It stays
903
- deterministic even when launched from inside a managed Yui Session, because it
904
- preloads `test/helpers/scrubSessionEnv.js` to strip every Yui-owned managed
905
- runtime value from the test process, including shared `YUI_HOME`, exact Leader
906
- action assertions, workspace projections, and Agent launch descriptors. Tests
907
- that touch Home/CLI/Controller/tmux explicitly supply a test-created isolated
908
- Home. The same preamble puts local refusal shims for bare `codex` and `claude`
909
- ahead of the caller's `PATH`; Session fixtures install observable Mock Agents
910
- inside their owned Home instead. Only a dedicated managed-identity child may
911
- opt out. The Provider E2E tier is exempt from the shims only after its explicit
912
- opt-in and mandatory isolation preflight path has been selected.
913
-
914
- ### Test tiers
915
-
916
- Yui's tests are classified into five explicit, executable tiers so a reader
917
- never has to guess what a test actually exercised. Each tier declares whether it
918
- creates a Session, whether it calls a real model, and whether it stands up a
919
- disposable real runtime. Agent workflow for applying these tiers while developing
920
- Yui lives in [`.agents/skills/develop-yui/SKILL.md`](.agents/skills/develop-yui/SKILL.md); it is
921
- not part of the generic Leader, Worker, or Reviewer workflow:
922
-
923
- | Tier | Session | Real model | Disposable runtime | Preflight | Opt-in |
924
- | --- | --- | --- | --- | --- | --- |
925
- | Unit | no | no | no | no | — |
926
- | Isolated Integration | yes | no | yes | no | — |
927
- | Mock Agent Session | yes | no | yes | no | — |
928
- | Provider E2E | yes | **yes** | yes | **required** | `YUI_ALLOW_PROVIDER_E2E=1` |
929
- | Release E2E | **no** | **no** | yes | **required** | `YUI_ALLOW_RELEASE_E2E=1` |
930
-
931
- ```sh
932
- make test-tier T=unit # or: npm run test:tier -- unit
933
- npm run test:tier -- unit -- --test-name-pattern "test name"
934
- node scripts/run-test-tier.mjs list
888
+ npm test
935
889
  ```
936
890
 
937
- The supported tier entrypoint always runs the canonical `npm run build` first.
938
- It therefore works on a fresh checkout and cannot mistake a present but stale
939
- `dist/cli.js` for current code. The raw `node --test dist/...` path remains an
940
- unsupported bypass of that freshness boundary.
941
-
942
- **Provider E2E is the only tier that calls a real model.** Release E2E, on its
943
- normal path, creates no Session and calls no model — it exercises
944
- binary/install/update/upgrade release flows against real npm/home/namespace
945
- resources. Both tiers are **privileged and fail-closed**: they live only in
946
- nested privileged manifests excluded from the default test glob, refuse to run
947
- without their opt-in env var, and execute through one wrapper that registers
948
- cleanup before observation and does not even evaluate the scenario module until
949
- the blocking isolation preflight (`assertIsolationReady`) passes. Active-Session
950
- observation is runner-owned and uses an all-scope Yui runtime inventory;
951
- scenario code cannot replace it or manufacture an empty result. The preflight
952
- requires an absolute
953
- checkout-local launcher; a run root proven **temporary and creator-bound owned by
954
- this run** — created via `createOwnedRunRoot` (mkdtemp + a random-token
955
- ownership receipt) and re-proven by that exact token, with a symlink run root
956
- refused and every path canonicalized so a symlink escape cannot pass a lexical
957
- check; the disposable `YUI_HOME`, workspace, isolated npm prefix, and unique
958
- runtime namespace all derived *inside that exact owned root* and **physically
959
- fenced** against symlink escape; and an **explicit** observation that zero
960
- production Sessions are active (missing evidence fails closed — it is never
961
- assumed empty). No bare `yui`, `make link` symlink, shared home, arbitrary or
962
- pre-existing foreign run root, symlinked path, or unproven Session state is
963
- tolerated. Real-runtime teardown scans and cleans only the creator-owned Home,
964
- uses Yui's exact process/pane/artifact identity fences, verifies the Home-derived
965
- tmux server is absent, and refuses environment overrides that redirect
966
- `YUI_HOME`. The reusable annotated-resource selector separately requires an
967
- exact non-empty creator token plus matching `ephemeral-test` marker; a missing
968
- token touches nothing and is a failed cleanup outcome. **Mock Agent Session
969
- transport success does not prove
970
- provider-native acceptance** — only the Provider E2E tier can record that. See
971
- [docs/testing/test-tiers.md](./docs/testing/test-tiers.md) for the full contract.
891
+ The permanent suite is intentionally one seconds-scale core smoke. It checks
892
+ CLI startup, a normal SQLite Task path, the supported migration graph, and the
893
+ built-in Agent Drivers. Change-specific TDD fixtures and abnormal-data repros
894
+ are temporary development evidence and are removed when the change is complete;
895
+ they do not accumulate as permanent regression tests. See
896
+ [the verification policy](./docs/testing/verification-levels.md).
972
897
 
973
898
  To make user terminals use this checkout, reversibly link the user-level `yui` command:
974
899
 
@@ -1303,24 +1303,68 @@ function requireAggregateV17Snapshot(snapshot) {
1303
1303
  /**
1304
1304
  * Runtime state is now projected exclusively from canonical
1305
1305
  * `runtime.observation` events. Offline upgrade inventory proves there are no
1306
- * active Runs or live Sessions, so the historical lifecycle vocabulary needs
1307
- * no dual-read adapter after this version-only boundary.
1306
+ * active Runs or live Sessions. A retired Task has also explicitly abandoned
1307
+ * its runtime, so its stored runtime inconsistencies no longer block the Home;
1308
+ * the anomalous records themselves remain available as history. Non-retired
1309
+ * Tasks still fail closed and receive the supported retirement command.
1308
1310
  */
1309
1311
  function migrateAggregateV18ToV19(snapshot) {
1310
1312
  requireAggregateV18Snapshot(snapshot);
1313
+ const schemaManifest = {
1314
+ ...snapshot.schemaManifest,
1315
+ aggregateSchemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION
1316
+ };
1317
+ if (snapshot.state === null)
1318
+ return { schemaManifest, state: null };
1319
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1320
+ const nextTasks = {};
1321
+ for (const [taskId, rawStoredTask] of Object.entries(tasks)) {
1322
+ const storedTask = asObject(rawStoredTask, `Task aggregate ${taskId}`);
1323
+ const rawTask = storedTask.task;
1324
+ const task = rawTask !== null && typeof rawTask === "object" && !Array.isArray(rawTask)
1325
+ ? rawTask
1326
+ : undefined;
1327
+ if (task?.status === "retired") {
1328
+ nextTasks[taskId] = { ...storedTask };
1329
+ continue;
1330
+ }
1331
+ requireResolvableActiveRunPointers(taskId, storedTask);
1332
+ nextTasks[taskId] = { ...storedTask };
1333
+ }
1311
1334
  return {
1312
- schemaManifest: {
1313
- ...snapshot.schemaManifest,
1314
- aggregateSchemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION
1315
- },
1316
- state: snapshot.state === null
1317
- ? null
1318
- : {
1319
- ...snapshot.state,
1320
- schemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION
1321
- }
1335
+ schemaManifest,
1336
+ state: {
1337
+ ...snapshot.state,
1338
+ schemaVersion: RUNTIME_OBSERVATION_AGGREGATE_TO_VERSION,
1339
+ tasks: nextTasks
1340
+ }
1322
1341
  };
1323
1342
  }
1343
+ function requireResolvableActiveRunPointers(taskId, storedTask) {
1344
+ if (storedTask.activeRuns === undefined)
1345
+ return;
1346
+ const activeRuns = asObject(storedTask.activeRuns, `activeRunPointer map ${taskId}`);
1347
+ if (Object.keys(activeRuns).length === 0)
1348
+ return;
1349
+ const agentRuns = asObject(storedTask.agentRuns, `agentRun map ${taskId}`);
1350
+ for (const [pointer, rawActiveRun] of Object.entries(activeRuns)) {
1351
+ const activeRun = asObject(rawActiveRun, `Active run ${taskId}/${pointer}`);
1352
+ const runId = typeof activeRun.runId === "string" ? activeRun.runId.trim() : "";
1353
+ if (runId.length === 0) {
1354
+ throw new Error(`Active run pointer ${taskId}/${pointer} has an invalid runId. `
1355
+ + taskRetirementUpgradeHint(taskId));
1356
+ }
1357
+ if (agentRuns[runId] === undefined) {
1358
+ throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${runId}. `
1359
+ + taskRetirementUpgradeHint(taskId));
1360
+ }
1361
+ }
1362
+ }
1363
+ function taskRetirementUpgradeHint(taskId) {
1364
+ return `Retire Task ${taskId} with `
1365
+ + `\`yui task retire ${taskId} --summary "abandon inconsistent runtime state"\`, `
1366
+ + "then retry `yui update`.";
1367
+ }
1324
1368
  function requireAggregateV18Snapshot(snapshot) {
1325
1369
  if (snapshot.schemaManifest.aggregateSchemaVersion
1326
1370
  !== RUNTIME_OBSERVATION_AGGREGATE_FROM_VERSION) {
@@ -37,7 +37,7 @@ import { planMigration } from "../migration/planner.js";
37
37
  import { AmbiguousSwitchError } from "../migration/index.js";
38
38
  import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
39
39
  import { writeSwitchProgress } from "./switchProgress.js";
40
- import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
40
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, copySqlitePassthroughState, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
41
41
  /** Build the SQLite-backed record-migration target. */
42
42
  export function createSqliteRecordMigrationTarget(options) {
43
43
  const home = options.home;
@@ -92,6 +92,7 @@ export function createSqliteRecordMigrationTarget(options) {
92
92
  updatedAt: now().toISOString()
93
93
  };
94
94
  populateSqliteFromState(home, snapshot.state ?? {}, STAGED_DATABASE_FILENAME);
95
+ copySqlitePassthroughState(home, COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME);
95
96
  },
96
97
  rebuildDerivedState(effects) {
97
98
  // The SQLite database is fully normalised by populateSqliteFromState;
@@ -33,6 +33,90 @@ import { CURRENT_STORED_TASK_SCHEMA_VERSION } from "../taskStore.js";
33
33
  export const STAGED_DATABASE_FILENAME = "yui.db.staged";
34
34
  /** The committed database filename. */
35
35
  export const COMMITTED_DATABASE_FILENAME = "yui.db";
36
+ /**
37
+ * Preserve SQLite-owned durable state that intentionally sits outside the
38
+ * state.json-shaped Task aggregate snapshot. Volatile coordination locks and
39
+ * derived projections are rebuilt or dropped at the offline boundary.
40
+ */
41
+ export function copySqlitePassthroughState(home, sourceDatabaseFilename, targetDatabaseFilename) {
42
+ if (sourceDatabaseFilename === targetDatabaseFilename) {
43
+ throw new Error("SQLite passthrough copy requires distinct source and target databases.");
44
+ }
45
+ const source = new Database(join(home, sourceDatabaseFilename), { readonly: true });
46
+ const target = new Database(join(home, targetDatabaseFilename));
47
+ try {
48
+ target.pragma("foreign_keys = ON");
49
+ target.transaction(() => {
50
+ mergeGlobalSequences(source, target);
51
+ copyTableRows(source, target, "outbox");
52
+ copyMailboxSignals(source, target);
53
+ copyTableRows(source, target, "work_item_candidates");
54
+ copyTableRows(source, target, "review_findings");
55
+ copyTableRows(source, target, "telemetry");
56
+ if (sqliteTableExists(source, "telemetry_aggregate")) {
57
+ target.exec("DELETE FROM telemetry_aggregate");
58
+ copyTableRows(source, target, "telemetry_aggregate");
59
+ }
60
+ copyTableRows(source, target, "session_owners");
61
+ copyTableRows(source, target, "resource_registry");
62
+ copyTableRows(source, target, "gate_artifacts");
63
+ copyTableRows(source, target, "gate_artifact_logs");
64
+ })();
65
+ }
66
+ finally {
67
+ source.close();
68
+ target.close();
69
+ }
70
+ }
71
+ function mergeGlobalSequences(source, target) {
72
+ if (!sqliteTableExists(source, "global_sequences"))
73
+ return;
74
+ const rows = source.prepare("SELECT name, high_water FROM global_sequences").all();
75
+ const merge = target.prepare(`INSERT INTO global_sequences (name, high_water) VALUES (?, ?)
76
+ ON CONFLICT(name) DO UPDATE SET
77
+ high_water = MAX(global_sequences.high_water, excluded.high_water)`);
78
+ for (const row of rows)
79
+ merge.run(row.name, row.high_water);
80
+ }
81
+ function copyMailboxSignals(source, target) {
82
+ if (!sqliteTableExists(source, "mailbox_signals"))
83
+ return;
84
+ const rows = source.prepare(`SELECT m.target_key, s.sequence, s.reason, s.ref_type, s.ref_task_id,
85
+ s.ref_id, s.occurred_at, s.request_id
86
+ FROM mailbox_signals s
87
+ JOIN mailboxes m ON m.mailbox_id = s.mailbox_id
88
+ ORDER BY m.target_key, s.sequence`).iterate();
89
+ const findMailbox = target.prepare("SELECT mailbox_id FROM mailboxes WHERE target_key = ?");
90
+ const insert = target.prepare(`INSERT INTO mailbox_signals
91
+ (mailbox_id, sequence, reason, ref_type, ref_task_id, ref_id, occurred_at, request_id)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
93
+ for (const row of rows) {
94
+ const mailbox = findMailbox.get(row.target_key);
95
+ if (mailbox === undefined) {
96
+ throw new Error(`SQLite migration cannot preserve signals for missing mailbox ${row.target_key}.`);
97
+ }
98
+ insert.run(mailbox.mailbox_id, row.sequence, row.reason, row.ref_type, row.ref_task_id, row.ref_id, row.occurred_at, row.request_id);
99
+ }
100
+ }
101
+ function copyTableRows(source, target, table) {
102
+ if (!sqliteTableExists(source, table) || !sqliteTableExists(target, table))
103
+ return;
104
+ const columns = source.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all().map((row) => row.name);
105
+ if (columns.length === 0)
106
+ return;
107
+ const quotedColumns = columns.map(quoteSqliteIdentifier);
108
+ const rows = source.prepare(`SELECT ${quotedColumns.join(", ")} FROM ${quoteSqliteIdentifier(table)}`).iterate();
109
+ const insert = target.prepare(`INSERT INTO ${quoteSqliteIdentifier(table)} (${quotedColumns.join(", ")}) `
110
+ + `VALUES (${columns.map(() => "?").join(", ")})`);
111
+ for (const row of rows)
112
+ insert.run(...columns.map((column) => row[column]));
113
+ }
114
+ function sqliteTableExists(db, table) {
115
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
116
+ }
117
+ function quoteSqliteIdentifier(value) {
118
+ return `"${value.replaceAll("\"", "\"\"")}"`;
119
+ }
36
120
  // ---------------------------------------------------------------------------
37
121
  // Canonical JSON and hashing
38
122
  // ---------------------------------------------------------------------------
@@ -157,6 +241,7 @@ function tasksOf(state) {
157
241
  * sidecar database file.
158
242
  */
159
243
  export function populateSqliteFromState(home, state, databaseFilename) {
244
+ const retiredActiveRuns = [];
160
245
  const store = new SqliteTaskStore(home, { databaseFilename, migration: true });
161
246
  try {
162
247
  store.transaction(() => {
@@ -234,6 +319,17 @@ export function populateSqliteFromState(home, state, databaseFilename) {
234
319
  // Active-run pointers: the document stores { schemaVersion, runId }
235
320
  // keyed by pointer; the store derives the pointer from the Run.
236
321
  for (const [pointer, value] of Object.entries(stored.activeRuns)) {
322
+ if (stored.task.status === "retired") {
323
+ retiredActiveRuns.push({
324
+ taskId,
325
+ pointer,
326
+ value,
327
+ updatedAt: typeof stored.task.updatedAt === "string"
328
+ ? stored.task.updatedAt
329
+ : new Date().toISOString()
330
+ });
331
+ continue;
332
+ }
237
333
  const run = stored.agentRuns[value.runId];
238
334
  if (run === undefined) {
239
335
  throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${value.runId}.`);
@@ -291,6 +387,33 @@ export function populateSqliteFromState(home, state, databaseFilename) {
291
387
  finally {
292
388
  store.close();
293
389
  }
390
+ persistRetiredActiveRunPointers(home, databaseFilename, retiredActiveRuns);
391
+ }
392
+ /**
393
+ * A retired Task is an explicit isolation boundary. Its active-run rows are
394
+ * retained byte-for-byte at the logical record level even when their Run is
395
+ * missing; normal Tasks continue through the referentially strict store path.
396
+ */
397
+ function persistRetiredActiveRunPointers(home, databaseFilename, pointers) {
398
+ if (pointers.length === 0)
399
+ return;
400
+ const db = new Database(join(home, databaseFilename));
401
+ try {
402
+ const insert = db.prepare(`INSERT INTO active_runs (task_id, pointer, run_id, payload, updated_at)
403
+ VALUES (?, ?, ?, ?, ?)`);
404
+ db.transaction(() => {
405
+ for (const entry of pointers) {
406
+ if (typeof entry.value.runId !== "string") {
407
+ throw new Error(`Retired Task active run pointer ${entry.taskId}/${entry.pointer} `
408
+ + "cannot be represented because runId is not a string.");
409
+ }
410
+ insert.run(entry.taskId, entry.pointer, entry.value.runId, JSON.stringify(entry.value), entry.updatedAt);
411
+ }
412
+ })();
413
+ }
414
+ finally {
415
+ db.close();
416
+ }
294
417
  }
295
418
  /**
296
419
  * Seed `global_sequences` from the numeric suffixes of existing task and
@@ -534,8 +534,6 @@ Agent 环境变量绑定只保存进程环境变量名,不保存 secret 值;
534
534
  Yui 面向一台机器上的一个受信任本地用户。它的 Web/API 仅支持 loopback,不包含远程或多用户 Web、分布式协调、backup/import/export、trash/restore、derived index、recovery journal、runtime lease、inactivity TTL、cooldown 或 recurring schedule。
535
535
 
536
536
  持久化和调度细节见 [ARCHITECTURE.md](../ARCHITECTURE.md)。
537
- 可复用的用户视角验收方案见
538
- [Operator 路由与长期任务端到端测试方案](../docs/testing/operator-routing-e2e-plan.md)。
539
537
 
540
538
  ## 本地开发
541
539
 
@@ -546,6 +544,11 @@ npm test
546
544
  npm run lint
547
545
  ```
548
546
 
547
+ `npm test` 只保留秒级核心 smoke:CLI 启动、正常 SQLite Task、受支持迁移和内置
548
+ Agent Driver。针对当前修改编写的 TDD、异常数据和故障复现仅作为开发期证据,需求完成后
549
+ 删除,不累积为常驻回归测试。具体约束见
550
+ [验证策略](../docs/testing/verification-levels.md)。
551
+
549
552
  如需让用户终端使用当前 checkout,可逆地接管用户级 `yui` 命令:
550
553
 
551
554
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,