e4acfbd0bff86755d5064792efb1fcfddb85bf0e
428 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e4acfbd0bf | refactor: complete phase 4 builder cleanup | ||
|
|
4cf26405f6 | feat: complete phase 3 career workspace | ||
|
|
f8a7cf5205 | fix(deploy): repair backup directory permissions | ||
|
|
56fed05d70 | feat: complete phase 2 UX improvements | ||
|
|
173187dcbb |
feat(cv): Phase 2.1-b — extract Projects, Certifications, and languages-from-prose
The structured model and StructuredCvProfileJson.FromSections already map
Projects/Certifications/Languages headings, but the AI normalize prompt
never emitted them, so on the benchmark CV the entire Projects section and
the in-summary languages (English Native, Norwegian B1) were silently
dropped. This closes that gap upstream — no backend schema or data change.
ai-service (tools/summarizer/app.py):
- /cv/normalize: added # Projects and # Certifications headings; a
languages-from-prose rule (pull "native English", "Norwegian at B1" out
of the summary even with no Languages section; ignore programming
languages); and skill-group prefix stripping ("Development:",
"DevOps & Infrastructure:", "Practices:" dropped, only the skills kept).
- /cv/classify-block: Projects and Certifications added to the section
enum + rules (fallback path).
Backend:
- LooksLikeNormalizedMarkdownCv now recognises # Projects / # Certifications
so those CVs still take the markdown assembly path.
Tests:
- CvExtractionCoverageTests (4) lock the C# mapping of Projects,
Certifications and Languages sections into the structured profile.
- ai-service test_classify_block_supports_projects_section (1).
426 backend tests, 17 ai-service tests pass; app.py compiles.
The LLM behaviour (prompt -> headings) needs Ollama to observe and was not
run here; the C# side that consumes the headings is proven and the prompt
change is additive. Merge-not-replace + the review screen are the next
increment (2.1-a, approved: always-review, conservative merge).
Deployment: these prompts live in the ai-service container, which
deploy.sh does not rebuild by default -- deploy with
DEPLOY_BUILD_AI_SERVICE=true or the change won't take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
fe9cd4dda1 |
refactor(career): Phase 1 increment 2 — extract editor sections, hide duplicate CV concepts
UI-only. No change to APIs, save payloads, extraction behaviour, or data
models. The parent CareerProfilePage still owns loading, state, saving,
and all extraction/import actions; the new sections are presentational
(value + onChange, plus a getMetadata callback for review chips).
Extracted into src/views/career/CareerProfileSections.tsx:
PersonalInformation, ProfessionalSummary, Skills, Interests, Languages,
WorkExperience, Education, OtherSections. FieldReviewNote + confidenceTone
moved there verbatim and shared with the parent. CareerProfilePage went
from 1376 to ~1200 lines.
No Projects/Certifications sections were created -- the editor never had
them (they are not editable structured fields here). Inventing them would
add functionality, which this refactor avoids; noted for a product
decision later.
Hid the duplicate CV concepts behind an "Advanced CV tools" toggle,
collapsed by default: the CV Structure Overview parse block and the
Template-driven CV builder. Both stay mounted and functional (gated with
display:none), so no tested functionality is removed -- the real CV
Builder at /career/builder is the single generation surface. Future
removal plan documented.
Tests: added "editing a field in an extracted section updates parent
state and flows into save" (render -> edit -> PUT /career/profile
{profile,cvText}); existing parse/rewrite tests reveal the advanced tools
first. The increment-1 save-invariant test still pins the payload.
Verified: tsc clean, production build clean, 137 frontend tests pass.
Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
63473bae85 |
refactor(career): Phase 1 increment — user-facing terminology + component split
UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.
Terminology -> user-facing (i18n strings):
- "Structured CV editor" -> "Career information"
- "CV structure overview" -> "Profile sections"
- "Summary bullets" -> "Professional summary"
- "Core skills" -> "Skills"
- "Analyze sections" -> "Read sections"
- "Original extraction" -> "Original import"
- hardcoded "Master career profile" -> "Career profile"
Help text de-jargoned; the Career information help now frames it as the
source the CV Builder consumes.
Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.
Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.
Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.
The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
4f69d395be |
fix(nav): only the most-specific sidebar item is active
On /career/builder/{id} both "Career Workspace" (/career) and "CV Builder"
(/career/builder) highlighted, because AppShell tested each item with
`pathname === to || pathname.startsWith(to + "/")` — so /career matched
every /career/... child. No "most specific wins" rule.
Add AppShell.activeNavTo(pathname, tos): the longest `to` that the path is
at or under wins, across both nav lists; every other item is inactive. A
child route never lights up a parent nav item. `selected` now compares
against that single computed activeTo. Exported as a pure function so the
ownership rule is unit-tested directly (sidebar-active-nav.test.ts):
exactly one active item for /career, /career/builder and
/career/builder/{id}, and no double-highlight.
Also give the breadcrumb/title in App.tsx explicit /career/builder ->
"CV Builder" ownership (it previously showed "Career Workspace"), and
reframe the Career Workspace header to the "Career Profile" product
framing: "This information powers your CVs, applications, cover letters
and AI assistance."
Frontend only — no change to CareerProfiles, CvVariants, CV generation,
extraction APIs, AI, permissions or tenant isolation. Plan for the deeper
information-architecture work is in docs/career-workspace-ux-refactor.md,
staged so the 1376-line CareerProfilePage and the live CV/extraction
pipeline are refactored incrementally with verification, not in one risky
rewrite.
Verified: tsc clean, frontend build clean, 135 frontend tests pass
(128 + 7 new nav tests). Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
4db8c08958 |
fix(security): evaluate tenant CurrentUserId live, not at construction
Production POST /api/cv/variants returned 200 but GET /api/cv/variants/{id}
returned 404, with the query logged as `... FROM CvVariants WHERE FALSE`
(no parameters). The created row had a correct OwnerUserId; the read was
excluded by the global query filter because CurrentUserId was null at
query time. Reproduced locally: it affected EVERY tenant-filtered read
(CV list returned 0 after creating 5, JobApplications returned total 0),
not just CV -- writes worked, reads came back empty.
Root cause: the "local" JwtBearer OnTokenValidated resolves the
request-scoped JobTrackerContext (to run LocalSessionValidator) BEFORE the
authentication middleware assigns HttpContext.User. JobTrackerContext
captured CurrentUserId in its constructor from ICurrentUserService.UserId,
which reads HttpContext.User -- still unauthenticated at that point -- so
CurrentUserId froze to null. That same scoped instance is reused by the
controller, so `CurrentUserId != null && OwnerUserId == CurrentUserId`
compiled to WHERE FALSE for the whole request. POST worked because
CreateAsync sets OwnerUserId from the controller-resolved user, and
inserts are not filtered.
Fix: make CurrentUserId a computed property that reads
ICurrentUserService.UserId live, so the query filters see the
authenticated user at query-execution time. Deny-on-null is preserved
(still null for an unauthenticated principal). LocalSessionValidator is
unaffected -- it already uses IgnoreQueryFilters and queries by explicit
sid.
Verified on a real MariaDB 11 container end to end: create then read a
variant returns 200, the variant list returns all rows, and
GET /api/jobapplications reads normally. Added
CurrentUserIdLiveEvaluationTests pinning the live-evaluation behaviour
(both fail against a constructor snapshot). 422 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
474654b1c1 |
fix(cv): add LongTailJson to reconciler CareerProfiles schema
Production GET /api/cv/outline returned 500 "Unknown column 'c.LongTailJson'". CareerProfiles is reconciler-owned, but the reconciler's CREATE TABLE (both the SQLite and MySQL branches) only listed Id, OwnerUserId, ProfileJson, Version, CreatedAtUtc, UpdatedAtUtc. LongTailJson was added to the CareerProfile model in Phase 3 but neither CREATE was updated and no column-repair existed, so: - existing databases (prod): the MySQL CREATE is guarded on !HasMySqlTable, so it never runs once the table exists, and nothing adds the column -> LoadStructuredAsync selects a column that isn't there. - fresh databases: the CREATE itself omitted the column, so even a brand new MariaDB/SQLite was missing it. The 420 tests never caught this because they build tables from the EF model, not the reconciler DDL. The release audit missed it because it never exercised /api/cv/outline. Add LongTailJson to both CREATE statements and add an additive repair (EnsureColumn / EnsureMySqlColumn) for existing tables. DEFAULT '' backfills existing rows and matches the non-nullable model property. This is the sanctioned reconciler repair path, not a manual ALTER, and preserves existing data (ADD COLUMN is non-destructive). Verified on a real MariaDB 11 container: an existing 6-column CareerProfiles gains LongTailJson on startup (repair path), a fresh DB gets it from the CREATE (longtext), and GET /api/cv/outline returns 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
83206df9d7 |
docs(release): final release report
Consolidates release-candidate verification: backend 420 (incl. the CI runner's exact Ubuntu 20.04/libicu66 environment), frontend 128, all four DB scenarios, backup/restore with a byte-exact æøå round trip, health and auth checks. Status: READY WITH DOCUMENTED RISKS. No open code blocker. Remaining risks separated into code (none), infrastructure (runner instability A/B, still unconfirmed-fixed; old runner ICU), and manual owner verification (sign-in, production backup, production scale). Recommendation: deploy with documented risks -- re-run CI with the ICU fix, owner runs a real backup + scratch restore, deploy, then run the manual smoke test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>v1.0.0 |
||
|
|
57fabe9a97 |
docs(deployment): backup-restore, smoke test, runner finding C
- docs/deployment/backup-restore.md: production backup checklist; documents that deploy.sh loads the env, validates before backup, and validates the dump. Adds VERIFIED UTF-8/Norwegian-character round trip (æ ø å survive a real deploy.sh backup -> restore byte-exact; HEX compared). States plainly that no production database was reached and the owner must run one real backup + scratch restore. - docs/deployment/manual-smoke-test.md: owner-run post-deploy checklist (auth, applications, career profile, CV builder, AI, files). Each item names what "wrong" looks like. Documents that login requires the owner. - runner-investigation.md: Finding C -- the latest CI red was a real ICU code bug the runner caught correctly, not instability. Amends the blanket "outside the repository" conclusion. A and B stand as separate env issues. - release-candidate-review.md: corrected drifted line refs after the index fix; noted the CI ICU finding so the "purely external" verdict is honest. All claims reflect behaviour verified this session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b705cbaf60 |
chore: pin shell scripts to LF via .gitattributes
deploy.sh and the other scripts run on the Linux deploy host and in Docker. A CRLF checkout breaks them with "bad interpreter: bash\r". The committed blobs are already LF, but nothing guaranteed it against a host with core.autocrlf=true. `*.sh text eol=lf` makes it explicit. Verified: git ls-files --eol shows attr/text eol=lf on all .sh files; renormalize produced no index churn (already LF). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fba858e8eb |
fix(cv): force language alias precedence over host culture data
The CI backend test job failed on HumanLanguageCatalogTests:
nynorsk -> expected "Norwegian", actual "Norwegian Nynorsk"
This was a real bug, correctly caught by the runner -- not runner
instability. Reproduced on Ubuntu 20.04 / libicu66 (the CI runner's ICU)
with .NET 9 installed via dotnet-install.sh exactly as CI does.
Root cause: BuildLanguageLookup's explicit normalization aliases
(nynorsk/bokmål/norsk -> Norwegian) were added with map.TryAdd, which
loses to any key the culture enumeration already inserted. On libicu66
the "nn" culture's NativeName is the bare word "nynorsk", so enumeration
claimed key "nynorsk" -> "Norwegian Nynorsk" first and the explicit alias
silently lost. On libicu70+ (Debian/Ubuntu 22.04+, my earlier local
runs) the native name is "norsk nynorsk", so the key was free and the
alias won -- which is why it passed locally and only failed on the
runner's older ICU. Same host-ICU dependence class as
|
||
|
|
5c5a572cfc |
docs(ops): record release-candidate audit findings
Adds the two issues found and fixed during the release-candidate audit to release-candidate-review.md: the follow-up reminder index that never created on MariaDB (fix in the preceding commit), and the nondeterministic timeline day-grouping test. Corrects database-ownership.md drift: the MariaDB startup scenarios now report 42 tables (measured in every scenario this audit), not the stale 40 from before the last Phase 5 tables were added, and adds the partially-migrated heal scenario (35 -> 42) that was verified. All claims reflect behaviour verified in this audit: 420 tests on Windows and Linux in both ICU modes, all three Docker images built, four database startup scenarios against live MariaDB 11 and SQLite, and a full backup -> restore -> app-start cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
95646e1d53 |
fix(db): create follow-up reminder index on MariaDB
The reconciler's IX_JobApplications_OwnerUserId_FollowUpAt was declared as
(OwnerUserId(191), FollowUpAt) with no prefix length on FollowUpAt. But
FollowUpAt is `text` on MariaDB -- JobApplications is migration-owned and
the migration was scaffolded against SQLite, which stores DateTimeOffset
as TEXT. A text column cannot be indexed without a prefix length, so this
index failed the 3072-byte key check on EVERY MariaDB boot, was caught by
TryCreateIndex, and was silently skipped -- leaving the follow-up reminder
query (OwnerUserId + FollowUpAt) unindexed.
Two consequences, both real:
- the index the code intends to create never existed on MariaDB
- every healthy boot logged "Specified key was too long", which
deploy/first-production-deployment.md lists as a STOP-AND-ROLL-BACK
signal -- so an operator following the runbook could abort a good deploy
The author already handled the identical problem for the longtext Status
column one line below with Status(50). Apply the same fix: FollowUpAt(20).
ISO-8601 date strings sort lexicographically, so a 20-char prefix
("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
Verified on a fresh empty MariaDB 11 container: the index is now created
(both key parts present), zero "Specified key was too long" lines, zero
skipped indexes, zero unhandled exceptions, 42 tables, app healthy. This
was the only unprefixed text column in any reconciler composite index --
the datetime columns on reconciler-owned tables are datetime(6). SQLite is
unaffected (its CREATE INDEX has no key-length limit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
c1ff98ff4c |
fix(test): make timeline day-grouping test deterministic
Timeline_groups_by_day_newest_first seeded two "same day" events as DateTime.Now.AddDays(-3) and DateTime.Now.AddDays(-3).AddHours(2). When the wall clock was within two hours of midnight the second timestamp crossed into the next calendar day, so the service grouped them into two days instead of one and the test failed (expected 2 day-groups, got 3). The service is correct -- it groups by e.At.Date, which is the intended behaviour and what the test name asserts. The test was nondeterministic, failing roughly two hours out of every twenty-four, including in CI whenever CI ran late in the day. Anchor the two older events to DateTime.Today plus fixed hours (9 and 11) so they always land on the same calendar day regardless of wall-clock time. The "today" event stays DateTime.Now so the "Today" label assertion still exercises the real path. Verified: 420 tests pass at 22:35 local (the failing window) and on Linux with full ICU and under DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
834a775c9d |
docs(release): final deployment checklist
Validation only. No application behaviour changed. Verified against the code rather than the prose, by line number: Before deployment -- validate_deploy_config (deploy.sh:343) and backup_database (:347) both precede the build (:375) and the container replacement (:382), so nothing is built, stopped or replaced without a verified restore point. During deployment -- startup runs ReconcileSchema (:1965), Database.Migrate (:1973), ReconcileSchema (:1984). All seven Phase 4/5 migrations confirmed to have a literally empty Up body, which is what makes a code rollback safe. Health checks and rollback -- backend and frontend healthchecks present, frontend gated on backend health, rollback documented in two places with the code-vs-database distinction. After deployment -- added an eight-point owner checklist covering login, existing applications, workspace, career profile, CV builder, public CV, AI features and attachments. Each item names what wrong looks like, because "it loaded" is not a check. Merged the previous overlapping "After deploying" list into it rather than leaving two competing checklists. Recorded the CV language ICU defect as closed, with the note that it was invisible to a normal local test run -- the clearest evidence in this review that passing locally and correct in the deployed container are different claims. Sections are now READY / BLOCKED / MANUAL VERIFICATION, with accepted limitations kept separate. Test count updated to 420. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
df9322f5c0 |
docs(ops): verify production backup restore
Full backup -> verify -> restore -> start-the-app rehearsal of the deploy.sh backup path against MariaDB 11. Verified: the real backup_database function selected the MariaDB path from DATABASE_PROVIDER=mariadb, produced a valid .sql.gz with 42 CREATE TABLE statements and an intact "Dump completed" trailer, restored into a separate empty MariaDB container, and the application then started healthy against the restored database with the reconciler finding nothing to do. All 42 tables matched on row count, and content survived including foreign key relationships and career profile JSON. This is a rehearsal, NOT a verification of production data. No production host was contacted and no production data was read. This machine has no route to production: no /opt/job-tracker, no DATABASE_PROVIDER or connection string in its .env, and the local stack runs SQLite. Production host, user and key are CI secrets not available here. The document leads with that scope limit, records the commands to run against production with values substituted, and ends with the checklist that actually closes the gap -- including checking that non-ASCII CV text survives the round trip, which the ASCII-heavy seed data did not prove. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
96816186cb |
fix(cv): preserve human languages during structured CV normalization
HumanLanguageCatalog built its lookup table solely from CultureInfo.GetCultures, so which languages counted as human languages depended on the host's ICU data rather than on the CV. Measured: 806 cultures on a normal Windows or Linux machine, exactly 1 under globalization-invariant mode, and an English-only subset on a container with trimmed ICU data. Consequences by environment, all silent: - full ICU: correct - trimmed ICU: canonical names present in the reduced data survive and the rest are dropped, so a CV keeps English and loses Norwegian - invariant: every language is dropped and a CV import loses its Languages section entirely, with no error The tests were right and are unchanged. Seed the catalog explicitly with the languages a CV realistically lists, before the culture enumeration, which still runs and still adds breadth. Nothing in the seed collides with a technical skill -- Go, Java, Swift, Rust and Basic are deliberately absent, and Basic is also a proficiency level. Verified 420 tests pass in four environments: Windows and Linux, each with full ICU and with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. Before this change the invariant runs failed 5 tests. No test was modified, skipped or relaxed. Added HumanLanguageCatalogTests to pin the seeded catalog, confirmed non-vacuous by removing the seed and watching 15 tests fail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
de35947244 |
docs(ops): finalize release candidate status
Restructured into READY / BLOCKED / MANUAL, with accepted limitations kept separate. B1 (backup selected the wrong provider and reported success) and N2 (/health always reported version: unknown) are both closed and verified; their original findings are kept because the failure modes are worth understanding. N1 closed with the .env.example additions. One blocker remains and it is external: the CI runner. Stated with what it needs from the owner, and with the decision it forces -- fix the runner, or deploy deliberately from a locally verified commit knowing CI is red. MANUAL now leads with backup and restore readiness: the mechanism is verified against containers, but only the owner can prove it works on production data. Added a note that authenticated smoke testing is not an automation gap that more work would close -- sign-in needs a password, and no automated step here should handle one. Validation only. No application behaviour changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8f6f2ba8d6 |
fix(health): report configured application version
/health read the APP_VERSION environment variable directly, but docker-compose passes App__Version, which binds to the App:Version configuration key. The variable under that name never existed in the container, so the endpoint always reported "unknown". Read App:Version through IConfiguration, the approach AdminSystemController already used for the same value. The resolution rule (configured version, else assembly version) moves to a shared BuildMetadata helper rather than being written twice; AdminSystemController now calls it, so the admin page and /health cannot drift apart. Local development is unaffected: nothing sets App:Version there, and the assembly-version fallback still applies. Tests pin the configuration KEY, not just the behaviour, including that an App__Version environment variable binds to App:Version. The original bug failed silently, so a behavioural test alone would not have caught it. Verified against a running backend: App__Version=9.9.9-test reports 9.9.9-test; unset reports the assembly version rather than "unknown". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
66b02bcab8 |
fix(deploy): load production environment before backup
deploy.sh symlinked /opt/job-tracker/shared/.env for docker compose but never loaded it into its own shell. Its own decisions therefore ran against an empty environment: DATABASE_PROVIDER fell back to sqlite on a MariaDB host, so the deploy tarred the data volume, printed "Backup verified" and continued with no database dump. The operator saw a green backup line and a new file in the backups directory, and had no restore point. Load the shared env before any decision. Parsed line by line rather than sourced, because a compose .env is not a shell script and an unquoted value containing spaces would execute as a command. Values already in the environment win, so CI-provided APP_VERSION and friends still override the file. No value is echoed. Remove the sqlite default. DATABASE_PROVIDER must be stated; missing or unrecognised aborts the deploy. Validate deployment configuration before the backup, and so before anything is built, stopped or replaced: the connection string when the provider needs one, AI_SERVICE_TOKEN (compose declares it with :?) and AUTH_JWT_KEY (the backend throws on a blank key). Names in the output, never values. Verify each backup against its own format. A dump must be valid gzip, contain CREATE TABLE, and carry the "Dump completed" trailer, so a dump that died partway through is rejected. An archive must contain jobtracker.db. A tar can no longer pass the dump check. Also resolve the SQLite volume by its project-prefixed name and fail if absent. The bare jobtracker_data name would have silently created an empty volume and backed that up -- the same class of bug, found while testing this fix. Verified against a seeded MariaDB 11 container and real Docker volumes: provider selection, all four validation failures, both backup formats and their failure paths, truncated and trailer-stripped dumps, and zero secret occurrences across every test's output. Docs updated for the drift: deploy/README.md, deploy/first-production- deployment.md, docs/release-candidate-review.md (B1 closed) and .env.example, which now names the two database variables. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ab53582c71 |
docs(ops): add release candidate review
Final validation pass before the first production deployment. Findings only; no application code changed. Verified against the implementation rather than the other documents: migration and reconciler ownership, startup order, authorization coverage, tenant isolation, AI service protection, file access, the five architecture rules, container dependency ordering and failure behaviour. Two blocking items, one new: deploy.sh symlinks the shared .env for docker compose but never sources it, so DATABASE_PROVIDER is unset in the script's own shell and backup_database takes the SQLite branch on a MariaDB host. It tars the data volume, verify_backup only checks the file is non-empty on that path, and the deploy reports a verified backup that contains no database dump. Same root cause silently disables the APP_PUBLIC_BASE_URL smoke check and the Ollama warmup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
25b64bee8a |
docs(ops): add first production deployment runbook
Written against the actual implementation rather than the existing docs, and validated locally against MariaDB 11 containers. No application behaviour changed — this commit adds two documents. deploy/first-production-deployment.md covers pre-deployment checks, the eight deployment steps, smoke tests for backend, database and application, and rollback. It documents what deploy.sh really does: it backs up first and aborts on failure, and it replaces containers with up -d --force-recreate rather than running compose down, so the window is container start time. It also records the startup sequence as implemented — reconcile, migrate, reconcile — and that Database.Migrate() throws rather than limping on. Validation surfaced things worth writing down. The connection string resolves from inside the backend container, so Server=127.0.0.1 means the container and not the host; this broke a validation run before it could have broken a deploy. DATABASE_PROVIDER defaults to sqlite, and if it goes missing the backend does not quietly serve an empty database — it exits with "no such table: INFORMATION_SCHEMA.TABLES", which is loud but baffling if unexplained. A blank AUTH_JWT_KEY throws at startup when auth is required, which is the right behaviour. The runbook maps each of these log lines to its cause. Rollback is documented with the distinction stated plainly: a code rollback keeps all data and is almost always the whole fix, while a database restore discards everything written since the dump. Restore only when the data itself is wrong. docs/release-checklist.md records the completed architecture work, local verification results, known risks with severities, the unresolved CI runner blocker and what would unblock it, and seven first-deployment warnings. Validated: compose build; compose up on a fresh MariaDB (42 tables, backend healthy); the depends_on health gate holding the frontend until the backend is healthy; restart against the populated database with rows preserved; backup and restore; and the failure paths. Not validated, and said so in both documents: the authenticated end-to-end journey, because signing in needs a password. 393 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
93462b799c |
chore(ops): add deployment backups restore docs and health checks
Closes the three operational blockers from the production readiness review. deploy.sh now takes a database backup before it builds, stops or replaces anything, and aborts the deploy if the backup fails — so no deploy proceeds without a restore point. Dumps are gzipped and timestamped into /opt/job-tracker/backups (override with BACKUP_DIR), so one deploy never overwrites an earlier backup. Credentials come from the existing connection string and travel via MYSQL_PWD, never on the command line, so they cannot reach the process list or the deploy log. A dump that is empty or missing CREATE TABLE is rejected, because a truncated file that looks like a restore point is worse than none. SQLite deployments get their data volume tarred instead. Nothing is ever deleted automatically; retention is documented as manual. deploy/README.md documents backup creation, location, retention, database restore, application rollback, and when to use which — restore and rollback kept distinct, because a bad deploy usually needs only the rollback and restoring would discard everything written since the dump. Health checks now cover backend and frontend, which previously had none. GET /health is anonymous, cheap, and deliberately does not touch the database: a health check that queried MariaDB would restart a healthy backend whenever the database blipped, and would hand out an unauthenticated way to probe database availability. The backend image gains curl on the existing chromium apt layer, since the aspnet runtime ships neither curl nor wget. frontend now waits for backend to be healthy rather than merely started, because nginx proxies /api and refuses to start when the upstream cannot be resolved. Verified against real containers, no production data: backup from a seeded MariaDB 11; restore into a clean MariaDB 11 with rows identical; bad credentials and a missing connection string both abort non-zero and leave no partial file; SQLite volume backup produces a readable archive; backend and frontend both reach healthy; and a backend pointed at an unreachable database exits and is reported unhealthy, so a broken deploy cannot present as a running stack. Incidentally confirmed the earlier authorization work: with Auth:Require unset, /health returns 200 while /api/jobapplications returns 401. 393 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b2b87f39a5 |
docs: production readiness review
Audited migrations, reconciler ownership, authentication, authorization, AI security, file storage, public CV access, backups, logging, error handling and configuration defaults before the first production deployment after the Phase 4/5 architecture changes. Verdict: not ready to deploy unattended. The application verifies clean — fresh MariaDB, populated MariaDB restart, existing SQLite upgrade, 393 backend tests, 128 frontend tests, both Docker images — but four things stand in the way, and the review lists them rather than declaring success. Two are deployment blockers found by this audit. deploy.sh takes no database dump before bringing the stack down, which is exactly backwards for a first deploy where the reconciler will create roughly a dozen tables on a database many commits behind; BackupController offers only an application-level encrypted export, not an operational dump. And there is no documented restore procedure — a backup nobody has restored is a hypothesis. One is an operational gap: compose defines health checks for ai-service and ollama but not for backend or frontend, so nothing detects a backend that starts and then goes unhealthy. One is the standing external blocker: CI is red for an environmental reason, and deployment is gated on it. Also recorded as accepted rather than fixed: console-only logging, no global exception handler, the intentionally anonymous client-error endpoint, and the fact that nobody has walked the authenticated end-to-end journey. Includes a ten-step deployment checklist and a rollback plan. Rollback is safe because every Phase 4/5 migration is a no-op, so reverting the code never leaves migration state ahead of the schema — but it does not recover data users create in the new tables during the window, which the review says plainly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
432e1fd667 |
feat(timeline): emit application lifecycle events
The timeline could interpret InterviewScheduled, InterviewCompleted, OfferReceived and FollowUpCompleted, but only StatusChanged and FollowUpSet were ever written, so those branches never rendered. Events are now derived from the status TRANSITION in one shared emitter rather than at each call site, so the two status-change boundaries in JobApplicationsController cannot drift apart and a third would get the behaviour for free. Both boundaries now call it instead of hand-writing the StatusChanged block. Deriving from the transition rather than the resulting state is what prevents duplicates: one user action produces at most one lifecycle event, re-saving an unchanged status produces none, and reaching an offer twice records it once. Moving an application backwards is treated as a correction, not a completed interview, so only a forward move out of an interview stage counts. An Interview to Offer move reports the offer, which is the thing the user cares about. Completing a follow-up checklist item emits FollowUpCompleted, guarded on the same transition rule so re-saving a done item stays silent. The task itself remains a checklist item — this only records that it happened. No new history store: every event is a JobEvent row, which stays the single source of application history. 393 backend tests pass, including timeline rendering of the emitted events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c0bf69ad56 |
fix(security): enforce explicit api authorization
Authentication relied on a fallback policy gated on Auth:Require, which defaults to false. Five user-owned controllers carried no [Authorize] of their own, so a deployment that lost that flag would have served tenant data anonymously: JobApplications, Companies, Correspondence, Rules and JobImport. All five now declare [Authorize(AuthenticationSchemes = "local")] explicitly. This does not affect local development, which already sets Auth:Require=true in appsettings.Development.json — the gap was only ever in a production configuration that omitted the flag. Added a reflection test over every controller in the assembly so a new one cannot ship unprotected by accident. A controller passes if the class requires authorization, or if every action declares its own [Authorize] or [AllowAnonymous] — the shape AuthController and TwoFactorController need, since login and register must stay anonymous while the rest must not. Public endpoints are an explicit allow-list, so making something anonymous is now a deliberate edit rather than an omission. That test found one real gap: AuthController.Logout declared neither attribute. It is now explicitly [AllowAnonymous] — it only clears the caller's own session cookies and leaks nothing, and requiring authentication would leave a user whose token had already expired unable to sign out. Also pinned: admin controllers require the Admin role rather than merely a signed-in user, and PublicCvController stays anonymous so shared CV links keep working. 384 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a3735299ec |
docs: complete application workspace phase
Phase 5.6 — refinement and validation, no new features. Audited the ownership rules mechanically across all six Phase 5 services rather than asserting them in prose. None writes to CareerProfile or its children, none writes CV variant content, and the two services that read the profile (ApplicationChecklistService, ApplicationIntelligenceService) never save it. The rules hold: JobEvent is the history source, the checklist is workflow guidance, readiness is a projection of it, CareerProfile is the source of truth, CvVariant is derived output, and AI only appends to AiInteraction. Verified locally end to end: 379 backend tests in Release, 128 frontend tests across 36 suites, TypeScript clean, frontend production build, both Docker images, a fresh empty MariaDB 11 (42 tables, no exceptions), a restart against the populated database (rows preserved), and the existing SQLite dev database. The security review found one genuine gap, reported rather than silently changed: authentication is enforced by a fallback policy gated on Auth:Require, which defaults to false. docker-compose.yml hardcodes it true so every compose deployment is protected, and every Phase 5 controller carries an explicit Authorize attribute, but several pre-Phase-5 controllers do not — a deployment that lost the flag would expose them. Adding explicit attributes changes local development behaviour, so it is flagged for a deliberate decision instead of applied unilaterally. docs/phase-5-completion-report.md records the milestones, the architecture decisions and their reasoning, the ownership audit, the verification matrix, and four remaining risks: CI red for an environmental reason (a docs-only commit fails identically), production behind and needing a backup before first deploy, the authentication configuration gap, and prompt quality being unmeasured. Phase 5 is feature-complete locally. It is not deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fc132273f7 |
feat(ai): include application intelligence in interview preparation
Interview generation saw only the profile and the advert, so it produced generic questions. It now also receives what the workspace already computed: seniority, employment type, key requirements and advert technologies from the job analysis, plus the match score, the skills the candidate demonstrably has, the most relevant experience and projects — and above all the gaps, which is exactly what an interviewer probes. No second pipeline. The context comes from ApplicationIntelligenceService, which is deterministic and read-only, so this adds no AI call and cannot alter user data. Generation still runs through AiWorkspaceService and is still appended to AiInteraction. The dependency is optional, so existing constructions keep working and a missing intelligence service degrades to the previous prompt instead of failing. Only the interview module is affected; job-analysis, career-match, cover-letter and application-review assemble exactly as before. Suggestion-only is unchanged and now pinned by tests: generation adds an AiInteraction and nothing else, creates no InterviewPrepItem, leaves existing prep items and the CareerProfile untouched, and refuses another user's application. Context is scoped to the requesting user, so another user's profile is never scored in. 379 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3d74baef78 |
feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase. Interview preparation gets a durable, user-owned store. There were already two per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are caches that regenerate when their context signature changes — anything a user typed into them would eventually be overwritten. InterviewPrepItem is the side nothing regenerates, covering company research, technical notes, behavioural answers, STAR examples and the user's own questions in one table, because those categories differ only by label and adding one must not need a migration. Each item records whether the user wrote it or accepted a suggestion, and an IsPrepared flag makes the section double as the preparation checklist. Generation stays in the existing AiWorkspaceService "interview" module, appended to AiInteraction as before. A suggestion is history until the user adds it as a prep item; opening the section generates nothing. Follow-up reuses what exists rather than adding a tracker. The date is JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted service already act on, so reminders keep working with no new wiring. The task stays an ApplicationChecklistItem in the follow-up category — the section counts open tasks without owning them. The record is a FollowUpSet JobEvent, the same type the rest of the app emits. Communication is untouched: Correspondence already owns recruiter contacts, history and notes, and the workspace already mounted it. The timeline interpreter learned five more types — InterviewScheduled, InterviewCompleted and OfferReceived as milestones, FollowUpCreated and FollowUpCompleted as routine, deliberately outside the milestone spine so it stays a summary of what actually happened. JobEvent remains the history source. InterviewPrepItems is reconciler-owned with a no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar owner and title, tinyint flag, datetime(6), composite index inside the key limit. 371 backend tests, 128 frontend tests, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4759f1f610 |
fix(cv): support application variant deep links
The Application Workspace CV section linked to /cv-builder?variant={id}. That
route does not exist: the builder is mounted at /career/builder/:id and reads the
variant from the path, not a query string. The button dead-ended.
Corrected the href. No loading logic was added — the editor already loads the
variant by id and already has a safe error state, and ownership is already
enforced server-side, where CvVariantService scopes every read to the owner and
the controller returns 404.
Added tests for the deep-link entry point, which had none: the variant loads from
the route, a missing variant shows the error state rather than an empty editor,
and another user's variant is refused identically. The asset test now asserts the
exact href, so a route that the router does not serve fails the build instead of
shipping.
118 frontend tests and the production build pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
02b38f7acb |
feat(workspace): application assets workflow
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
a7cecce13d |
feat(workspace): add application intelligence
Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".
Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.
Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.
Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.
All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.
Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.
345 backend tests, 104 frontend tests, type check, production build all pass
locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
7f426e255c |
fix(infrastructure): support clean MariaDB initialization
A completely empty MariaDB database could not start: the reconciler assumed migration-owned tables already existed, and migrations assumed reconciler-owned tables already existed. Neither could go first. Existing databases worked, so only fresh installs were affected. Startup is now an explicit sequence: connect, reconcile, migrate, reconcile, start. The reconciler runs twice because neither position alone works — pass 1 repairs legacy schemas and creates the reconciler-owned tables that migrations reference, pass 2 picks up everything that could not exist yet on a fresh database. Every statement is existence-guarded, so the second pass is a no-op scan on a correct database. Untangled the overlapping ownership: - RuleSettings is migration-owned. The reconciler also created it, which made a clean install fail with "Table 'RuleSettings' already exists". It now only seeds the default row, and only once the table exists. - The six CareerProfile child tables are reconciler-owned. Their migration was scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to varchar(255) in the model so the index fits. - Reconciler tables that reference another table are guarded on their parent, so pass 1 skips them on an empty database instead of failing on the foreign key. - All index creation goes through one EnsureMySqlIndex helper, guarded on table existence as well as index existence. This removes ten copies of the raw block that crashed on a missing table. - The DbContext-owned connection is no longer disposed by the reconciler, and Open() is guarded on connection state, so the second pass can reuse it. Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts), restart on the populated database (idempotent, rows preserved), empty MariaDB via the Docker image, fresh SQLite (42 tables), and an existing partially migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications and 8 companies intact). 329 backend tests pass in Release. Ownership rules, startup order, fresh install and production upgrade are documented in docs/infrastructure/database-ownership.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1f1cbb92f3 |
docs(infra): docs-only commit fails CI identically — proves failure is not in the repo
Run 531 (
|
||
|
|
3a906b881e |
feat(workspace): unified application checklist (Phase 5 milestone 2)
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e55a6e86b7 |
feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
Every JobApplication gets a dedicated workspace at /applications/{id} — a
surface, not a new data store. It owns no data and duplicates none: CV comes
from the Phase 4 CvVariant lens, analysis/match/interview from the existing
AiWorkspacePanel, documents from Attachments, communication from
Correspondence, activity from JobEvent, stage semantics from JobPipeline. No
career data is copied and nothing here writes.
- GET /api/jobapplications/{id}/workspace: one aggregate read (role, company,
stage, dates, attached CV variant, cover letter, documents, AI history,
recent activity) replacing the page fanning out across endpoints
- Next recommended action: ordered rules answering "what do I do next?", the
core product principle for this phase
- ApplicationWorkspacePage: left nav + linkable ?section=, reusing the existing
component for each domain; later-milestone sections say so rather than faking
- Entry point from the job dialog via an optional onOpenWorkspace callback —
the dialog must not depend on router context (it is mounted without a
<Router> in several suites), so the caller owns navigation
- 8 backend tests (aggregate, CV variant surfacing, counts, activity ordering,
next-step rules, tenant scoping)
Local: 314 backend, 88/88 frontend (31 suites), tsc clean, production build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
3b59152782 |
docs(infra): runner fails at different stages across runs — nondeterministic
Two post-reboot runs: the first reproduced the failure identically (smoke 1s pass, suite 3s fail), the second failed earlier at `dotnet restore` in 0s — a step that succeeded in 3-4s on every previous run, same commit, same runner. That rules out stuck state (reboot changed nothing) and rules out a deterministic sandbox policy such as seccomp/W^X blocking runtime IL emission, which was the leading remaining hypothesis. Combined with host telemetry showing no disk/memory/PID pressure, confidence in any specific mechanism drops to ~25%; confidence that application code is not the cause stays high. Removes the pure-vs-Moq diagnostic scaffolding (it never executed). No test skipped or weakened. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c4c0cd4e42 | ci: test whether EF-InMemory/Moq (runtime IL emission) is the runner trigger | ||
|
|
8f73548e33 |
docs(infra): host telemetry rules out disk/memory/PID exhaustion and fail2ban
Server shows 62G free (71% used), inodes 14%, 16G /dev/shm (~32G RAM), ulimit -u 127749, no cgroup pids.max, and no fail2ban installed. That falsifies both resource-exhaustion hypotheses at the host level and the fail2ban explanation for the deploy failure. Notes the caveat that Gitea act_runner usually runs jobs inside a Docker container, so host figures do not describe the environment the tests ran in (separate cgroup limits, and a 64MB /dev/shm by Docker default). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
55962bd29b |
docs(infra): conclude runner + deploy investigation — both outside the repo
Moves the report to docs/infrastructure/runner-investigation.md with the
requested structure (evidence, experiments, hypothesis, confidence, required
infrastructure changes, why application code is no longer suspected).
Decisive new experiment: the suite was run from a clean `git archive HEAD`
tree — byte-identical to CI's checkout, without the gitignored runtime dirs
(jobtracker.db, keys/, CvArtifacts/, backups/) that earlier local runs had
silently included. 10/10 pass in 1s. That removes the last difference between
the local tree and the runner, eliminating application code (~95% confidence).
Also establishes, by route probe, that production is healthy but stale:
/api/public-cv/{unknown} returns 404 locally (route exists, AllowAnonymous) but
401 on prod, same as a nonsense path — PublicCvController is absent, so Phase 4
and Phase 5 have never deployed. Production therefore never ran the faulty
migration: no half-built tables exist there and no data cleanup is needed.
Deploy is a second, separate infrastructure failure: the first attempt reached
deploy.sh (37s, consistent with the MariaDB crash since fixed), every attempt
since dies at 3s at SSH connection time while the host serves traffic normally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
b7dc07b045 |
docs(ci): report runner-only backend test failure investigation
Bisected the failure across four CI runs (job logs are not readable via the Gitea API) down to the AiWorkspace test classes — 10 tests that pass on Windows, in a clean Linux container, under a 1GB memory cap, in CI's exact step order, with a custom-dir SDK and no DOTNET_ROOT, serially, and under a hostile locale/timezone. Ruled out: Linux behaviour, case sensitivity, path separators, locale/culture, time zone, environment variables, parallel execution, test ordering, shared state, memory. Not testable remotely: host permissions/limits. Assessment is environmental: the workflow already documents three failure modes on this same runner with an identical signature (processes dying with no error output — SDK cache corruption, npm ci SIGSEGV, CRA build OOM/SIGSEGV). Report includes evidence table and recommended infrastructure fix. Removes the temporary bisection scaffolding; keeps the restore/build/test split and the host smoke. No test was weakened, skipped, or filtered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0f62dc42c2 | ci: split failing slice per test class to name the offender | ||
|
|
7fa3080a28 |
ci: bisect backend suite across steps to localise runner crash
The host smoke passes, so the test host starts; the full suite still dies ~3s in with parallelism disabled, so one specific test takes the process down on this runner only. Job logs are unreadable via the API, so the suite is sliced across four steps — the first failing step identifies the class. Temporary diagnostic scaffolding; every test still runs, nothing is skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2bdc4a9748 |
ci: isolate backend test-host failure and serialise the suite
Restore and build pass on the self-hosted runner but the test run dies after ~3s — too fast to have executed 306 tests. The suite passes on Windows, in a clean Linux container, under a 1GB memory cap, in CI's exact step order, and with the SDK installed to a custom dir without DOTNET_ROOT, so the trigger is specific to this runner rather than the code. Adds a one-test host smoke step (separates "host cannot start" from "the suite takes the host down" using step boundaries, since job logs are not readable via the API) and disables xUnit collection parallelism for the full run — the same remedy the frontend already needs (--runInBand) on this resource-flaky runner. All 306 tests still run; only concurrency changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
45725acc7c |
ci: split backend restore/build/test into separate steps
The backend test step fails on the self-hosted runner after 8s while passing on Windows, in a clean Linux container, and in CI's exact build-then-test order. The job log is not readable via the Gitea API (401), so step boundaries are the only available telemetry: splitting restore / build / test makes the failing phase identifiable from step timings alone. Restore retries once, mirroring the npm ci and dotnet SDK retries already in this workflow for the same runner's known flakiness. The suite itself is unchanged — still the whole suite, nothing filtered or skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cfba7fbbc4 |
fix(ci): actually run the backend test suite
The build step only builds JobTrackerApi, so the test project was never compiled — and `dotnet test --no-build` then made the step a ~1s no-op (locally it errors "test source file not found"; on the persistent self-hosted runner it can silently run a stale binary). The 306 backend tests have not been gating CI. Drop --no-build so the test project is compiled and the suite runs. Verified locally: 306 passed in 14s instead of "succeeding" in 1s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1430313a20 |
fix(db): provision CV builder + AI workspace tables via the MySQL-safe reconciler
Deploy failed on prod (MariaDB) while the test job was green: backend startup threw during Database.Migrate(), so deploy.sh's post-deploy health check exited. Root cause: AddCvVariants/AddAiInteractions were scaffolded against SQLite, so they bake SQLite type names into their DDL — DateTimeOffset emits `TEXT`, bool/int emit `INTEGER`, and the PK gets no AUTO_INCREMENT. Run against MariaDB that yields a structurally wrong table, and the composite index over a TEXT column then trips "ERROR 1071: Specified key was too long; max key length is 3072 bytes". SQLite accepts all of it, which is why local/container verification passed. Fix, following the pattern already used for CareerProfiles/AiWorkspaceNotes: - both migrations become no-ops; the three tables are reconciler-owned - reconciler provisions them idempotently per dialect (MySQL: varchar/int AUTO_INCREMENT/datetime(6); SQLite: CREATE TABLE IF NOT EXISTS) - DropMalformedMySqlTable rebuilds a half-built table left by the failed migration, guarded on row count so a table with ANY rows is never dropped - bound the indexed string columns with HasMaxLength so the model matches Verified against a real MariaDB 11 container: reproduced error 1071, then confirmed the corrected DDL yields auto_increment PKs, varchar/datetime columns and all previously-failing indexes. 306 backend tests green; SQLite container starts clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |