your-local-cost-explorer 0.0.0-stage → 0.4.0

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 (51) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/DESIGN.md +9 -0
  3. package/LICENSE +21 -0
  4. package/MANUAL-FEATURES.md +21 -0
  5. package/README.md +83 -2
  6. package/SOURCE-INVENTORY.json +127 -0
  7. package/bin/commitops-local.cjs +18 -0
  8. package/bin/runtime.cjs +81 -0
  9. package/engine/app/__init__.py +0 -0
  10. package/engine/app/core/__init__.py +0 -0
  11. package/engine/app/core/analysis_storage.py +245 -0
  12. package/engine/app/core/config.py +10 -0
  13. package/engine/app/core/resources.py +4 -0
  14. package/engine/app/growth/__init__.py +0 -0
  15. package/engine/app/growth/contracts.py +253 -0
  16. package/engine/app/pipeline/__init__.py +0 -0
  17. package/engine/app/pipeline/attribution.py +218 -0
  18. package/engine/app/pipeline/baseline.py +554 -0
  19. package/engine/app/pipeline/canonical.py +627 -0
  20. package/engine/app/pipeline/commitments.py +522 -0
  21. package/engine/app/pipeline/comparison.py +378 -0
  22. package/engine/app/pipeline/creep_policy.py +26 -0
  23. package/engine/app/pipeline/currency.py +35 -0
  24. package/engine/app/pipeline/detectors.py +1128 -0
  25. package/engine/app/pipeline/evidence.py +465 -0
  26. package/engine/app/pipeline/finding_amounts.py +211 -0
  27. package/engine/app/pipeline/history_facts.py +72 -0
  28. package/engine/app/pipeline/ingest.py +504 -0
  29. package/engine/app/pipeline/money.py +119 -0
  30. package/engine/app/pipeline/monthly_growth.py +203 -0
  31. package/engine/app/pipeline/reserved_instances.py +564 -0
  32. package/engine/app/pipeline/routing_tags.py +31 -0
  33. package/engine/app/pipeline/run.py +902 -0
  34. package/engine/app/pipeline/savings_plans.py +505 -0
  35. package/engine/app/pipeline/spend_history.py +133 -0
  36. package/engine/app/pipeline/string_source.py +68 -0
  37. package/engine/app/pipeline/types.py +632 -0
  38. package/engine/app/pipeline/validate.py +244 -0
  39. package/engine/app/services/__init__.py +0 -0
  40. package/engine/app/services/monthly_growth.py +144 -0
  41. package/engine/local_analysis.py +78 -0
  42. package/engine/local_app.py +507 -0
  43. package/engine/local_dates.py +35 -0
  44. package/engine/local_hardware.py +57 -0
  45. package/engine/manual_settings.py +128 -0
  46. package/engine/manual_store.py +49 -0
  47. package/package.json +43 -6
  48. package/requirements.txt +6 -0
  49. package/web/app.js +232 -0
  50. package/web/index.html +12 -0
  51. package/web/style.css +4 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # 0.4.0 - Your Local Cost Explorer
