@zaip-ai/cli 1.3.2 → 1.3.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.
Files changed (98) hide show
  1. package/LICENSE +6 -3
  2. package/README.md +8 -2
  3. package/dist/services/codex-pre-tool-hook-template.js +1 -1
  4. package/dist/services/runtime-template.test.js +9 -8
  5. package/dist/services/runtime-template.test.js.map +1 -1
  6. package/package.json +2 -2
  7. package/templates/.agents/dashboard/.zaip-managed.json +414 -394
  8. package/templates/.agents/dashboard/apps/frontend/dist/assets/{CostCalculationGuidePage-ovxurkIk.js → CostCalculationGuidePage-D1mpyYwh.js} +1 -1
  9. package/templates/.agents/dashboard/apps/frontend/dist/assets/{DashboardHelpDialog-BXy6nti1.js → DashboardHelpDialog-CKO47D5s.js} +30 -1
  10. package/templates/.agents/dashboard/apps/frontend/dist/assets/{PricingSettingsDialog-vSvvOHQp.js → PricingSettingsDialog-BuSlCbyZ.js} +1 -1
  11. package/templates/.agents/dashboard/apps/frontend/dist/assets/{ProjectSettingsPage-zH758vfs.js → ProjectSettingsPage-CUHFkAhZ.js} +1 -1
  12. package/templates/.agents/dashboard/apps/frontend/dist/assets/{TraceabilityWorkspace-CTH2wDs5.js → TraceabilityWorkspace-BmASq2gW.js} +1 -1
  13. package/templates/.agents/dashboard/apps/frontend/dist/assets/cost-monitor-Csj6LiAt.js +68 -0
  14. package/templates/.agents/dashboard/apps/frontend/dist/assets/{icons-vendor-B_lDDHn1.js → icons-vendor-VyTTl_ph.js} +1 -1
  15. package/templates/.agents/dashboard/apps/frontend/dist/assets/index-DIwDvCu5.js +2 -0
  16. package/templates/.agents/dashboard/apps/frontend/dist/assets/index-hPPfVGQv.css +1 -0
  17. package/templates/.agents/dashboard/apps/frontend/dist/assets/{react-vendor-B3TJrD4O.js → react-vendor-CTC6vtln.js} +1 -1
  18. package/templates/.agents/dashboard/apps/frontend/dist/assets/{traceability-7f1j5wx3.js → traceability-9Mtvbwrg.js} +1 -1
  19. package/templates/.agents/dashboard/apps/frontend/dist/index.html +6 -6
  20. package/templates/.agents/dashboard/apps/frontend/src/App.tsx +74 -10
  21. package/templates/.agents/dashboard/apps/frontend/src/AppRequestGuards.tsx +44 -0
  22. package/templates/.agents/dashboard/apps/frontend/src/cost-period-refresh.test.ts +103 -2
  23. package/templates/.agents/dashboard/apps/frontend/src/dashboard-capability-nav.css +11 -0
  24. package/templates/.agents/dashboard/apps/frontend/src/operations-period-contract.test.ts +14 -0
  25. package/templates/.agents/dashboard/apps/frontend/src/operations-period.test.ts +3 -6
  26. package/templates/.agents/dashboard/apps/frontend/src/operations-period.ts +3 -2
  27. package/templates/.agents/dashboard/apps/frontend/src/snapshot-coherence.test.ts +2 -2
  28. package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostDeveloperAttributionPanel.test.ts +60 -0
  29. package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostDeveloperAttributionPanel.tsx +26 -9
  30. package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostMonitorNavigation.tsx +189 -0
  31. package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostMonitorPage.test.ts +122 -2
  32. package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostMonitorPage.tsx +70 -155
  33. package/templates/.agents/dashboard/modules/operations/src/react/OperationsOverview.helpers.tsx +2 -1
  34. package/templates/.agents/dashboard/modules/operations/src/react/OperationsOverview.test.ts +12 -4
  35. package/templates/.agents/dashboard/modules/operations/src/react/OperationsOverview.tsx +67 -24
  36. package/templates/.agents/dashboard/modules/operations/src/react/OperationsPage.tsx +19 -4
  37. package/templates/.agents/dashboard/modules/operations/src/react/export/ExcelExportAction.test.ts +59 -0
  38. package/templates/.agents/dashboard/modules/operations/src/react/export/ExcelExportAction.tsx +55 -2
  39. package/templates/.agents/dashboard/modules/operations/src/react/operation-date-range.test.ts +25 -0
  40. package/templates/.agents/dashboard/modules/operations/src/react/operation-date-range.ts +29 -0
  41. package/templates/.agents/dashboard/modules/operations/src/react/operations-coverage-toast.test.ts +3 -2
  42. package/templates/.agents/dashboard/modules/operations/src/react/operations-overview-period-filter.test.ts +12 -2
  43. package/templates/.agents/dashboard/packages/local-runtime/dist/server.cjs +274 -246
  44. package/templates/.agents/dashboard/packages/local-runtime/dist/sqlite-read-model.cjs +255 -227
  45. package/templates/.agents/dashboard/packages/local-runtime/server.mjs +263 -235
  46. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-cost-dimension-facts.test.ts +282 -0
  47. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-cost-dimension-facts.ts +71 -0
  48. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model-launch-progress.ts +18 -0
  49. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model-stale-seed.ts +50 -0
  50. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model.test.ts +9 -6
  51. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model.ts +45 -73
  52. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-snapshot-seed.test.ts +79 -2
  53. package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-work-item-index.test.ts +56 -0
  54. package/templates/.agents/dashboard/packages/local-runtime/start-dashboard-launch-page.mjs +26 -8
  55. package/templates/.agents/dashboard/packages/local-runtime/start-dashboard-launch-page.test.mjs +45 -4
  56. package/templates/.agents/dashboard/packages/local-runtime/start-dashboard-lifecycle.mjs +58 -0
  57. package/templates/.agents/dashboard/packages/local-runtime/start-dashboard-lifecycle.test.mjs +81 -0
  58. package/templates/.agents/dashboard/packages/local-runtime/start-dashboard.mjs +171 -71
  59. package/templates/.agents/dashboard/packages/local-runtime/start-dashboard.test.mjs +152 -11
  60. package/templates/.agents/dashboard/packages/windows-launcher/Program.cs +4 -9
  61. package/templates/.agents/dashboard/tests/e2e/playwright.config.ts +9 -1
  62. package/templates/.agents/dashboard/tests/e2e/specs/cost-monitor-loading.spec.ts +1 -1
  63. package/templates/.agents/dashboard/tests/e2e/specs/cost-monitor-pagination.spec.ts +4 -0
  64. package/templates/.agents/dashboard/tests/e2e/specs/cost-monitor-period-transition.spec.ts +238 -0
  65. package/templates/.agents/dashboard/tests/e2e/specs/dashboard-performance-matrix.spec.ts +4 -0
  66. package/templates/.agents/dashboard/tests/e2e/specs/dashboard.spec.ts +60 -17
  67. package/templates/.agents/requirements/autonomous-assurance.md +1 -1
  68. package/templates/.agents/requirements/comparative-audit.md +1 -1
  69. package/templates/.agents/requirements/context-engine.md +1 -1
  70. package/templates/.agents/requirements/cost-observability.md +1 -1
  71. package/templates/.agents/requirements/dashboard-launch.md +1 -1
  72. package/templates/.agents/requirements/data-export.md +1 -1
  73. package/templates/.agents/requirements/design-system.md +1 -1
  74. package/templates/.agents/requirements/execution-engine.md +1 -1
  75. package/templates/.agents/requirements/kanban-dashboard.md +1 -1
  76. package/templates/.agents/requirements/model-orchestration.md +1 -1
  77. package/templates/.agents/requirements/public-distribution.md +1 -1
  78. package/templates/.agents/requirements/software-architecture.md +1 -1
  79. package/templates/.agents/requirements/strategic-improvements.md +1 -1
  80. package/templates/.agents/requirements/work-lifecycle.md +1 -1
  81. package/templates/.agents/requirements/zaip-strategic-95.md +1 -1
  82. package/templates/.agents/runtime/bin/zaip.mjs +7 -7
  83. package/templates/.agents/runtime/checksums.sha256 +3 -3
  84. package/templates/.agents/runtime/provenance.json +3 -3
  85. package/templates/.agents/runtime/sbom.cdx.json +1595 -3491
  86. package/templates/.agents/skills/zaip-skill-lifecycle-closure/SKILL.md +16 -0
  87. package/templates/.agents/skills/zaip-skill-reconcile-telemetry/scripts/reconcile-local-telemetry.mjs +19 -20
  88. package/templates/.agents/skills/zaip-skill-reconcile-telemetry/scripts/reconcile-local-telemetry.test.mjs +143 -0
  89. package/templates/.agents/skills/zaip-skill-reconcile-telemetry/scripts/reconcile-telemetry-lib.mjs +14 -0
  90. package/templates/.agents/skills/zaip-skill-reconcile-telemetry/scripts/reconcile-telemetry-remeasurement.mjs +3 -1
  91. package/templates/.agents/skills/zaip-skill-reconcile-telemetry/scripts/reconcile-telemetry-remeasurement.test.mjs +21 -0
  92. package/templates/.agents/skills/zaip-skill-reconcile-telemetry/scripts/source-size-telemetry/model-identity-reconciliation.mjs +3 -0
  93. package/templates/.agents/zaip-aios/guides/26-qualidade-e-release.md +29 -0
  94. package/templates/template-manifest.json +539 -935
  95. package/templates/.agents/dashboard/.tmp-run-list.json +0 -254
  96. package/templates/.agents/dashboard/apps/frontend/dist/assets/cost-monitor-DKwP1stV.js +0 -68
  97. package/templates/.agents/dashboard/apps/frontend/dist/assets/index-B31GMG5O.css +0 -1
  98. package/templates/.agents/dashboard/apps/frontend/dist/assets/index-CuMcoUni.js +0 -2