2
+
3
+ - Rename the public project and npm command to `your-local-cost-explorer`. Keep the existing private data/runtime directory so upgrading does not lose saved work.
4
+ - Keep the local light design and sidebar. Remove Data Sources navigation; upload and retained-file controls appear only on Dashboard. Replace broken dash characters with `-`.
5
+ - Inspect observed CUR dates locally for CSV, gzip and Parquet. Offer supported 7/30/90/180-day windows ending at the supplied data's latest date, with earlier rows as baseline. Missing history stays unavailable.
6
+ - Put previous-check and device-file comparisons below the baseline fields. Retained comparisons verify original file hashes and cannot contaminate primary data.
7
+ - Preserve all eight detectors, exact decimal financial calculations, commitments, allocation, settings and downloadable local reports. No hosted or automatic connections are included.
8
+
9
+ Focused verification: real loopback uploads, CUR date aliases/formats/conflicts, calendar windows, previous-check provenance and separation, DOM upload/period/button behavior, small independent signed-charge/rounding ledger, duplicate removal, original eight-detector and commitment controls, hosted pricing withdrawal tests, frontend typecheck and production build. No large-file laptop stress test.
package/DESIGN.md ADDED
@@ -0,0 +1,9 @@
1
+ # Local manual dashboard
2
+
3
+ The local edition keeps its own light workspace, white sidebar, slate text (#263445), blue controls (#4165cf) and rectangular cards. It deliberately does not reuse the main app's dark Flow palette, horizontal navigation or pill-shaped toolbar.
4
+
5
+ The main app supplies button labels and page responsibilities: Dashboard, Check Charges, Findings, Commitments, Allocation, Reports and Settings. Billing files live only on the Dashboard. Manual behavior includes evidence, file-anchored date presets, explicit comparisons, local review statuses/notes, archive/restore, named report snapshots and general/team preferences. Connections, authentication, billing and automatic workflows are absent.
6
+
7
+ Settings support company name, default date range, detector sensitivity, team CRUD, documentation and hardware visibility. Check snapshots preserve the settings/mappings used at calculation time. Reports never send automatically.
8
+
9
+ Original HTML/CSS/JS is bundled without CDN assets or copied library code. Earlier Forge, Magic UI and Uiverse reference queries inform accessible native control patterns only. Device-derived budgets replace fixed input-size caps; no hardware capacity claim is made for arbitrary files.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CommitOps contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ # Manual-only feature map
2
+
3
+ | Main app manual area | Local equivalent |
4
+ |---|---|
5
+ | Dashboard and check selection | Independent light dashboard, saved-check picker, observed monthly spending/usage and explicit limitations |
6
+ | Check Charges | Local CUR1/CUR2 CSV/gzip/Parquet, multiple primary and explicit comparison files, date windows, expectations, manual run/cancel |
7
+ | Eight detectors and source evidence | Same extracted engine; evidence drawer, source hashes and complete JSON |
8
+ | Findings filters and review workflow | Search, severity/detector/status filters, bounded pages, local notes/statuses, archive/restore |
9
+ | Commitments | Savings Plan/RI summaries, consumed/unused amounts and missing inventory reasons |
10
+ | Allocation and team ownership | Category/account consumption and local account/team mapping snapshots |
11
+ | Reports | Explicit whole-check JSON, readable meeting HTML, CSV and print; no sending |
12
+ | Dashboard billing files and history | Retained upload identities, explicit removal, keyset history pagination, check archive/restore |
13
+ | Baselines and previous consumption | File-anchored 7/30/90/180-day choices, retained-check/device baselines, canonical usage comparison and observed daily history |
14
+ | General & teams settings | Persisted company name, default date range and creep sensitivity; team add/edit/delete; documentation and hardware budgets |
15
+ | Saved reports | Generate, rename, archive/restore, download and delete immutable local report snapshots |
16
+ | Connections/account billing/automatic settings | Not included; no hosted login, quotas, billing or provider integration |
17
+ | Schedules, rules and automatic delivery | Absent from both runtime routes and UI; not merely hidden |
18
+
19
+ No claims of company/revenue growth or unsupported multi-check total aggregation are introduced. To analyse a wider scope, select its canonical source files in a new permitted manual check rather than adding overlapping saved result totals.
20
+
21
+ No fixed file-size or batch-byte caps apply to this local edition. Processing budgets derive from available hardware, with OS headroom and explicit resource-failure reporting.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # Your Local Cost Explorer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local-only AWS billing dashboard, with its own light dashboard design and sidebar navigation. Upload CUR CSV, CSV.gz or Parquet exports and review reported charges, all eight detectors, commitment consumption, allocation and supporting evidence on your device.
4
+
5
+ ## Install from npm
6
+
7
+ Install Node.js 20+ with npm, then:
8
+
9
+ ```sh
10
+ npm install -g your-local-cost-explorer
11
+ your-local-cost-explorer
12
+ ```
13
+
14
+ The package's install step automatically provisions a private Python 3.12 runtime and all pinned analysis dependencies. Python, pip, DuckDB and Arrow do not need separate installation. It downloads a pinned, SHA256-verified Astral uv binary and uses it to install managed Python and public PyPI wheels. Supported platforms: Windows, macOS and Linux (glibc), x64 and ARM64. Installation requires internet and sufficient free disk space. Node/npm are prerequisites because npm itself runs the installer; no administrator permissions are needed.
15
+
16
+ The first launch also checks setup, so environments that skip npm lifecycle scripts can recover automatically. To retry an interrupted installation:
17
+
18
+ ```sh
19
+ your-local-cost-explorer --setup
20
+ ```
21
+
22
+ Setup never opens or analyses billing files. Subsequent launches work offline after successful installation; ordinary analysis disables outbound Python sockets and DuckDB remote extension loading. Dependencies and runtime tools live privately under your user data directory, not inside the downloaded package. Installation does not change system Python or global PATH.
23
+
24
+ Open the printed `http://127.0.0.1:8080/#token=...` URL. Keep the launch token private. Use `your-local-cost-explorer --port 8081` if 8080 is busy. `your-local-cost-explorer --help` does not download or start anything.
25
+
26
+ ## Install from GitHub
27
+
28
+ ```sh
29
+ git clone https://github.com/leejan2647-a11y/your-local-cost-explorer.git
30
+ cd your-local-cost-explorer
31
+ npm install
32
+ npm start
33
+ ```
34
+
35
+ `npm install` provisions the runtime automatically, just like the npm package. No Python or separate setup command is required. A GitHub archive works too: extract it, run `npm install`, then `npm start`.
36
+
37
+ ## Manual workflow
38
+
39
+ 1. **Dashboard billing files → Check Charges:** upload primary files on the Dashboard. The app detects their observed usage-date bounds and offers supported last 7, 30, 90 or 180 days ending at the latest supplied date. All earlier rows form the baseline. A 30-day export offers 7 and 30 days; selecting all 30 leaves no historical baseline. Bounds do not prove complete coverage. Choose manual dates when needed, optional service expectations, then run or cancel a check. Below the baseline dates, choose earlier rows, retained files from a previous completed check, or comparison files from your device. Comparison files stay separate; missing or changed retained sources are refused.
40
+ 2. **Dashboard:** view reported charges, canonical observations, coverage, monthly observations and supported usage comparisons for a selected saved check.
41
+ 3. **Findings:** search/filter by service/account/text, severity, detector and review status; inspect billing-row evidence; save local notes/statuses; archive and restore findings. Review annotations never rewrite calculated financial evidence.
42
+ 4. **Commitments:** review Savings Plans/reserved instances, purchased/used/unused amounts and inventory limitations.
43
+ 5. **Allocation and team mappings:** view supported category/account consumption, map 12-digit AWS account IDs to local teams, and preserve the mapping snapshot used by each result. Run a new manual check to apply changed mappings to new results.
44
+ 6. **Reports:** generate named snapshots from one or several completed checks, rename/archive/restore/download/delete saved reports, download complete JSON, a readable standalone meeting HTML report or findings CSV, or print locally. Multiple check snapshots remain separate; totals are never added together. Whole-check reports are distinct from filtered findings views. CSV text is protected against spreadsheet formula interpretation.
45
+ 7. **Dashboard billing files:** select retained file identities, inspect names/bytes/roles/hashes, remove files explicitly, and preserve already saved result evidence.
46
+ 8. **Check history & evidence:** page through all saved runs, select previous results, archive/restore checks, review original evidence and baselines. Archived does not mean deleted.
47
+
48
+ All eight detector IDs are included: `unexpected-charge`, `spike`, `gradual-creep`, `new-charge`, `usage-cost-mismatch`, `marketplace`, `expectation-mismatch` and `unused-commitment`. Relevant optional columns, usable history and expectations are required for their corresponding measurements. Unsupported evidence is unavailable, never a fabricated clean assessment.
49
+
50
+ **There is no automatic path.** No schedules, rules, background discovery, AWS connections, hosted authentication, saved delivery connections, email or webhook sending, payment services, telemetry or hosted API clients are included. Check execution uses a local child process only after the user clicks Run. Progress polling observes that manually requested work; it cannot start another check. Reports are local files, as explicitly requested by the owner.
51
+
52
+ ## Financial meaning
53
+
54
+ Estimates use only the AWS data provided. Changes in cloud usage do not establish company or revenue growth. Missing exports, workload changes, discounts and billing adjustments can affect the result.
55
+
56
+ Spending is reported AWS charges, not an independently reconciled final invoice. Finding impacts can overlap and must not be summed into a bill total. Monthly usage comparisons preserve coverage, currency, unit and compatible-basket requirements. Missing days remain unknown. Partial/open months never become completed-month growth just because time passes. Mixed quantities are not added. The existing exact decimal/canonical financial engine is reused.
57
+
58
+ ## Storage and safeguards
59
+
60
+ User data lives in `%LOCALAPPDATA%/commitops-local/data` on Windows or `~/.local/share/commitops-local/data` on Linux/macOS. Runtime dependencies live in an isolated `runtime-v2-<dependency fingerprint>` folder, managed Python in `python`, and verified uv in `tools`. Direct Python launch supports an explicitly chosen `--data` directory. Browser requests accept stored upload IDs, never arbitrary filesystem paths or remote URLs.
61
+
62
+ The app binds only to `127.0.0.1`, validates Host/Origin and requires a random launch token for data access. A lifetime data-directory lease prevents two instances from damaging each other's work. Uploads are streamed; one analysis runs at a time; cancellation kills its child process without saving incomplete results as completed. SQLite transactions close their connections deterministically, including on Windows.
63
+
64
+ There are no fixed per-file or combined-check byte caps and no commercial quotas in the local edition. Your device's available hardware determines what it can process. The app uses all available CPU threads (respecting affinity/container quotas), allows DuckDB to use up to 70% of currently available RAM, and derives spill space from the working disk's current free space. It reserves proportional memory/disk headroom for Python, the operating system and other applications. One manually requested analysis uses this shared budget at a time.
65
+
66
+ CPU settings permit parallel work where the engine supports it; some stages remain serial. The Settings page shows current resource budgets. Device pressure or memory/disk exhaustion produces an explicit unavailable/interrupted outcome rather than a completed financial result. The app does not close other programs or promise that every file will complete. Decompression, column widths, group cardinality and other running programs affect requirements. Supported data formats and financial-evidence validity rules still apply regardless of file size. Users are responsible for their device, power and cooling.
67
+
68
+ To use another drive with more free space, choose trusted local directories at launch:
69
+
70
+ ```sh
71
+ your-local-cost-explorer --data D:/CommitOpsData --work-dir D:/CommitOpsWork
72
+ ```
73
+
74
+ `--data` selects the retained-data directory; existing saved data remains in its original directory until you intentionally migrate it while the app is stopped. `--work-dir` selects disposable processing storage. The browser cannot submit filesystem paths.
75
+
76
+ Stop the app before deliberately removing its data directory to clear all retained results/mappings. Save needed reports first. Removing individual retained files never deletes saved financial results. Local same-user malware and someone with device access are outside launch-token protection.
77
+
78
+ ## Source and tests
79
+
80
+ `SOURCE-INVENTORY.json` records the allowlisted original financial modules and export hashes. `MANUAL-FEATURES.md` maps the local manual experience. Only intended financial code and local adapters are exported; no hosted repository history, private environments, user records, keys or provider configuration are included. New dashboards use native bundled HTML/CSS/JS with no remote fonts, scripts or assets.
81
+
82
+ After a source install, `npm test` runs focused Node tests (Node 22.22.2+ for the development-only jsdom test dependency). Python tests under `tests/` use only synthetic data and temporary local storage. The financial engine has separate earlier parity receipts; unchanged calculations are not re-tested for every stylesheet edit.
83
+
84
+ MIT licensed. Astral uv is downloaded separately under its upstream MIT/Apache-2.0 terms; managed CPython and installed Python/npm dependencies retain their respective upstream licences. No third-party UI component source was vendored. Forge, Magic UI and Uiverse were consulted as design references; Button names, page responsibilities and manual workflow behavior follow the main application; its palette and visual layout are not copied. No hosted assets or integration code are included.
@@ -0,0 +1,127 @@
1
+ [
2
+ {
3
+ "file": "app/core/analysis_storage.py",
4
+ "upstream_sha256": "cb93f0cd05f9e5afb2a29c2379eb1d845bf86606cf458f0960c09e0036a020da",
5
+ "export_sha256": "d54d25bda088d9e6ac7e3393e2f862e633f1ce7088699bf092eed4e59fca5fa2"
6
+ },
7
+ {
8
+ "file": "app/growth/contracts.py",
9
+ "upstream_sha256": "069570ae90cf1452542f1d7e1cdf2cafea258f4b68b9c3315ce119784d9363fd",
10
+ "export_sha256": "f64dc67efc3083fb72bf0b45a6e4ba619a03ff5c26178f06d506fb213d81e30a"
11
+ },
12
+ {
13
+ "file": "app/pipeline/attribution.py",
14
+ "upstream_sha256": "195f1b604f7c8fc16dc127573cc7450d5b893909dd7277e47b4075e5b9dbdb74",
15
+ "export_sha256": "195f1b604f7c8fc16dc127573cc7450d5b893909dd7277e47b4075e5b9dbdb74"
16
+ },
17
+ {
18
+ "file": "app/pipeline/baseline.py",
19
+ "upstream_sha256": "85055663079228a178dcde352faaba6f2f56e07d024b98402938cab4608b4d38",
20
+ "export_sha256": "7a407fc38d48de2886ca57ac730a15202507942205ccebdd41ce7b2eded128b7"
21
+ },
22
+ {
23
+ "file": "app/pipeline/canonical.py",
24
+ "upstream_sha256": "226c7502cdb5dd5d3cefe35cd0a447380a5b59b0a50568c4161eb941f7944497",
25
+ "export_sha256": "a740d00b4d315f698081d87e92b63a41876912d2a9fd6b29f38a9f8827e57aab"
26
+ },
27
+ {
28
+ "file": "app/pipeline/commitments.py",
29
+ "upstream_sha256": "b63f255ee16b0d159010ffd7222c7a666e241667b3a8c6d32d6a9493246cbc5d",
30
+ "export_sha256": "b1e62fa369708edb1d8bf1ecffbe3c25828d9ea65efb0c2edd58a084ab0bbfb4"
31
+ },
32
+ {
33
+ "file": "app/pipeline/comparison.py",
34
+ "upstream_sha256": "2d431a66e842d285e18913412417b1b70318617ccc8598482351848a414bcfc8",
35
+ "export_sha256": "d729b56860e7d326762376c9aedf95e01b11d2fa96daf62b2e6fcbe11dfa6f84"
36
+ },
37
+ {
38
+ "file": "app/pipeline/creep_policy.py",
39
+ "upstream_sha256": "0798da2d98b7c073f6362a390e37dd5891048e396188cb2a07493a8be762ed93",
40
+ "export_sha256": "956aca20469b74f9e8eedb5f5733dbb5638d6d9daadf14ed17054581168fc800"
41
+ },
42
+ {
43
+ "file": "app/pipeline/currency.py",
44
+ "upstream_sha256": "f11845613be80b99f2c5405955872cb68a7576fa47f0bc0ca5d6d7b7441014ec",
45
+ "export_sha256": "b3c4a141c095297394727a95576760043c63a4eefda224609d94eccce2bd5612"
46
+ },
47
+ {
48
+ "file": "app/pipeline/detectors.py",
49
+ "upstream_sha256": "f1ac4e2bd94324acfd4db48011cc544df10506a2fb1daf2912324526a200315f",
50
+ "export_sha256": "f1ac4e2bd94324acfd4db48011cc544df10506a2fb1daf2912324526a200315f"
51
+ },
52
+ {
53
+ "file": "app/pipeline/evidence.py",
54
+ "upstream_sha256": "0b7d45dfef68bebb83b3c0b2a90782ee0d2375bc95f5bcf6b336d78cc01bbb31",
55
+ "export_sha256": "0b7d45dfef68bebb83b3c0b2a90782ee0d2375bc95f5bcf6b336d78cc01bbb31"
56
+ },
57
+ {
58
+ "file": "app/pipeline/finding_amounts.py",
59
+ "upstream_sha256": "bce0050111c3bf085d6ba5b95596f89299f893a36cd94642121b6f1d57bc1143",
60
+ "export_sha256": "aa464e4a3901491ed4d576ce34b1df817bbbf1d17545147f41f08daa0d1fcd36"
61
+ },
62
+ {
63
+ "file": "app/pipeline/history_facts.py",
64
+ "upstream_sha256": "7a77eb3ddeb7bf1b8f29630d71e86ad2dc1bf5fd80c2496f3c66a32abcdebc39",
65
+ "export_sha256": "85aa2275401cc9c9e74a95c83f5d32c8b35b4fb2b4a7b9739d66cf691e9a664d"
66
+ },
67
+ {
68
+ "file": "app/pipeline/ingest.py",
69
+ "upstream_sha256": "45becd68afb7c7e4ab044e0a79695e3d23e204e040d687d53179ea159f9da48b",
70
+ "export_sha256": "d1ab420ce420ac737d01b63ef6fcb85a969f3a8ad58907e497ede5ed9568a54a"
71
+ },
72
+ {
73
+ "file": "app/pipeline/money.py",
74
+ "upstream_sha256": "6f276ed3e2c6c6b76986b3955207f0b9f3b96080cc2afc7e2547f772a8612837",
75
+ "export_sha256": "75aa807bbb79e88e22a72ed5861c07c2b84be7ad9db9ae2d57651881bc482ff9"
76
+ },
77
+ {
78
+ "file": "app/pipeline/monthly_growth.py",
79
+ "upstream_sha256": "41abd5a5aecb15fed6f603dfa0541b794a39aae43f58a99b9abe3805b1516242",
80
+ "export_sha256": "5b4a7431cca0e5c8dc3e7d1611bacdc2b8a39944c81621158bbcf1d4f25fc6ab"
81
+ },
82
+ {
83
+ "file": "app/pipeline/reserved_instances.py",
84
+ "upstream_sha256": "73b402e825b81171190ca912e5d313ac3dee07d2a2ea44c1edc4ac1b8a53cd15",
85
+ "export_sha256": "73b402e825b81171190ca912e5d313ac3dee07d2a2ea44c1edc4ac1b8a53cd15"
86
+ },
87
+ {
88
+ "file": "app/pipeline/routing_tags.py",
89
+ "upstream_sha256": "dd091c32195ae9543d052922cb7999f5ac5433c00220d3afb5037ded240adc24",
90
+ "export_sha256": "b0e27623f371a6ee153a9fb1fc8bbb5dad8615c1bc9d84114cbed524f2ea8ca6"
91
+ },
92
+ {
93
+ "file": "app/pipeline/run.py",
94
+ "upstream_sha256": "1a03edbfd4d3de72422b50d7405ae99e04f74d59709afd0ebb3c6f0f50feb06e",
95
+ "export_sha256": "4302c7778b6a4ab88cccf19d0d7800fa7adfb2302027b3b23c3cb59912d7bb8e"
96
+ },
97
+ {
98
+ "file": "app/pipeline/savings_plans.py",
99
+ "upstream_sha256": "742ee44de6372bcf8fe700a2b75c4f5a6aae1dc44ac2235bd3fe89946135067f",
100
+ "export_sha256": "742ee44de6372bcf8fe700a2b75c4f5a6aae1dc44ac2235bd3fe89946135067f"
101
+ },
102
+ {
103
+ "file": "app/pipeline/spend_history.py",
104
+ "upstream_sha256": "92df5bfbd084172f0664e089438b1f785df89fa1b98e3ad2378faa14f7504727",
105
+ "export_sha256": "fc56f38c0bc838c56ebc7a298d4eaaa842ee8a9860c2d740d306f5cc3f4aa74d"
106
+ },
107
+ {
108
+ "file": "app/pipeline/string_source.py",
109
+ "upstream_sha256": "14663adf0fbebd87470e92f8fce3f5fea17d13a6292b0b8bbf0da8350fdf8d51",
110
+ "export_sha256": "4658e9808fa2d00c0aa826ce0b6e9421420bc57240d42d3425bf4b8f6f7a52c3"
111
+ },
112
+ {
113
+ "file": "app/pipeline/types.py",
114
+ "upstream_sha256": "ac19856a1fd053c604fa50fa5db880c33694eb93f8f346e35696f2c572b2a5af",
115
+ "export_sha256": "ac19856a1fd053c604fa50fa5db880c33694eb93f8f346e35696f2c572b2a5af"
116
+ },
117
+ {
118
+ "file": "app/pipeline/validate.py",
119
+ "upstream_sha256": "fe77eaffc5c791455c78fd0d4f0a533784bed9744543363525e3b1f0a548c5ed",
120
+ "export_sha256": "6ca62ffe3ea2bf4e8312ba5be42283408f5e9b197d0fad1b4b82d35014404c4b"
121
+ },
122
+ {
123
+ "file": "app/services/monthly_growth.py",
124
+ "upstream_sha256": "f7710949294198a259ebd0611c3ced5b5cae4a56a0ca73b8781976faf28f484a",
125
+ "export_sha256": "fcc968904c28b3d00809bc8655f87cb3c081b7527fc590b154fcff8ee78d376c"
126
+ }
127
+ ]
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const {spawn}=require('node:child_process');
4
+ const path=require('node:path');
5
+ const {setup}=require('./runtime.cjs');
6
+ const root=path.resolve(__dirname,'..');const args=process.argv.slice(2);
7
+ if(args.includes('--help')){console.log('your-local-cost-explorer [--port 8080] [--setup]\nFirst installation provisions a private Python runtime. Analysis stays offline.');process.exit(0);}
8
+ async function main(){
9
+ const info=await setup(root);
10
+ if(args.includes('--setup')){console.log('Setup complete. Run your-local-cost-explorer.');return;}
11
+ const env={};
12
+ for(const key of ['PATH','SystemRoot','WINDIR','TEMP','TMP','TMPDIR','HOME','USERPROFILE','LOCALAPPDATA','LANG'])if(process.env[key])env[key]=process.env[key];
13
+ env.PYTHONNOUSERSITE='1';env.PYTHONDONTWRITEBYTECODE='1';
14
+ const child=spawn(info.python,[path.join(root,'engine','local_app.py'),'--data',path.join(info.home,'data'),...args],{stdio:'inherit',shell:false,env});
15
+ child.on('error',error=>{console.error(error.message);process.exitCode=1;});child.on('exit',code=>{process.exitCode=code||0;});
16
+ for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
17
+ }
18
+ main().catch(error=>{console.error(error.message);process.exitCode=1;});
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ const fs=require('node:fs'), path=require('node:path'), os=require('node:os'), crypto=require('node:crypto'), zlib=require('node:zlib');
3
+ const {spawnSync}=require('node:child_process');
4
+ const VERSION='0.13.5';
5
+ const ASSETS={
6
+ 'win32-x64':['uv-x86_64-pc-windows-msvc.zip','a38b8d1902b7cfbc76b423ba1bb456f623f42064cdc72628b6075b89afd1019a'],
7
+ 'win32-arm64':['uv-aarch64-pc-windows-msvc.zip','09f47edf90726084a70f9426f6e3ca4f46e9bf7cbf23b1716ec64d60b055fae3'],
8
+ 'linux-x64':['uv-x86_64-unknown-linux-gnu.tar.gz','f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f'],
9
+ 'linux-arm64':['uv-aarch64-unknown-linux-gnu.tar.gz','4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403'],
10
+ 'darwin-x64':['uv-x86_64-apple-darwin.tar.gz','b0f52b0e3945b4107d89024ea38e625683011fbfc05a0d98f9fa2d6dcee28c66'],
11
+ 'darwin-arm64':['uv-aarch64-apple-darwin.tar.gz','743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73']
12
+ };
13
+ function locations(root){
14
+ const home=path.join(process.env.LOCALAPPDATA || path.join(os.homedir(),'.local','share'),'commitops-local');
15
+ const fingerprint=crypto.createHash('sha256').update(fs.readFileSync(path.join(root,'requirements.txt'))).update('managed-python-3.12-v2').digest('hex');
16
+ const runtime=path.join(home,'runtime-v2-'+fingerprint.slice(0,16));
17
+ return {home,runtime,python:path.join(runtime,process.platform==='win32'?'Scripts/python.exe':'bin/python'),marker:path.join(runtime,'.ready'),fingerprint};
18
+ }
19
+ function ready(info){return fs.existsSync(info.python)&&fs.existsSync(info.marker)&&fs.readFileSync(info.marker,'utf8')===info.fingerprint;}
20
+ function binaryFromTar(archive){
21
+ const tar=zlib.gunzipSync(archive,{maxOutputLength:200*1024*1024});
22
+ for(let offset=0;offset+512<=tar.length;){
23
+ const header=tar.subarray(offset,offset+512),name=header.subarray(0,100).toString().split('\0')[0];
24
+ if(!name)break;
25
+ const size=parseInt(header.subarray(124,136).toString().replace(/\0/g,'').trim(),8);
26
+ if(!Number.isSafeInteger(size)||size<0||offset+512+size>tar.length)throw Error('Invalid runtime archive');
27
+ if(path.posix.basename(name)==='uv'&&[0,48].includes(header[156]))return tar.subarray(offset+512,offset+512+size);
28
+ offset+=512+Math.ceil(size/512)*512;
29
+ }
30
+ throw Error('Runtime binary missing from archive');
31
+ }
32
+ async function uvBinary(info){
33
+ const spec=ASSETS[process.platform+'-'+process.arch];
34
+ if(!spec)throw Error('Automatic installation supports Windows/macOS/Linux x64 and ARM64. This platform is unsupported.');
35
+ const folder=path.join(info.home,'tools',VERSION),binary=path.join(folder,process.platform==='win32'?'uv.exe':'uv'),stamp=binary+'.sha256';
36
+ if(fs.existsSync(binary)&&fs.existsSync(stamp)&&crypto.createHash('sha256').update(fs.readFileSync(binary)).digest('hex')===fs.readFileSync(stamp,'utf8'))return binary;
37
+ console.log('Downloading verified local runtime installer…');
38
+ const response=await fetch(`https://github.com/astral-sh/uv/releases/download/${VERSION}/${spec[0]}`,{signal:AbortSignal.timeout(120000)});
39
+ if(!response.ok)throw Error('Runtime download failed: HTTP '+response.status);
40
+ const chunks=[];let size=0;
41
+ for await(const chunk of response.body){size+=chunk.length;if(size>50*1024*1024)throw Error('Runtime archive exceeds expected size');chunks.push(chunk);}
42
+ const archive=Buffer.concat(chunks);
43
+ if(crypto.createHash('sha256').update(archive).digest('hex')!==spec[1])throw Error('Runtime download checksum mismatch');
44
+ let contents;
45
+ if(spec[0].endsWith('.zip')){
46
+ const {unzipSync}=require('fflate');const files=unzipSync(archive,{filter:entry=>entry.name.split('/').pop()==='uv.exe'&&entry.originalSize<100*1024*1024});
47
+ contents=Object.entries(files).find(([name])=>name.split('/').pop()==='uv.exe')?.[1];
48
+ }else contents=binaryFromTar(archive);
49
+ if(!contents?.length)throw Error('Runtime binary missing');
50
+ fs.mkdirSync(folder,{recursive:true,mode:0o700});const temporary=binary+'.'+process.pid+'.tmp';fs.writeFileSync(temporary,contents,{mode:0o700});fs.renameSync(temporary,binary);
51
+ fs.writeFileSync(stamp,crypto.createHash('sha256').update(contents).digest('hex'),{mode:0o600});return binary;
52
+ }
53
+ async function setup(root){
54
+ const info=locations(root);if(ready(info))return info;
55
+ fs.mkdirSync(info.home,{recursive:true,mode:0o700});const lock=path.join(info.home,'.setup-lock');
56
+ // Exclusive lock file: another install must finish rather than sharing a partial runtime.
57
+ let handle;
58
+ try{handle=fs.openSync(lock,'wx',0o600);fs.writeSync(handle,String(process.pid));}
59
+ catch(error){
60
+ if(error.code!=='EEXIST')throw error;
61
+ const pid=Number(fs.readFileSync(lock,'utf8'));let alive=true;try{process.kill(pid,0);}catch(e){if(e.code==='ESRCH')alive=false;}
62
+ if(!alive){fs.unlinkSync(lock);return setup(root);}
63
+ for(let i=0;i<240;i++){await new Promise(resolve=>setTimeout(resolve,1000));if(ready(info))return info;if(!fs.existsSync(lock))return setup(root);}
64
+ throw Error('Another runtime setup is still running. Retry after it finishes.');
65
+ }
66
+ try{
67
+ if(ready(info))return info;
68
+ const uv=await uvBinary(info);
69
+ const env={...process.env,UV_PYTHON_INSTALL_DIR:path.join(info.home,'python'),UV_NO_CONFIG:'1',UV_PYTHON_DOWNLOADS:'automatic'};
70
+ console.log('Installing private Python 3.12 and analysis dependencies. Billing files are not accessed.');
71
+ for(const args of [['venv','--python','3.12','--managed-python','--allow-existing',info.runtime],['pip','install','--python',info.python,'--only-binary',':all:','--index-url','https://pypi.org/simple','-r',path.join(root,'requirements.txt')]]){
72
+ const result=spawnSync(uv,args,{env,stdio:'inherit',shell:false,timeout:600000});
73
+ if(result.error||result.status!==0)throw Error('Runtime setup failed. Check internet access and disk space, then run commitops-local --setup to retry.');
74
+ }
75
+ const probe=spawnSync(info.python,['-c','import duckdb,pydantic,pyarrow,pytz,dateutil,psutil'],{env,stdio:'inherit',timeout:30000});
76
+ if(probe.error||probe.status!==0)throw Error('Installed analysis dependencies did not pass verification');
77
+ fs.writeFileSync(info.marker,info.fingerprint,{mode:0o600});return info;
78
+ }finally{fs.closeSync(handle);fs.unlinkSync(lock);}
79
+ }
80
+ module.exports={locations,ready,binaryFromTar,setup,ASSETS};
81
+ if(require.main===module)setup(path.resolve(__dirname,'..')).then(()=>console.log('Local runtime ready.')).catch(error=>{console.error(error.message);process.exitCode=1;});
File without changes
File without changes
@@ -0,0 +1,245 @@
1
+ """Private, disposable working storage for one DuckDB analysis.
2
+
3
+ Source uploads remain in their retained storage. Only reproducible processing
4
+ files use the operating system's temporary volume.
5
+ """
6
+ from tempfile import TemporaryDirectory, gettempdir
7
+ from pathlib import Path
8
+ from contextlib import contextmanager
9
+ import os
10
+ import shutil
11
+ import stat
12
+ import threading
13
+ from duckdb import ConnectionException, Error as DuckDBError
14
+
15
+
16
+
17
+ class AnalysisStorageError(OSError):
18
+ """Processing storage failed, independently of provider export validity."""
19
+
20
+
21
+ def _lock(handle, *, blocking=True):
22
+ if os.name == 'nt':
23
+ import msvcrt
24
+ handle.seek(0)
25
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
26
+ else:
27
+ import fcntl
28
+ fcntl.flock(handle, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
29
+
30
+
31
+ @contextmanager
32
+ def _root_lock(root):
33
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
34
+ info=root.lstat()
35
+ if not stat.S_ISDIR(info.st_mode) or (hasattr(os,'getuid') and info.st_uid != os.getuid()):
36
+ raise OSError('Private analysis working storage is unavailable')
37
+ if os.name != 'nt' and stat.S_IMODE(info.st_mode) & 0o077:
38
+ raise OSError('Private analysis working storage permissions are unsafe')
39
+ # Root ownership/mode prevent substitution of this lock or job leases.
40
+ with (root/'.lock').open('a+b') as handle:
41
+ if handle.tell()==0:
42
+ handle.write(b'0');handle.flush()
43
+ _lock(handle)
44
+ yield
45
+
46
+
47
+ def _reap_locked(root):
48
+ for directory in root.glob('job-*'):
49
+ if directory.is_symlink() or not directory.is_dir():
50
+ continue
51
+ lease=directory/'.lease'
52
+ if not lease.exists():
53
+ # Initialization also holds the root lock. An empty directory
54
+ # without a lease can only be an interrupted pre-database setup.
55
+ try:
56
+ directory.rmdir()
57
+ except OSError:
58
+ pass # Preserve any unrecognized, nonempty directory.
59
+ continue
60
+ if lease.is_symlink() or not lease.is_file():
61
+ continue
62
+ try:
63
+ with lease.open('r+b') as handle:
64
+ _lock(handle, blocking=False)
65
+ # A worker holding this lease cannot be reaped, including
66
+ # workers in a different PID namespace. The OS releases it on
67
+ # timeout/SIGKILL without relying on a Python finally block.
68
+ if os.name != 'nt':
69
+ shutil.rmtree(directory)
70
+ if os.name == 'nt':
71
+ shutil.rmtree(directory)
72
+ except (OSError, PermissionError):
73
+ continue
74
+
75
+
76
+ def reap_orphans(root):
77
+ with _root_lock(root):
78
+ _reap_locked(root)
79
+
80
+
81
+ class AnalysisConnection:
82
+ def __init__(self, connection, working, lease, *, disk_reserve=0, memory_reserve=0):
83
+ self._connection = connection
84
+ self._working = working
85
+ self._lease = lease
86
+ self._stop = threading.Event()
87
+ self._storage_failed = False
88
+ self._closed = False
89
+ self._disk_reserve = disk_reserve
90
+ self._memory_reserve = memory_reserve
91
+ self._cursors = set()
92
+ self._cursor_lock = threading.Lock()
93
+ self._monitor = threading.Thread(target=self._watch_storage, name='analysis-storage-guard', daemon=True)
94
+ self._monitor.start()
95
+
96
+ def _watch_storage(self):
97
+ while not self._stop.wait(1):
98
+ try:
99
+ free = shutil.disk_usage(self._working.name).free
100
+ except OSError:
101
+ free = 0
102
+ if self._stop.is_set():
103
+ return
104
+ from local_hardware import memory
105
+ available, _ = memory()
106
+ if free < self._disk_reserve or available < self._memory_reserve//2:
107
+ self._storage_failed = True
108
+ with self._cursor_lock:
109
+ connections = [self._connection, *self._cursors]
110
+ for connection in connections:
111
+ try:
112
+ connection.interrupt()
113
+ except DuckDBError:
114
+ pass # A cursor may have closed after the snapshot.
115
+ return
116
+
117
+ def _ensure_available(self):
118
+ if self._closed:
119
+ raise ConnectionException('Analysis connection is closed')
120
+ if self._storage_failed:
121
+ raise AnalysisStorageError('Your device has insufficient available memory or working disk to continue safely')
122
+
123
+ def _new_cursor(self, source):
124
+ with self._cursor_lock:
125
+ self._ensure_available()
126
+ connection = source.cursor()
127
+ self._cursors.add(connection)
128
+ return AnalysisCursor(self, connection)
129
+
130
+ def cursor(self):
131
+ return self._new_cursor(self._connection)
132
+
133
+ def __getattr__(self, name):
134
+ return getattr(self._connection, name)
135
+
136
+ def execute(self, *args, **kwargs):
137
+ self._ensure_available()
138
+ result = self._connection.execute(*args, **kwargs)
139
+ return self if result is self._connection else result
140
+
141
+ def __enter__(self):
142
+ return self
143
+
144
+ def __exit__(self, *_):
145
+ self.close()
146
+
147
+ def close(self):
148
+ with self._cursor_lock:
149
+ if self._closed:
150
+ return
151
+ self._closed = True
152
+ working = self._working
153
+ cursors = list(self._cursors)
154
+ self._cursors.clear()
155
+ self._stop.set()
156
+ self._monitor.join(timeout=3)
157
+ self._working = None
158
+ try:
159
+ for connection in [self._connection, *cursors]:
160
+ try:
161
+ connection.interrupt()
162
+ except DuckDBError:
163
+ pass
164
+ try:
165
+ for cursor in cursors:
166
+ try:
167
+ cursor.close()
168
+ except DuckDBError:
169
+ pass
170
+ finally:
171
+ self._connection.close()
172
+ finally:
173
+ try:
174
+ self._lease.close()
175
+ finally:
176
+ working.cleanup()
177
+
178
+
179
+ class AnalysisCursor:
180
+ def __init__(self, owner, connection):
181
+ self._owner, self._connection = owner, connection
182
+
183
+ def __getattr__(self, name):
184
+ return getattr(self._connection, name)
185
+
186
+ def execute(self, *args, **kwargs):
187
+ self._owner._ensure_available()
188
+ result = self._connection.execute(*args, **kwargs)
189
+ return self if result is self._connection else result
190
+
191
+ def fetchmany(self, *args, **kwargs):
192
+ self._owner._ensure_available()
193
+ return self._connection.fetchmany(*args, **kwargs)
194
+
195
+ def fetch_record_batch(self, *args, **kwargs):
196
+ self._owner._ensure_available()
197
+ return self._connection.fetch_record_batch(*args, **kwargs)
198
+
199
+ def to_arrow_reader(self, *args, **kwargs):
200
+ self._owner._ensure_available()
201
+ return self._connection.to_arrow_reader(*args, **kwargs)
202
+
203
+ def cursor(self):
204
+ return self._owner._new_cursor(self._connection)
205
+
206
+ def close(self):
207
+ with self._owner._cursor_lock:
208
+ self._owner._cursors.discard(self._connection)
209
+ self._connection.close()
210
+
211
+ def __enter__(self):
212
+ return self
213
+
214
+ def __exit__(self, *_):
215
+ self.close()
216
+
217
+
218
+ def connect(settings):
219
+ import duckdb
220
+
221
+ root=Path(gettempdir()) / ('commitops-local-analysis-' + str(os.getuid() if hasattr(os,'getuid') else 'local'))
222
+ with _root_lock(root):
223
+ _reap_locked(root)
224
+ if shutil.disk_usage(root).free <= settings.disk_reserve:
225
+ raise AnalysisStorageError('Insufficient private analysis working storage')
226
+ working = TemporaryDirectory(prefix='job-', dir=root)
227
+ lease=(Path(working.name)/'.lease').open('w+b')
228
+ lease.write(b'0');lease.flush()
229
+ _lock(lease)
230
+ try:
231
+ connection = duckdb.connect(":memory:", config={
232
+ "autoinstall_known_extensions": False,
233
+ "autoload_known_extensions": False,
234
+ "threads": settings.duckdb_threads,
235
+ "memory_limit": settings.duckdb_memory_limit,
236
+ "max_temp_directory_size": settings.duckdb_spill_limit,
237
+ "temp_directory": working.name,
238
+ })
239
+ except BaseException:
240
+ lease.close()
241
+ working.cleanup()
242
+ raise
243
+ wrapper = AnalysisConnection(connection, working, lease, disk_reserve=settings.disk_reserve, memory_reserve=settings.memory_reserve)
244
+ wrapper.hardware = dict(settings.hardware)
245
+ return wrapper
@@ -0,0 +1,10 @@
1
+ from types import SimpleNamespace
2
+ def get_settings():
3
+ from tempfile import gettempdir
4
+ from local_hardware import budget
5
+ limits = budget(gettempdir())
6
+ return SimpleNamespace(duckdb_threads=limits['cpu_threads'],
7
+ duckdb_memory_limit=str(limits['duckdb_memory_bytes'])+'B',
8
+ duckdb_spill_limit=str(limits['spill_bytes'])+'B',
9
+ disk_reserve=limits['disk_reserve_bytes'],
10
+ memory_reserve=limits['memory_reserve_bytes'], hardware=limits)