@@ -28,6 +28,7 @@ import {
28
28
  readDateOnlyCostDimensionPage,
29
29
  } from "./sqlite-cost-agent-page.js";
30
30
  import { buildCostDimensionAggregates } from "./sqlite-cost-aggregates.js";
31
+ import { rebuildCostInvocationDimensionFactsSql } from "./sqlite-cost-dimension-facts.js";
31
32
  import { type CostGroupKind, readCostGroupPage } from "./sqlite-cost-group-page.js";
32
33
  import { materializeCostGroupDailyProjection } from "./sqlite-cost-group-projection.js";
33
34
  import { filterCostInvocationProjection } from "./sqlite-cost-invocation-page.js";
@@ -54,7 +55,6 @@ import {
54
55
  import {
55
56
  installLegacyViewAliases,
56
57
  materializeSnapshot,
57
- mostRecentCompatibleSnapshot,
58
58
  pruneSnapshotCache,
59
59
  readSnapshotFileCount,
60
60
  } from "./sqlite-snapshot-cache.js";
@@ -64,15 +64,14 @@ export {
64
64
  sourceFingerprint,
65
65
  } from "./sqlite-read-model-source-fingerprint.js";
66
66
 
67
+ import { reportSqliteReadModelLaunchProgress } from "./sqlite-read-model-launch-progress.js";
67
68
  import { sourceFingerprint } from "./sqlite-read-model-source-fingerprint.js";
69
+ import { materializePreferredStaleSeed } from "./sqlite-read-model-stale-seed.js";
68
70
 
69
71
  const dbRelativePath = ".local/dashboard-sqlite/read-model.sqlite";
70
- // Schema 58 aligns Operations invocation/day facts with the same factual
71
- // source day used by tokens, Cost Monitor and attributed Tempo. Seed reuse is
72
- // safe only when both source and this schema version match.
73
- // Allocation rows are materialized after attributed-time reconciliation; a
74
- // prior seed contains the pre-reconciliation payload and is incompatible.
75
- const readModelSchemaVersion = "70";
72
+ // Schema 71 requires the canonical work-item identity index. Older seeds must
73
+ // rebuild rather than silently retaining the unindexed correlated lookups.
74
+ const readModelSchemaVersion = "71";
76
75
  const requiredPersistedTables = [
77
76
  "zaip_aios_activities",
78
77
  "zaip_dim_llm_model",
@@ -97,6 +96,7 @@ const requiredIndexes = [
97
96
  "zaip_activities_period_invocation",
98
97
  "zaip_activities_status",
99
98
  "zaip_activities_payload",
99
+ "zaip_activities_work_item_identity",
100
100
  "cost_dimension_facts_day_model",
101
101
  "cost_dimension_facts_day_provider",
102
102
  "cost_dimension_facts_invocation",
@@ -993,6 +993,16 @@ export function validateSqliteReadModelSchema(path: string): {
993
993
  }
994
994
  }
995
995
 
996
+ function hasReusableSnapshotPayloads(database: DatabaseSync): boolean {
997
+ const indexes = database
998
+ .prepare("SELECT name FROM sqlite_master WHERE type='index'")
999
+ .all() as Array<{
1000
+ name: string;
1001
+ }>;
1002
+ const names = new Set(indexes.map((index) => index.name));
1003
+ return requiredIndexes.every((name) => names.has(name)) && hasRequiredFirstFoldPayloads(database);
1004
+ }
1005
+
996
1006
  /** Recreates the disposable comparison read model from ZAIP records and configuration. */
997
1007
  export async function rebuildSqliteReadModel(
998
1008
  workspaceRoot: string,
@@ -1005,21 +1015,28 @@ export async function rebuildSqliteReadModel(
1005
1015
  materialize_ms: number;
1006
1016
  }> {
1007
1017
  const path = sqliteReadModelPath(workspaceRoot);
1018
+ const reportLaunchProgress = reportSqliteReadModelLaunchProgress;
1019
+ const cacheDirectory = process.env.ZAIP_SQLITE_CACHE_DIR ?? join(dirname(path), "cache");
1020
+ const preferredSeed = await materializePreferredStaleSeed({
1021
+ destination: path,
1022
+ enabled: !options.forceCanonical && process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED === "1",
1023
+ fingerprintMs: 0,
1024
+ hasRequiredPayloads: hasReusableSnapshotPayloads,
1025
+ reportProgress: true,
1026
+ schemaVersion: readModelSchemaVersion,
1027
+ });
1028
+ if (preferredSeed !== null) return preferredSeed;
1008
1029
  const fingerprintStartedAt = performance.now();
1030
+ reportLaunchProgress("source_fingerprint_started", 8, "Inventariando fontes canônicas.");
1009
1031
  const sourceSnapshot = await sourceFingerprint(workspaceRoot);
1010
- const reportLaunchProgress = (phase: string, percent: number, detail: string) => {
1011
- if (process.env.ZAIP_DASHBOARD_LAUNCH_PROGRESS !== "1") return;
1012
- process.stderr.write(
1013
- `${JSON.stringify({ event: "dashboard_sqlite_rebuild_progress", phase, percent, detail })}\n`,
1014
- );
1015
- };
1032
+ reportLaunchProgress("source_fingerprint_ready", 20, "Inventário canônico concluído.");
1016
1033
  const { fingerprint } = sourceSnapshot;
1017
1034
  const fingerprintMs = Math.round(performance.now() - fingerprintStartedAt);
1018
1035
  const existingFiles = readSnapshotFileCount(
1019
1036
  path,
1020
1037
  fingerprint,
1021
1038
  readModelSchemaVersion,
1022
- hasRequiredFirstFoldPayloads,
1039
+ hasReusableSnapshotPayloads,
1023
1040
  );
1024
1041
  if (existingFiles !== null) {
1025
1042
  validateSqliteReadModelSchema(path);
@@ -1037,7 +1054,7 @@ export async function rebuildSqliteReadModel(
1037
1054
  // source fingerprint, then atomically materialize that cache into the
1038
1055
  // generation path. A normal fresh-port handoff can therefore be warm.
1039
1056
  const cachePath = join(
1040
- process.env.ZAIP_SQLITE_CACHE_DIR ?? join(dirname(path), "cache"),
1057
+ cacheDirectory,
1041
1058
  // The cache payload is part of the immutable generation contract. Keep
1042
1059
  // a separate namespace when that payload changes, so a schema-compatible
1043
1060
  // older seed cannot bypass the new all-time precomputation.
@@ -1049,7 +1066,7 @@ export async function rebuildSqliteReadModel(
1049
1066
  cachePath,
1050
1067
  fingerprint,
1051
1068
  readModelSchemaVersion,
1052
- hasRequiredFirstFoldPayloads,
1069
+ hasReusableSnapshotPayloads,
1053
1070
  );
1054
1071
  if (cachedFiles !== null) {
1055
1072
  const materializeStartedAt = performance.now();
@@ -1068,24 +1085,14 @@ export async function rebuildSqliteReadModel(
1068
1085
  // reconstruction merely because a record changed since the last snapshot.
1069
1086
  // The launcher labels this state as updating and starts an isolated refresh;
1070
1087
  // this process still serves only a schema-validated immutable seed.
1071
- if (!options.forceCanonical && process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED === "1") {
1072
- const fallback = await mostRecentCompatibleSnapshot(
1073
- dirname(cachePath),
1074
- readModelSchemaVersion,
1075
- hasRequiredFirstFoldPayloads,
1076
- );
1077
- if (fallback !== null) {
1078
- const materializeStartedAt = performance.now();
1079
- await materializeSnapshot(fallback.path, path);
1080
- return {
1081
- files: fallback.files,
1082
- path,
1083
- cache: "stale_seed",
1084
- fingerprint_ms: fingerprintMs,
1085
- materialize_ms: Math.round(performance.now() - materializeStartedAt),
1086
- };
1087
- }
1088
- }
1088
+ const fallback = await materializePreferredStaleSeed({
1089
+ destination: path,
1090
+ enabled: !options.forceCanonical && process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED === "1",
1091
+ fingerprintMs,
1092
+ hasRequiredPayloads: hasReusableSnapshotPayloads,
1093
+ schemaVersion: readModelSchemaVersion,
1094
+ });
1095
+ if (fallback !== null) return fallback;
1089
1096
 
1090
1097
  reportLaunchProgress(
1091
1098
  "source_inventory",
@@ -2123,6 +2130,8 @@ export async function rebuildSqliteReadModel(
2123
2130
  }),
2124
2131
  );
2125
2132
  }
2133
+ // Keep canonical TEMP references outside the public-view name rewriter.
2134
+ database.exec(rebuildCostInvocationDimensionFactsSql);
2126
2135
  const viewSql = `
2127
2136
  CREATE INDEX zaip_activities_agent_run ON zaip_aios_activities(activity_source_kind, agent_run_id, occurred_day_sp, occurred_at DESC);
2128
2137
  CREATE INDEX zaip_activities_relationships ON zaip_aios_activities(task_id, spec_id, prd_id, execution_id, conversation_id);
@@ -2140,6 +2149,7 @@ export async function rebuildSqliteReadModel(
2140
2149
  WHERE agent_invocation_id IS NOT NULL;
2141
2150
  CREATE INDEX zaip_activities_status ON zaip_aios_activities(activity_source_kind, status, phase);
2142
2151
  CREATE INDEX zaip_activities_payload ON zaip_aios_activities(payload_type, activity_key);
2152
+ CREATE INDEX zaip_activities_work_item_identity ON zaip_aios_activities(json_extract(payload_json, '$.entity_kind'), json_extract(payload_json, '$.id')) WHERE payload_type='work_item';
2143
2153
  CREATE VIEW files AS SELECT json_extract(payload_json, '$.path') AS path, json_extract(payload_json, '$.content') AS content, json_extract(payload_json, '$.mtime_ms') AS mtime_ms, json_extract(payload_json, '$.size_bytes') AS size_bytes, json_extract(payload_json, '$.content_sha256') AS content_sha256, json_extract(payload_json, '$.artifact_kind') AS artifact_kind, json_extract(payload_json, '$.artifact_id') AS artifact_id, json_extract(payload_json, '$.artifact_occurred_at') AS artifact_occurred_at, json_extract(payload_json, '$.artifact_occurred_day_sp') AS artifact_occurred_day_sp, json_extract(payload_json, '$.artifact_task_id') AS artifact_task_id, json_extract(payload_json, '$.artifact_change_id') AS artifact_change_id, json_extract(payload_json, '$.artifact_run_id') AS artifact_run_id FROM zaip_aios_activities WHERE payload_type='file';
2144
2154
  CREATE VIEW read_model_source_state AS SELECT json_extract(payload_json, '$.source_path') AS source_path, json_extract(payload_json, '$.source_hash') AS source_hash, json_extract(payload_json, '$.mtime_ms') AS mtime_ms, json_extract(payload_json, '$.size_bytes') AS size_bytes, json_extract(payload_json, '$.source_kind') AS source_kind, json_extract(payload_json, '$.last_processed_at') AS last_processed_at FROM zaip_aios_activities WHERE payload_type='source_state';
2145
2155
  CREATE VIEW read_model_metadata AS SELECT json_extract(payload_json, '$.key') AS key, json_extract(payload_json, '$.value') AS value FROM zaip_aios_activities WHERE payload_type='metadata';
@@ -2182,44 +2192,6 @@ export async function rebuildSqliteReadModel(
2182
2192
  CREATE VIEW sub_runs AS SELECT * FROM activities WHERE run_role = 'subrun';
2183
2193
  CREATE VIEW invocations AS SELECT activity_row_id, activity_key, version_activity_id, source_occurred_at, root_agent_run_id AS root_agent_invocation_id, parent_agent_run_id AS parent_agent_invocation_id, execution_id, task_id, spec_id, prd_id, change_id, conversation_id, run_id, occurred_at, occurred_day_sp, invocation_kind, delegation_depth, zaip_agent, zaip_agent_persona, zaip_agent_job, zaip_agent_role, client, provider, model, phase, purpose, status, source_started_at, source_finished_at, attributed_token_active_ms, attributed_token_active_ms AS duration_ms, CASE WHEN attributed_token_active_ms IS NOT NULL THEN 'observed' ELSE 'unavailable' END AS duration_provenance, CASE WHEN attributed_token_active_ms IS NOT NULL THEN 'attributed_token_active' ELSE NULL END AS duration_fact_provenance, input_tokens, cached_input_tokens, output_tokens, reasoning_tokens, total_tokens, token_source, cost_usd, cost_brl, cost_method, pricing_model, pricing_source, fx_rate, service_tier, reasoning_effort, source_event_count, usage_event_count, llm_call_count, agent_run_id AS agent_invocation_id FROM agent_runs;
2184
2194
  CREATE VIEW invocation_usage AS SELECT agent_invocation_id, agent_invocation_id AS usage_index, provider, model, service_tier, reasoning_effort, input_tokens, cached_input_tokens, uncached_input_tokens, output_tokens, reasoning_tokens, visible_output_tokens, total_tokens, input_rate_usd_per_million, cached_input_rate_usd_per_million, output_rate_usd_per_million, reasoning_rate_usd_per_million, context_input_multiplier, context_output_multiplier, speed_cost_multiplier, pricing_scenario, standard_cost_usd, speed_uplift_usd, input_cost_usd, cached_input_cost_usd, output_cost_usd, reasoning_cost_usd, calculated_cost_usd, recorded_cost_usd, cost_variance_usd, cost_usd, cost_brl, cost_method, pricing_model, pricing_source, fx_rate FROM invocation_usage_segments;
2185
- -- Flatten the small financial dimension fact once during rebuild.
2186
- -- Models/Providers must never execute JSON extraction for each paint.
2187
- CREATE TABLE cost_invocation_dimension_facts AS
2188
- SELECT i.agent_invocation_id, date(COALESCE(i.source_started_at, i.source_occurred_at, i.occurred_at), '-3 hours') AS occurred_day_sp,
2189
- CASE WHEN u.model IS NULL OR trim(u.model)='' OR u.model='unavailable' THEN 'unavailable' ELSE u.model END AS model,
2190
- CASE WHEN u.provider IS NULL OR trim(u.provider)='' OR u.provider='unavailable' THEN 'unavailable' ELSE u.provider END AS provider,
2191
- u.input_tokens, u.cached_input_tokens, u.output_tokens, u.reasoning_tokens,
2192
- u.total_tokens, u.cost_usd, u.cost_brl, i.attributed_token_active_ms AS duration_ms, 0 AS residual
2193
- FROM invocations i JOIN invocation_usage u USING(agent_invocation_id)
2194
- WHERE u.total_tokens IS NOT NULL OR u.cost_usd IS NOT NULL OR u.cost_brl IS NOT NULL;
2195
- CREATE INDEX cost_dimension_facts_day_model ON cost_invocation_dimension_facts(occurred_day_sp, model);
2196
- CREATE INDEX cost_dimension_facts_day_provider ON cost_invocation_dimension_facts(occurred_day_sp, provider);
2197
- CREATE INDEX cost_dimension_facts_invocation ON cost_invocation_dimension_facts(agent_invocation_id);
2198
- INSERT INTO cost_invocation_dimension_facts
2199
- SELECT i.agent_invocation_id, date(COALESCE(i.source_started_at, i.source_occurred_at, i.occurred_at), '-3 hours') AS occurred_day_sp, 'unavailable', 'unavailable',
2200
- i.input_tokens-COALESCE(s.input_tokens,0), i.cached_input_tokens-COALESCE(s.cached_input_tokens,0),
2201
- i.output_tokens-COALESCE(s.output_tokens,0), i.reasoning_tokens-COALESCE(s.reasoning_tokens,0),
2202
- i.total_tokens-COALESCE(s.total_tokens,0), i.cost_usd-COALESCE(s.cost_usd,0),
2203
- i.cost_brl-COALESCE(s.cost_brl,0), i.attributed_token_active_ms, 1
2204
- FROM invocations i LEFT JOIN (
2205
- SELECT agent_invocation_id, SUM(input_tokens) input_tokens, SUM(cached_input_tokens) cached_input_tokens,
2206
- SUM(output_tokens) output_tokens, SUM(reasoning_tokens) reasoning_tokens, SUM(total_tokens) total_tokens,
2207
- SUM(cost_usd) cost_usd, SUM(cost_brl) cost_brl
2208
- FROM cost_invocation_dimension_facts GROUP BY agent_invocation_id
2209
- ) s USING(agent_invocation_id)
2210
- WHERE i.total_tokens<>COALESCE(s.total_tokens,0) OR i.cost_usd<>COALESCE(s.cost_usd,0) OR i.cost_brl<>COALESCE(s.cost_brl,0);
2211
- -- Financial activities outside Agent Invocation telemetry are still
2212
- -- factual Cost Monitor facts. Give them a stable synthetic key and
2213
- -- retain them as the explicit no-dimension population.
2214
- INSERT INTO cost_invocation_dimension_facts
2215
- SELECT 'activity:' || a.activity_row_id, a.occurred_day_sp, 'unavailable', 'unavailable',
2216
- a.input_tokens, a.cached_input_tokens, a.output_tokens, a.reasoning_tokens,
2217
- a.total_tokens, a.cost_usd, a.cost_brl, f.attributed_token_active_ms, 1
2218
- FROM zaip_aios_activities a
2219
- LEFT JOIN attributed_token_active_facts f USING(activity_row_id)
2220
- WHERE a.payload_type IS NULL AND a.activity_source_kind <> 'agent_run'
2221
- AND (a.total_tokens IS NOT NULL OR a.cost_usd IS NOT NULL OR a.cost_brl IS NOT NULL)
2222
- AND NOT EXISTS (SELECT 1 FROM cost_invocation_dimension_facts d WHERE d.agent_invocation_id='activity:' || a.activity_row_id);
2223
2195
  CREATE VIEW work_items AS SELECT entity_kind, id, title, status, risk, occurred_at, occurred_day_sp FROM canonical_work_items UNION ALL SELECT 'task', task_id, COALESCE(MAX(title), task_id), MAX(work_item_status), MAX(risk), MIN(occurred_at), MIN(occurred_day_sp) FROM activities WHERE task_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM canonical_work_items c WHERE c.entity_kind='task' AND c.id=activities.task_id) GROUP BY task_id UNION ALL SELECT 'spec', spec_id, COALESCE(MAX(title), spec_id), MAX(work_item_status), MAX(risk), MIN(occurred_at), MIN(occurred_day_sp) FROM activities WHERE spec_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM canonical_work_items c WHERE c.entity_kind='spec' AND c.id=activities.spec_id) GROUP BY spec_id UNION ALL SELECT 'prd', prd_id, COALESCE(MAX(title), prd_id), MAX(work_item_status), MAX(risk), MIN(occurred_at), MIN(occurred_day_sp) FROM activities WHERE prd_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM canonical_work_items c WHERE c.entity_kind='prd' AND c.id=activities.prd_id) GROUP BY prd_id;
2224
2196
  CREATE VIEW tasks AS SELECT id, title, status, risk, occurred_at, occurred_day_sp FROM work_items WHERE entity_kind = 'task';
2225
2197
  CREATE VIEW specs AS SELECT id, title, status, risk, occurred_at, occurred_day_sp FROM work_items WHERE entity_kind = 'spec';
@@ -3167,7 +3139,7 @@ export async function rebuildSqliteReadModel(
3167
3139
  cachePath,
3168
3140
  fingerprint,
3169
3141
  readModelSchemaVersion,
3170
- hasRequiredFirstFoldPayloads,
3142
+ hasReusableSnapshotPayloads,
3171
3143
  ) === null
3172
3144
  ) {
3173
3145
  await rm(temporaryPath, { force: true });
@@ -2,9 +2,13 @@ import { copyFile, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promis
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { hasRequiredFirstFoldPayloads } from "./sqlite-first-fold-persistence.js";
7
- import { rebuildSqliteReadModel, SqliteReadModelFileSystem } from "./sqlite-read-model.js";
7
+ import {
8
+ rebuildSqliteReadModel,
9
+ SqliteReadModelFileSystem,
10
+ validateSqliteReadModelSchema,
11
+ } from "./sqlite-read-model.js";
8
12
 
9
13
  const roots: string[] = [];
10
14
 
@@ -13,6 +17,45 @@ afterEach(async () => {
13
17
  });
14
18
 
15
19
  describe("SQLite snapshot seed publication", () => {
20
+ it.each([
21
+ "old-schema",
22
+ "missing-index",
23
+ ])("rebuilds incompatible %s generations and stale seeds", async (damage) => {
24
+ const root = await mkdtemp(join(tmpdir(), "zaip-snapshot-index-upgrade-"));
25
+ roots.push(root);
26
+ await mkdir(join(root, ".agents", "config"), { recursive: true });
27
+ await writeFile(join(root, ".agents", "config", "project.yaml"), "zaip:\n enabled: true\n");
28
+ const destination = join(root, ".local", "dashboard-sqlite", "read-model.sqlite");
29
+ const cacheDirectory = join(root, ".local", "dashboard-sqlite", "cache");
30
+ vi.stubEnv("ZAIP_SQLITE_DB_PATH", destination);
31
+ vi.stubEnv("ZAIP_SQLITE_CACHE_DIR", cacheDirectory);
32
+ vi.stubEnv("ZAIP_SQLITE_PREFER_LAST_VALID_SEED", "1");
33
+ try {
34
+ await expect(rebuildSqliteReadModel(root)).resolves.toMatchObject({ cache: "rebuilt" });
35
+ const seedName = (await readdir(cacheDirectory)).find((name) => name.endsWith(".sqlite"));
36
+ if (seedName === undefined) throw new Error("Expected the rebuilt seed");
37
+ for (const path of [destination, join(cacheDirectory, seedName)]) {
38
+ const database = new DatabaseSync(path);
39
+ try {
40
+ if (damage === "missing-index")
41
+ database.exec("DROP INDEX zaip_activities_work_item_identity");
42
+ else
43
+ database.exec(
44
+ "UPDATE zaip_aios_activities SET payload_json=json_set(payload_json, '$.value', '70') WHERE payload_type='metadata' AND json_extract(payload_json, '$.key')='schema_version'",
45
+ );
46
+ } finally {
47
+ database.close();
48
+ }
49
+ }
50
+ await expect(rebuildSqliteReadModel(root)).resolves.toMatchObject({ cache: "rebuilt" });
51
+ expect(() => validateSqliteReadModelSchema(destination)).not.toThrow();
52
+ await expect(rebuildSqliteReadModel(root)).resolves.toMatchObject({ cache: "stale_seed" });
53
+ expect(() => validateSqliteReadModelSchema(destination)).not.toThrow();
54
+ } finally {
55
+ vi.unstubAllEnvs();
56
+ }
57
+ });
58
+
16
59
  it("skips a newer incomplete seed and materializes the prior complete cache", async () => {
17
60
  const root = await mkdtemp(join(tmpdir(), "zaip-snapshot-seed-"));
18
61
  roots.push(root);
@@ -58,6 +101,40 @@ describe("SQLite snapshot seed publication", () => {
58
101
  }
59
102
  });
60
103
 
104
+ it("uses a compatible stale seed before source inventory when launcher policy requests it", async () => {
105
+ const root = await mkdtemp(join(tmpdir(), "zaip-snapshot-seed-early-"));
106
+ roots.push(root);
107
+ await mkdir(join(root, ".agents", "records", "tasks"), { recursive: true });
108
+ await mkdir(join(root, ".agents", "config"), { recursive: true });
109
+ await writeFile(join(root, ".agents", "config", "project.yaml"), "zaip:\n enabled: true\n");
110
+ await writeFile(join(root, ".agents", "records", "tasks", "task.md"), "# initial\n");
111
+ await rebuildSqliteReadModel(root);
112
+ await writeFile(join(root, ".agents", "records", "tasks", "task.md"), "# changed\n");
113
+ const previousPath = process.env.ZAIP_SQLITE_DB_PATH;
114
+ const previousSeed = process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED;
115
+ const previousProgress = process.env.ZAIP_DASHBOARD_LAUNCH_PROGRESS;
116
+ const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
117
+ try {
118
+ process.env.ZAIP_SQLITE_DB_PATH = join(root, ".local", "dashboard-sqlite", "served.sqlite");
119
+ process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED = "1";
120
+ process.env.ZAIP_DASHBOARD_LAUNCH_PROGRESS = "1";
121
+ await expect(rebuildSqliteReadModel(root)).resolves.toMatchObject({
122
+ cache: "stale_seed",
123
+ fingerprint_ms: 0,
124
+ });
125
+ expect(stderr.mock.calls.join("\n")).toContain('"phase":"stale_seed_ready"');
126
+ expect(stderr.mock.calls.join("\n")).not.toContain('"phase":"source_fingerprint_started"');
127
+ } finally {
128
+ stderr.mockRestore();
129
+ if (previousPath === undefined) delete process.env.ZAIP_SQLITE_DB_PATH;
130
+ else process.env.ZAIP_SQLITE_DB_PATH = previousPath;
131
+ if (previousSeed === undefined) delete process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED;
132
+ else process.env.ZAIP_SQLITE_PREFER_LAST_VALID_SEED = previousSeed;
133
+ if (previousProgress === undefined) delete process.env.ZAIP_DASHBOARD_LAUNCH_PROGRESS;
134
+ else process.env.ZAIP_DASHBOARD_LAUNCH_PROGRESS = previousProgress;
135
+ }
136
+ });
137
+
61
138
  it("rejects a seed whose board payload is truncated below canonical work", async () => {
62
139
  const root = await mkdtemp(join(tmpdir(), "zaip-snapshot-truncated-"));
63
140
  roots.push(root);
@@ -0,0 +1,56 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { rebuildSqliteReadModel, SqliteReadModelFileSystem } from "./sqlite-read-model.js";
7
+
8
+ const roots: string[] = [];
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
12
+ });
13
+
14
+ describe("SQLite work-item identity index", () => {
15
+ it("uses the partial JSON identity index without changing canonical work-list IDs", async () => {
16
+ const root = await mkdtemp(join(tmpdir(), "zaip-work-item-index-"));
17
+ roots.push(root);
18
+ await mkdir(join(root, ".agents", "config"), { recursive: true });
19
+ await mkdir(join(root, ".agents", "records", "tasks"), { recursive: true });
20
+ await writeFile(join(root, ".agents", "config", "project.yaml"), "zaip:\n enabled: true\n");
21
+ await writeFile(
22
+ join(root, ".agents", "records", "tasks", "TASK-INDEXED.md"),
23
+ "---\nid: TASK-INDEXED\ntitle: Indexed canonical task\nstatus: ready\nrisk: standard\ncreated_at: 2026-08-31T12:00:00.000Z\n---\n# Indexed canonical task\n",
24
+ );
25
+ const previousDbPath = process.env.ZAIP_SQLITE_DB_PATH;
26
+ process.env.ZAIP_SQLITE_DB_PATH = join(root, ".local", "dashboard-sqlite", "read-model.sqlite");
27
+ let filesystem: SqliteReadModelFileSystem | undefined;
28
+ try {
29
+ await rebuildSqliteReadModel(root);
30
+ filesystem = new SqliteReadModelFileSystem(root);
31
+ expect(
32
+ filesystem
33
+ .viewPage("operations_work_list", { limit: 10, offset: 0 })
34
+ .rows.map((row) => row.id),
35
+ ).toContain("TASK-INDEXED");
36
+ const database = new DatabaseSync(process.env.ZAIP_SQLITE_DB_PATH, { readOnly: true });
37
+ try {
38
+ const plan = database
39
+ .prepare(`EXPLAIN QUERY PLAN
40
+ SELECT a.task_id FROM zaip_vw_activities a WHERE a.task_id IS NOT NULL
41
+ AND NOT EXISTS (SELECT 1 FROM zaip_vw_canonical_work_items c WHERE c.entity_kind='task' AND c.id=a.task_id)
42
+ GROUP BY a.task_id`)
43
+ .all() as Array<{ detail: string }>;
44
+ expect(plan.map((row) => row.detail).join("\n")).toContain(
45
+ "zaip_activities_work_item_identity",
46
+ );
47
+ } finally {
48
+ database.close();
49
+ }
50
+ } finally {
51
+ filesystem?.close();
52
+ if (previousDbPath === undefined) delete process.env.ZAIP_SQLITE_DB_PATH;
53
+ else process.env.ZAIP_SQLITE_DB_PATH = previousDbPath;
54
+ }
55
+ });
56
+ });
@@ -8,6 +8,8 @@ export async function createDashboardLaunchPage({ port, host, runtimeKind, healt
8
8
  phase: "Iniciando o launcher local",
9
9
  detail: "Abrindo a página de preparação.",
10
10
  };
11
+ let progressSteps = [{ phase: progress.phase, detail: progress.detail }];
12
+ progress = { ...progress, steps: progressSteps };
11
13
  const connections = new Set();
12
14
  let resolveRedirectObserved;
13
15
  const redirectObserved = new Promise((resolveObserved) => {
@@ -49,18 +51,26 @@ export async function createDashboardLaunchPage({ port, host, runtimeKind, healt
49
51
  <style>
50
52
  :root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#010610;color:#f1f5f9}
51
53
  *{box-sizing:border-box}body{margin:0;background:#010610}.launch-overlay{position:fixed;inset:0;width:100vw;height:100vh;display:grid;place-items:center;padding:clamp(18px,5vw,72px);background:radial-gradient(circle at 50% 35%,rgba(0,176,240,.11),transparent 42%),#010610}
52
- .progress{width:min(100%,760px);padding:clamp(24px,4vw,42px);border:1px solid rgba(125,211,252,.2);border-radius:20px;background:rgba(3,9,22,.94);box-shadow:0 24px 80px rgba(0,0,0,.45);backdrop-filter:blur(10px)}.progress strong,.progress span:not(.handoff):not(.failure){display:block}.progress strong{font-size:clamp(1.05rem,2vw,1.35rem)}.progress span{color:#b6c6db;font-size:clamp(.88rem,1.5vw,1rem);margin-top:7px}.track{position:relative;height:9px;margin-top:20px;border-radius:999px;background:#172033;overflow:hidden}.track::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent 0%,rgba(224,242,254,.14) 35%,rgba(224,242,254,.8) 50%,rgba(224,242,254,.14) 65%,transparent 100%);transform:translateX(-100%);animation:launch-activity 1.35s ease-in-out infinite;pointer-events:none}.progress.terminal-failure .track::after{animation:none;transform:none;background:rgba(254,202,202,.2)}.bar{position:relative;z-index:1;height:100%;width:4%;border-radius:inherit;background:linear-gradient(90deg,#38bdf8,#818cf8);transition:width .35s ease}@keyframes launch-activity{to{transform:translateX(100%)}}@media (prefers-reduced-motion:reduce){.track::after{animation:none;transform:none;background:linear-gradient(90deg,transparent 0%,rgba(224,242,254,.2) 50%,transparent 100%)}}.handoff,.failure{display:none;margin-top:16px;color:#dbeafe}.failure{color:#fecaca}
54
+ .progress{width:min(100%,760px);padding:clamp(24px,4vw,42px);border:1px solid rgba(125,211,252,.2);border-radius:20px;background:rgba(3,9,22,.94);box-shadow:0 24px 80px rgba(0,0,0,.45);backdrop-filter:blur(10px)}.progress strong,.progress span:not(.handoff):not(.failure){display:block}.progress strong{font-size:clamp(1.05rem,2vw,1.35rem)}.progress span{color:#b6c6db;font-size:clamp(.88rem,1.5vw,1rem);margin-top:7px}.track{position:relative;height:9px;margin-top:20px;border-radius:999px;background:#172033;overflow:hidden}.track::after{content:"";position:absolute;z-index:2;inset:0;background:linear-gradient(90deg,transparent 0%,rgba(224,242,254,.14) 35%,rgba(224,242,254,.8) 50%,rgba(224,242,254,.14) 65%,transparent 100%);transform:translateX(-100%);animation:launch-activity 1.35s ease-in-out infinite;pointer-events:none}.progress.terminal-failure .track::after{animation:none;transform:none;background:rgba(254,202,202,.2)}.progress.terminal-failure .bar{background:#f87171}.bar{position:relative;z-index:1;height:100%;width:4%;border-radius:inherit;background:linear-gradient(90deg,#38bdf8,#818cf8);transition:width .35s ease}@keyframes launch-activity{to{transform:translateX(100%)}}@media (prefers-reduced-motion:reduce){.track::after{animation:none;transform:none;background:linear-gradient(90deg,transparent 0%,rgba(224,242,254,.2) 50%,transparent 100%)}}.handoff,.failure{display:none;margin-top:16px;color:#dbeafe}.failure{color:#fecaca}
55
+ .activity{min-height:1.3em}.steps{display:grid;gap:7px;max-height:12.5rem;overflow:auto;margin:18px 0 0;padding:0;list-style:none}.steps li{position:relative;padding-left:20px;color:#94a3b8;font-size:.82rem}.steps li::before{content:"";position:absolute;left:1px;top:.4em;width:8px;height:8px;border-radius:50%;background:#475569}.steps li[data-state="active"]{color:#dbeafe}.steps li[data-state="active"]::before{background:#38bdf8;box-shadow:0 0 0 4px rgba(56,189,248,.16)}.progress.terminal-failure .steps li[data-state="active"]::before{background:#f87171}@media (prefers-reduced-motion:reduce){.bar{transition:none}}
53
56
  </style>
54
57
  </head>
55
58
  <body>
56
- <main class="launch-overlay" aria-label="Preparando dashboard">
57
- <section class="progress" aria-live="polite" role="status"><strong id="phase">Iniciando o launcher local</strong><span id="detail">Abrindo a página de preparação.</span><div class="track" id="track" aria-label="Preparação em andamento"><div class="bar" id="bar"></div></div><span class="handoff" id="handoff">O dashboard está pronto. Abrindo a tela principal automaticamente…</span><span class="failure" id="failure" role="alert"></span></section>
59
+ <main class="launch-overlay" aria-label="Preparando dashboard" aria-busy="true">
60
+ <section class="progress" aria-live="polite" role="status"><strong id="phase">Iniciando o launcher local</strong><span id="detail">Abrindo a página de preparação.</span><span class="activity" id="activity" aria-hidden="true">Atividade em andamento.</span><div class="track" id="track" role="progressbar" aria-label="Preparação em andamento" aria-valuemin="0" aria-valuemax="100" aria-valuenow="4" aria-valuetext="Iniciando o launcher local"><div class="bar" id="bar"></div></div><ol class="steps" id="steps" aria-label="Etapas de preparação"><li data-state="active">Iniciando o launcher local</li></ol><span class="handoff" id="handoff">O dashboard está pronto. Abrindo a tela principal automaticamente…</span><span class="failure" id="failure" role="alert"></span></section>
58
61
  </main>
59
62
  <script>
60
- let handoffTarget=null,progressRequestInFlight=false,terminalFailure=false;
61
- function showHandoff(target){handoffTarget=target;document.getElementById("handoff").style.display="block";document.getElementById("phase").textContent="Dashboard pronto";document.getElementById("detail").textContent="Concluindo a abertura da tela principal."}
62
- function showFailure(next){terminalFailure=true;clearInterval(progressPoll);document.getElementById("phase").textContent=next.phase;document.getElementById("detail").textContent="A preparação não foi concluída. Consulte a mensagem abaixo para agir.";document.getElementById("bar").style.width="100%";document.getElementById("handoff").style.display="none";document.getElementById("failure").textContent=next.detail;document.getElementById("failure").style.display="block";document.querySelector(".progress").classList.add("terminal-failure")}
63
- async function updateProgress(){if(progressRequestInFlight||terminalFailure)return terminalFailure;progressRequestInFlight=true;try{const r=await fetch("/api/v2/progress",{cache:"no-store",signal:AbortSignal.timeout(1000)});const p=await r.json();if(terminalFailure)return true;if(p.failure){showFailure(p.failure);return true}if(!handoffTarget){document.getElementById("phase").textContent=p.phase;document.getElementById("detail").textContent=p.detail};if(!terminalFailure)document.getElementById("bar").style.width=Math.max(4,Math.min(100,Number(p.percent)||4))+"%"}catch{}finally{progressRequestInFlight=false}return terminalFailure}
63
+ let handoffTarget=null,progressRequestInFlight=false,terminalFailure=false,lastProgressPercent=4,compilationStartedAt=null,compilationDetail="",compilationTicker=null,activeStepKey="",activeStepStartedAt=Date.now();
64
+ const reducedMotion=window.matchMedia?.("(prefers-reduced-motion: reduce)").matches===true;
65
+ const recordedStepKeys=new Set(["Iniciando o launcher local\\u0000Abrindo a página de preparação."]);
66
+ function stopCompilationTicker(){if(compilationTicker!==null){clearInterval(compilationTicker);compilationTicker=null}compilationStartedAt=null}
67
+ function renderCompilationElapsed(){if(compilationStartedAt===null||handoffTarget||terminalFailure)return;const seconds=Math.floor((Date.now()-compilationStartedAt)/1000);document.getElementById("detail").textContent=compilationDetail+" · Compilação em andamento · "+seconds+"s"}
68
+ function renderActivityElapsed(){if(handoffTarget||terminalFailure)return;const seconds=Math.floor((Date.now()-activeStepStartedAt)/1000);document.getElementById("activity").textContent="Etapa atual em andamento · "+seconds+"s"}
69
+ function recordStep(step){if(!step||typeof step.phase!=="string")return;const detail=typeof step.detail==="string"?step.detail:"";const key=step.phase+"\\u0000"+detail;if(recordedStepKeys.has(key))return;recordedStepKeys.add(key);const list=document.getElementById("steps");for(const item of list.querySelectorAll("li[data-state=active]"))item.dataset.state="complete";const item=document.createElement("li");item.dataset.state="active";item.textContent=detail?step.phase+" — "+detail:step.phase;list.append(item);while(list.children.length>10)list.removeChild(list.firstElementChild);list.scrollTop=list.scrollHeight}
70
+ function setProgressBar(percent,phase){lastProgressPercent=percent;document.getElementById("bar").style.width=percent+"%";document.getElementById("track").setAttribute("aria-valuenow",String(percent));document.getElementById("track").setAttribute("aria-valuetext",phase)}
71
+ function showHandoff(target){handoffTarget=target;stopCompilationTicker();setProgressBar(100,"Dashboard pronto");document.querySelector(".launch-overlay").setAttribute("aria-busy","false");document.getElementById("handoff").style.display="block";document.getElementById("phase").textContent="Dashboard pronto";document.getElementById("detail").textContent="Concluindo a abertura da tela principal.";document.getElementById("activity").textContent="Preparação concluída."}
72
+ function showFailure(next,percent=lastProgressPercent){terminalFailure=true;clearInterval(progressPoll);stopCompilationTicker();document.querySelector(".launch-overlay").setAttribute("aria-busy","false");document.getElementById("phase").textContent=next.phase;document.getElementById("detail").textContent="A preparação não foi concluída. Consulte a mensagem abaixo para agir.";setProgressBar(percent,next.phase);document.getElementById("handoff").style.display="none";document.getElementById("failure").textContent=next.detail;document.getElementById("failure").style.display="block";document.querySelector(".progress").classList.add("terminal-failure")}
73
+ async function updateProgress(){if(progressRequestInFlight||terminalFailure)return terminalFailure;progressRequestInFlight=true;try{const r=await fetch("/api/v2/progress",{cache:"no-store",signal:AbortSignal.timeout(1000)});const p=await r.json();if(terminalFailure)return true;const percent=Math.max(4,Math.min(100,Number(p.percent)||4));if(p.failure){showFailure(p.failure,percent);return true}if(!handoffTarget){for(const step of Array.isArray(p.steps)?p.steps:[p])recordStep(step);const nextStepKey=p.phase+"\\u0000"+(p.detail??"");if(nextStepKey!==activeStepKey){activeStepKey=nextStepKey;activeStepStartedAt=Date.now()}document.getElementById("phase").textContent=p.phase;const compiling=p.phase.startsWith("Atualizando ");if(compiling){if(compilationStartedAt===null){compilationStartedAt=Date.now();compilationDetail=p.detail;if(!reducedMotion)compilationTicker=setInterval(renderCompilationElapsed,1000)}renderCompilationElapsed()}else{stopCompilationTicker();document.getElementById("detail").textContent=p.detail}renderActivityElapsed()}if(!terminalFailure&&!handoffTarget)setProgressBar(percent,p.phase)}catch{}finally{progressRequestInFlight=false}return terminalFailure}
64
74
  const progressPoll=setInterval(updateProgress,250);
65
75
  async function waitForDashboard(){try{if(await updateProgress())return;const ready=await fetch("/api/v2/ready",{cache:"no-store",signal:AbortSignal.timeout(1000)});const next=await ready.json();if(next.failure){showFailure(next.failure);return}if(next.target){showHandoff(next.target);clearInterval(progressPoll);setTimeout(()=>location.replace(next.target),0);return}const response=await fetch("${healthPath}",{cache:"no-store",signal:AbortSignal.timeout(1000)});const payload=await response.json();if(response.ok&&payload.kind==="${runtimeKind}"){clearInterval(progressPoll);location.reload();return}}catch{}setTimeout(waitForDashboard,250)}
66
76
  updateProgress();setTimeout(waitForDashboard,100)
@@ -86,10 +96,18 @@ export async function createDashboardLaunchPage({ port, host, runtimeKind, healt
86
96
  redirectUrl = target;
87
97
  },
88
98
  setProgress: (next) => {
99
+ const nextStep = {
100
+ phase: next.phase ?? progress.phase,
101
+ detail: next.detail ?? progress.detail,
102
+ };
103
+ const previousStep = progressSteps.at(-1);
104
+ if (previousStep?.phase !== nextStep.phase || previousStep.detail !== nextStep.detail)
105
+ progressSteps = [...progressSteps, nextStep].slice(-10);
89
106
  progress = {
90
107
  ...progress,
91
108
  ...next,
92
109
  percent: Math.max(progress.percent, next.percent ?? progress.percent),
110
+ steps: progressSteps,
93
111
  };
94
112
  },
95
113
  setFailure: (next) => {
@@ -97,7 +115,7 @@ export async function createDashboardLaunchPage({ port, host, runtimeKind, healt
97
115
  progress = {
98
116
  ...progress,
99
117
  ...next,
100
- percent: Math.max(progress.percent, next.percent ?? progress.percent),
118
+ percent: progress.percent,
101
119
  };
102
120
  },
103
121
  waitForRedirect: async (timeoutMs = 1_500) =>
@@ -16,7 +16,14 @@ describe("dashboard launch page", () => {
16
16
  expect(html).toContain("position:fixed;inset:0;width:100vw;height:100vh");
17
17
  expect(html).toContain("display:grid;place-items:center");
18
18
  expect(html).toContain('aria-live="polite" role="status"');
19
- expect(html).toContain('aria-label="Preparação em andamento"');
19
+ expect(html).toContain('role="progressbar" aria-label="Preparação em andamento"');
20
+ expect(html).toContain('aria-valuemin="0" aria-valuemax="100" aria-valuenow="4"');
21
+ expect(html).toContain('aria-busy="true"');
22
+ expect(html).toContain("z-index:2");
23
+ expect(html).toContain("Compilação em andamento");
24
+ expect(html).toContain('id="steps" aria-label="Etapas de preparação"');
25
+ expect(html).toContain('id="activity" aria-hidden="true"');
26
+ expect(html).toContain("function recordStep(step)");
20
27
  expect(html).toContain("animation:launch-activity 1.35s ease-in-out infinite");
21
28
  expect(html).toContain("@media (prefers-reduced-motion:reduce)");
22
29
  expect(html).toContain("O dashboard está pronto. Abrindo a tela principal automaticamente…");
@@ -27,6 +34,12 @@ describe("dashboard launch page", () => {
27
34
  await expect(progress.json()).resolves.toMatchObject({
28
35
  phase: "Iniciando o launcher local",
29
36
  percent: expect.any(Number),
37
+ steps: [
38
+ {
39
+ phase: "Iniciando o launcher local",
40
+ detail: "Abrindo a página de preparação.",
41
+ },
42
+ ],
30
43
  });
31
44
  expect(health.status).toBe(503);
32
45
  await expect(health.json()).resolves.toEqual({
@@ -62,10 +75,26 @@ describe("dashboard launch page", () => {
62
75
  page.locator("#handoff").evaluate((element) => getComputedStyle(element).display),
63
76
  )
64
77
  .toBe("none");
65
- launchPage.setProgress({ percent: 64, phase: "Atualizando progresso independente" });
78
+ launchPage.setProgress({
79
+ percent: 64,
80
+ phase: "Atualizando a interface",
81
+ detail: "Uma dependência deste artefato foi modificada.",
82
+ });
83
+ await expect.poll(() => page.locator("#phase").textContent()).toBe("Atualizando a interface");
84
+ await expect.poll(() => page.locator("#track").getAttribute("aria-valuenow")).toBe("64");
66
85
  await expect
67
- .poll(() => page.locator("#phase").textContent())
68
- .toBe("Atualizando progresso independente");
86
+ .poll(() => page.locator("#track").getAttribute("aria-valuetext"))
87
+ .toBe("Atualizando a interface");
88
+ await expect
89
+ .poll(() => page.locator("#detail").textContent())
90
+ .toContain("Compilação em andamento · 0s");
91
+ await expect.poll(() => page.locator("#steps li").count()).toBe(2);
92
+ await expect
93
+ .poll(() => page.locator("#steps li[data-state=active]").textContent())
94
+ .toContain("Atualizando a interface");
95
+ await expect
96
+ .poll(() => page.locator("#activity").textContent())
97
+ .toContain("Etapa atual em andamento");
69
98
 
70
99
  await page.evaluate(() => window.showHandoff("http://127.0.0.1:4417/?token=ready"));
71
100
  await expect
@@ -73,6 +102,9 @@ describe("dashboard launch page", () => {
73
102
  page.locator("#handoff").evaluate((element) => getComputedStyle(element).display),
74
103
  )
75
104
  .toBe("block");
105
+ await expect
106
+ .poll(() => page.locator(".launch-overlay").getAttribute("aria-busy"))
107
+ .toBe("false");
76
108
  } finally {
77
109
  await browser.close();
78
110
  await launchPage.close();
@@ -83,6 +115,11 @@ describe("dashboard launch page", () => {
83
115
  const launchPage = await startDashboardLaunchPage(0);
84
116
  const browser = await chromium.launch();
85
117
  try {
118
+ launchPage.setProgress({
119
+ percent: 64,
120
+ phase: "Atualizando o runtime local",
121
+ detail: "Uma dependência deste artefato foi modificada.",
122
+ });
86
123
  launchPage.setFailure({
87
124
  percent: 100,
88
125
  phase: "Não foi possível preparar o dashboard",
@@ -136,6 +173,10 @@ describe("dashboard launch page", () => {
136
173
  await expect
137
174
  .poll(() => page.locator("#bar").evaluate((element) => getComputedStyle(element).width))
138
175
  .not.toBe("4px");
176
+ await expect.poll(() => page.locator("#track").getAttribute("aria-valuenow")).toBe("64");
177
+ await expect
178
+ .poll(() => page.locator(".launch-overlay").getAttribute("aria-busy"))
179
+ .toBe("false");
139
180
 
140
181
  await launchPage.closeAfterFailureObservation(0);
141
182
  await expect
@@ -0,0 +1,58 @@
1
+ import { rename, writeFile } from "node:fs/promises";
2
+ import process from "node:process";
3
+
4
+ export function presentLaunchFailure(launchPage, error, formatError) {
5
+ if (launchPage === null) return null;
6
+ launchPage.setFailure({
7
+ percent: 100,
8
+ phase: "Não foi possível preparar o dashboard",
9
+ detail: formatError(error instanceof Error ? error.message : String(error), 900),
10
+ });
11
+ launchPage.closeAfterFailureObservation();
12
+ return null;
13
+ }
14
+
15
+ export function createPhaseTimer(appendLauncherLog, launcherPid) {
16
+ return (phase, startedAt, details = {}) => {
17
+ appendLauncherLog(
18
+ JSON.stringify({
19
+ event: "dashboard_sqlite_phase",
20
+ phase,
21
+ elapsed_ms: Date.now() - startedAt,
22
+ launcher_pid: launcherPid,
23
+ ...details,
24
+ }),
25
+ );
26
+ };
27
+ }
28
+
29
+ export async function writeLauncherStatus(status, message = "") {
30
+ const path = process.env.ZAIP_LAUNCH_STATUS;
31
+ if (!path) return;
32
+ const temporaryPath = `${path}.${process.pid}.tmp`;
33
+ await writeFile(temporaryPath, `${status}\n${message}`.trimEnd(), "utf8");
34
+ await rename(temporaryPath, path);
35
+ }
36
+
37
+ export async function awaitOwnedDashboardExit({
38
+ child,
39
+ currentSession,
40
+ clearOwnedDashboardSession,
41
+ recordPortStatus,
42
+ removeGeneratedReadModel,
43
+ processRef = process,
44
+ }) {
45
+ for (const signal of ["SIGINT", "SIGTERM"]) {
46
+ processRef.once(signal, () => child.kill(signal));
47
+ }
48
+ const exitCode = await new Promise((resolveExit, rejectExit) => {
49
+ child.once("error", rejectExit);
50
+ child.once("exit", (code) => resolveExit(code ?? 0));
51
+ });
52
+ await clearOwnedDashboardSession();
53
+ if (currentSession !== null) {
54
+ await recordPortStatus(currentSession, "closed");
55
+ await removeGeneratedReadModel(currentSession.dbPath);
56
+ }
57
+ return exitCode;
58
+ }