Skip to content

Adaptation VigieProcure : Dockerfiles, DeepSeek, déploiement vigiep1 - #1

Open
franckh-stack wants to merge 45 commits into
pre-vigieproc-chantierfrom
release
Open

Adaptation VigieProcure : Dockerfiles, DeepSeek, déploiement vigiep1#1
franckh-stack wants to merge 45 commits into
pre-vigieproc-chantierfrom
release

Conversation

@franckh-stack

Copy link
Copy Markdown
Owner

Résumé

PR documentaire couvrant l'ensemble du chantier d'adaptation de trycompai/crm pour l'infrastructure VigieProcure (fork+install, cf. scripts/SPEC-fork-trycompai-crm.md dans le dépôt VigieProcure). Tout est déjà déployé et fonctionnel sur crm.vigieproc.fr — cette PR sert de vue d'ensemble du diff, pas d'étape de merge (release est déjà la branche par défaut, tout est déjà dessus).

  • 3 Dockerfiles ajoutés (api, app, agent) — aucun n'existait en amont
  • Agent Eve : LLM basculé de Vercel AI Gateway vers DeepSeek (apps/agent/agent/lib/model.ts, agent.ts)
  • Images api/agent allégées (bun install --production) après épuisement disque répété en prod
  • Plusieurs correctifs de config découverts en déploiement réel (API_URL, BETTER_AUTH_SECRET propagé à crm-app, contrainte hd Google Workspace)

Base de comparaison

pre-vigieproc-chantier = état du fork juste avant ce chantier (commit 6d4793d, dernier merge amont avant le premier Dockerfile). Branche créée uniquement pour permettre cette vue de diff — pas un point de reprise à maintenir.

Test plan

Déjà vérifié en production (vigiep1) :

  • Les 3 conteneurs tournent, healthchecks verts
  • Connexion Google OAuth confirmée en conditions réelles
  • Agent Eve répond via DeepSeek (round-trip vérifié)

🤖 Généré avec Claude Code

franckh-stack and others added 30 commits August 28, 2026 18:32
Multi-stage build (deps/build/runtime) on oven/bun:1.3.12. Two fixes
verified empirically against the plan's first draft:

- apps/api's postinstall (chmod-trpc-binary.mjs) needs
  apps/api/scripts/ present in the deps stage, not just package.json,
  or bun install fails.
- @crm/db's postinstall runs `prisma generate`, whose
  prisma.config.ts calls env("DATABASE_URL") eagerly and throws if
  unset -- even though generate never opens a connection. Deps stage
  sets a placeholder DATABASE_URL for install; the real value is
  supplied at `docker run` time and overrides it.

Ignore file named apps/api/Dockerfile.dockerignore, not
apps/api/.dockerignore: Task 3's build command uses the repo root as
build context (docker build -f apps/api/Dockerfile ... .), and Docker
only honors a Dockerfile-specific ignore file when it is named
<path-to-dockerfile-relative-to-context>.dockerignore at the context
root. Verified with a canary file: apps/api/.dockerignore was
silently ignored (canary leaked into the image), Dockerfile.dockerignore
excludes node_modules/dist/.git as intended.

Verified: image builds, runs against the real crm_trycompai database
(137.74.172.178), connects, and serves GET /health -> 503
{"status":"error","database":"down"} -- expected since Task 2 only
provisioned an empty database, migrations run in Task 6.
…way, add Dockerfile

Vigieproc fork adaptation -- avoids a Vercel account dependency for the
main agent's LLM calls, cf. scripts/SPEC-fork-trycompai-crm.md Task 5.
eve already accepts a raw AI SDK LanguageModel in place of a Gateway
model-id string (PublicAgentStaticModelDefinition = string | LanguageModel),
so no change to eve itself was needed.

deepseekModel() uses @ai-sdk/openai's .chat(...) form explicitly, not the
callable-provider shorthand: the shorthand defaults to the Responses API
(POSTs to /responses), which DeepSeek's OpenAI-compatible endpoint does
not implement -- found empirically via the unit test's provider assertion.
`name: "deepseek"` overrides the default `openai` provider id.

selectedModel()/ModelSelection are kept (not removed as the task's draft
code suggested) because apps/agent/agent/subagents/agent_builder/agent.ts
and test/model.integration.spec.ts still depend on them for the per-run
custom-agent-builder model picker -- out of scope for this task, which
only swaps the main agent's static model. Its db import is now lazy so
importing this module (for deepseekModel(), from the new unit test)
doesn't eagerly require DATABASE_URL/TEST_DATABASE_URL.

The new unit test lives in apps/agent/test/ (not co-located next to
model.ts as first drafted) to match this app's existing convention --
every other bun:test spec lives there, specifically because apps/agent's
tsconfig include glob only covers agent/**, which has no bun:test types
and fails tsc --noEmit otherwise.

Dockerfile mirrors apps/api's and apps/app's (Tasks 3-4): DATABASE_URL
placeholder for @crm/db's postinstall prisma generate, multi-stage
bun build via turbo. Runtime CMD is `cd apps/agent && bun run start`,
not `bun apps/agent/scripts/start.ts` directly -- start.ts spawns the
`eve` CLI via child_process.spawn, which resolves against $PATH, and the
image's system PATH does not include node_modules/.bin (only `bun run`
augments it) -- confirmed empirically ("Executable not found in $PATH:
eve" with the direct form, despite the binary existing on disk).

No docker.io/docker CLI added to the runtime image: eve's sandbox backend
selection (selectDefaultSandbox in its own source) only tries Docker if
isDockerDaemonAvailableSync() is true, and falls back to microsandbox
otherwise. With no docker socket mounted (that lands in Task 6), the
container initializes its sandbox template and starts cleanly without
the CLI -- confirmed by running it standalone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014caYm8q32Dzgm2TiTeKK8G
crm-api (3.65GB) and crm-agent (4.56GB) runtime images copied the FULL
monorepo node_modules from the build stage (dev+prod deps for every
workspace at once) -- repeatedly exhausted vigiep1's 72GB disk across
rebuild cycles in production (29/08/2026). Both Dockerfiles now reinstall
with 'bun install --production' in a dedicated stage before the runtime
COPY, after removing the full node_modules tree.

apps/agent/package.json: moved microsandbox and just-bash from
devDependencies to dependencies -- eve's sandbox backend needs at least
one importable at runtime even though docker.sock (the preferred backend
in this deployment) is mounted; a --production install would otherwise
have silently dropped them (carried-forward minor finding from Task 5's
review, now directly relevant).
bun install --production re-triggers every workspace's postinstall hook
on a fresh install, including @crm/db's ('prisma generate') -- but prisma
(the CLI) is a devDependency, excluded by --production, so the hook fails
with exit 127. Unnecessary anyway: the Prisma Client was already
generated during the earlier 'build' stage and persists on disk
(packages/db/src/generated/), untouched by the node_modules rm+reinstall.
--ignore-scripts skips re-running it.
…endencies

crm-agent crash-looped after the --production slim-down:
'[TSCONFIG_ERROR] Failed to load tsconfig @crm/typescript-config/base.json:
Tsconfig not found' -- eve's 'start' re-bundles the authored agent module
on every boot (not just 'eve build' ahead of time), and needs tsconfig
resolution (and presumably the typescript toolchain) present at runtime,
not just at build/dev time. Both were devDependencies, excluded by
--production.
listMessages() already supported an arbitrary q= filter but nothing
called it with a participant clause -- the live incremental sync only
ever used listHistory(). searchByParticipant() reuses it unchanged
(default q string is byte-identical when query is omitted, pinned by
a regression test).
… backfill

Same treatment as GmailClient -- listEvents() gains an optional q
passthrough (omitted by default, matching today's behavior exactly),
searchByParticipant() is the new targeted-search entry point.
Behavior-preserving move of GmailSyncService's former private parse()/
sentAt() -- verbatim logic, now unit-testable on its own and reusable
by the contact-history backfill without duplicating parsing logic.
gmail-sync.service.ts delegates, no behavior change (no dedicated spec
existed for that service before; the extracted pure-function test is
the regression net for this move).
…y/contact + relinks

Optional 5th param, omitted everywhere in the live incremental sync
(byte-identical default behavior, pinned by re-running
mailbox-thread-writer.spec.ts unmodified). When passed by the contact
history backfill:
- a brand-new thread uses the given ids directly, skipping
  match.resolve() entirely
- a thread the live sync already stored by company-only match
  (companyId set, contactId null -- the common case) gets relinked to
  the newly-created contact instead of silently staying invisible on
  its Relationship panel. Relink never touches a thread already
  pointing at a different contact or a different company.
…backfillForParticipant()

apply() and a new buildContext() (extracted from sync(), same pattern
as ThreadWriterService.context()) become public so a preresolved
company/contact can drive writes directly -- default (preresolved
omitted) apply() behavior is unchanged for the live incremental
sync's own call. This is the first direct test coverage for this
service; flagging the pre-existing gap, not just closing it silently.

backfillForParticipant() is the contact-history-backfill entry point:
search by participant email, apply() each result with the known
contact/company, never touching the live sync's cursor/pagination
state.
Given a newly-created contact's email, searches Gmail (via
GmailClient.searchByParticipant + parseGmailMessage +
ThreadWriterService.store()) and Calendar (via
CalendarSyncService.backfillForParticipant()) in parallel, each
independently failing without blocking the other. Not wired into
ContactsService.create() yet -- that's Unit H.

Window/caps as tunable exported constants (24 months, 200 Gmail
results, 100 Calendar results), no pagination in v1 (documented
limitation, not silent). Capstone test proves the actual point of
this feature: a thread the live sync already stored by company-only
match becomes visible on the contact's Relationship panel query
after backfill, not just 'no error'.
google.module.ts exports CalendarSyncService + GmailClient (previously
internal-only). contacts.module.ts imports MailboxModule + GoogleModule
and registers ContactHistoryBackfillService. No circular dependency
(confirmed: nothing in mailbox/, google/, agent/, companies/, trpc/
imports ContactsModule). Verified by booting the real AppModule
(auth.e2e.spec.ts, --timeout 60000 -- the default 5s bun test timeout
is too short for this heavy a bootstrap, unrelated to this change).
Router passes ctx.user.id, same pattern as decideFact
(contacts.router.ts). Service gains an optional actorId param -- when
given and the contact has an email, fires history.run() in the
background (.catch()+log, same fire-and-forget pattern already used
for fields.queueBackfillForNewRecord). actorId omitted (all 3
pre-existing test call sites) or no email -> never fires, byte-
identical to before.

bulk.spec.ts, fields.spec.ts, record-delete.spec.ts each get a 7th
constructor stub (no behavior change, confirmed by re-running all 39
tests across the three files unmodified otherwise).
…izeImports

Reorders 3 files touched by this branch. Remaining 'needs formatting'
findings across the repo (this branch included) are the pre-existing
CRLF/core.autocrlf Windows artifact already documented in
CR-FORK-TRYCOMPAI-CRM-DEPLOYE-20260828.md -- confirmed present on a
clean release checkout too (241 pre-existing errors), not introduced
here.
feat(contacts): automatic Gmail/Calendar history backfill on contact creation
…ndence

Measured on real backfilled data (Damien Vignault, WP crm-enrich,
02/09/2026): 6 threads/13 messages from a decision-makers' club
mailing list (26-84 recipients each, sender bruno.hervein@orange.fr,
none of them personal) were polluting the contact's business
relationship view alongside 3 real 1:1 threads (1 recipient each).

parseGmailMessage() now returns null for a message that either
carries a List-Unsubscribe header or has more than
BULK_MAIL_RECIPIENT_THRESHOLD (5) recipients -- both signals proven
against the real data that triggered this fix. Applies to both the
live incremental sync and the contact-history backfill, since both
share this parser.
fix(gmail): filter mailing-list broadcasts out of the contact relationship view
… email/meeting

NOTE_TYPES fed the "Notes" tab's filter clause, but included EMAIL and
MEETING alongside NOTE/CALL -- both of which already have their own
dedicated tab. Effect: every synced email and every synced meeting
also showed up under Notes, which its own empty-state copy describes
as "what you write down for the next person to read" -- i.e. manual
entries only.

Found investigating a report that "all mail lands in the notes
channel" after the contact-history backfill made Notes tab traffic
visible for the first time (previously the tab was empty because the
live sync had never stored anything). NOTE_TYPES narrowed to
[NOTE, CALL] -- the two types with no dedicated tab of their own.
fix(activities): Notes tab was showing every synced email and meeting
…hesis

Requested after the deployed bulk-mail filter (gmail-message-parser.ts)
didn't catch every Club'IT broadcast for Damien Vignault -- some slip
under the recipient-count/List-Unsubscribe heuristics. Reps need a
manual escape hatch for whatever the automated filter misses.

EmailThread gains excludedAt (soft, not a delete -- the row and its
rfcMessageId/rootMessageId dedup keys stay in place so the live sync
never re-imports an excluded thread). ActivitiesService.timeline()/
timelineCounts() and ContactsService.relationship() now filter it out
everywhere the synthesis reads from EmailThread/Activity. New tRPC
mutations activities.excludeEmail/restoreEmail (reversible).

Known gap, not covered by a dedicated test: ActivityStampService's
lastActivityAt is not recomputed on exclude -- if the excluded thread
was the most recent activity, lastActivityAt won't fall back to the
next one until something else touches it. Acceptable for v1 (matches
this session's "backend only, no UI yet" scoping), flagged for anyone
picking this up next.
feat(activities): allow excluding an email thread from the CRM synthesis
DEC-C-CRM-10 (Franck, 2026-09-03): inbound direction of F.24 (VigieProcure's
CRM gateway) activated. Branches into the same point that already queues
agent-worker events (withCrmEvents) -- best-effort, HMAC-SHA256 signed on
the raw body, same "no secret = no bridge" rule as the existing agent
bridge.

New vigieprocure-bridge.ts, VIGIEPROCURE_WEBHOOK_URL/_SECRET env vars
(both optional, unset = no-op). createEventTask now returns the AgentTask
id, used as a stable event_id for VigieProcure-side deduplication.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLAb4MA3VwGs6CKRhxtsmD
Corrige deux lignes trop longues detectees par le hook pre-push (bun run
lint). Aucun changement de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLAb4MA3VwGs6CKRhxtsmD
Notify VigieProcure on CRM deal lifecycle events (DEC-C-CRM-10)
bunx biome check --write . after biome.jsonc's formatter/organizeImports
rules. All 12 files are pure line-wrap/reformatting (long object
literals, type unions, function signatures split across lines) --
verified diff-by-diff, no logic or values changed.

This was the actual scope of the pre-push lint gate failure blocking
feat/company-siren-column. The other ~790 files git status flagged were
a false positive from local core.autocrlf=true (Windows checkout) vs
this repo's LF blobs -- confirmed via `git diff --stat` (empty) and
`git ls-files --eol` (i/lf w/lf, no divergence). No commit needed for
those; see follow-up note on core.autocrlf / .gitattributes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
…e.failed

`no-unknown-parameters` allows an explicit `unknown` parameter only when
named `cause` -- the convention every other error-normalizing method in
this repo already follows (companies.service.ts, contacts.service.ts,
deals.service.ts, fields.ts, main.ts, settings.service.ts all name it
`cause`). This was the sole outlier, still named `error`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
…idening

`no-known-value-widening` -- `return { calls }` already carries evidence
for `{ calls: URL[] }`; the explicit anonymous return-type annotation
discarded it for no benefit (callers destructure `{ calls }` either way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
franckh-stack and others added 15 commits September 4, 2026 08:52
`no-runtime-typeof` -- bun:test ships a dedicated matcher for exactly
this assertion, same intent without a bare typeof check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
…mport

no-known-value-widening: gmailCaptured/calendarCaptured were annotated
with anonymous object-literal types instead of the named contract
already defined by the stub functions that mutate them
(GmailStubOptions["captured"], calendarClientStub's own parameter type
via Parameters<>) -- satisfies alone doesn't work here since these
objects start empty and get filled by the stub's side effect.

Also drops MailboxSyncModel (aliased MailboxSync), imported but never
referenced -- preexisting on release, caught by biome's
noUnusedImports while touching this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
chore: fix lint debt (formatting + anti-slop retyping)
…alue

no-unsafe-dictionary-type -- payload and the logger.debug() parameter
were Record<string, unknown>, an unsafe unknown escape hatch. The repo
already has a concrete owner type for this exact case: Prisma.InputJsonValue
/InputJsonObject, used the same way for outbound JSON payloads elsewhere
in apps/api/src/agent/ (agent-trigger.service.ts). No behavior change --
both are still structural JSON objects at runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
…e-anti-slop

fix(anti-slop): type VigieProcureEvent.payload with Prisma.InputJsonValue
Rapproche la fiche CRM du referentiel SIRENE cote VigieProcure
(GET /api/v1/companies/resolve, api_v2). Nullable -- la plupart des
fiches n'ont pas encore ete resolues, et les comptes non francais n'en
auront jamais. Meme patron que le champ `domain` deja en place :
CHAR(9) format SIRENE standard, contrainte unique scopee sur les
fiches actives (`archivedAt IS NULL`) pour permettre la reutilisation
d'un SIREN apres archivage.

Aucun seed/backfill : la migration ajoute la colonne vide, ne resout
pas retroactivement les fiches existantes. Cf. plan/CR
reports/2026-09/CR/CR-RESOLUTION-SIREN-CRM-20260904.md (repo
vigieprocure) pour le contexte complet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
ALTER TABLE company ADD COLUMN siren CHAR(9) + index unique partiel
sur (siren) WHERE archivedAt IS NULL. Meme forme que la migration
20260820161500_archive_scoped_uniqueness (patron domain) deja en prod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
feat(db): add siren column to Company, unique scoped on active records
Ajoute deux procedures tRPC (companies.resolveSiren, companies.setSiren)
qui appellent GET /api/v1/companies/resolve cote api_v2 avec un JWT de
service (VIGIEPROCURE_API_JWT/VIGIEPROCURE_API_URL, meme doctrine que
VIGIEPROCURE_WEBHOOK_URL/_SECRET -- absent = fonctionnalite degradee,
jamais un appel non authentifie).

Sur la fiche company : affiche le SIREN existant, ou propose de le
resoudre sur clic explicite. Un seul candidat "exact" ecrit
automatiquement (exception ciblee et reversible, validee par Franck --
jamais depuis un effet au chargement ni un backfill de masse). Sinon,
liste les candidats pour choix manuel. Conflit d'unicite (P2002 sur
Company.siren) traduit en erreur utilisateur nommant la fiche en
conflit, jamais un 500 nu.

Inclut la regeneration de apps/api/src/generated/server.ts, qui
corrige au passage une dette preexistante (excludeEmail/restoreEmail
declares dans activities.router.ts depuis le commit 044e350 mais
jamais regeneres).

A provisionner separement (hors perimetre de ce chantier) :
VIGIEPROCURE_API_URL, VIGIEPROCURE_API_JWT sur crm-api en production.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
- companySetSirenInput.siren: .length(9) laissait passer 9 caracteres
  non numeriques alors que le message annoncait "9 digits" -- .regex
  le fait respecter reellement.
- companySetSirenOutput (conflict): conflictingCompanyId/Name n'etaient
  consommes nulle part cote frontend (seul `reason` est lu), et le
  fallback `conflicting?.id ?? ""` etait un `z.string()` non-nullable
  rempli d'une chaine vide dans un cas quasi mort (le findFirst est
  scope par la meme contrainte unique partielle que le P2002 qui
  declenche ce chemin). Retire les deux champs plutot que de les
  rendre nullable pour un consommateur qui n'existe pas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
feat(companies): resolve and set SIREN via VigieProcure
Déplace le build Docker des 3 services (api/app/agent) de vigiep1 vers
GitHub Actions. Sur vigiep1, bun install (1771+ paquets) prenait
36-41min par service faute de cache incrémental (Dockerfile réinstalle
à froid volontairement, cf. incident disque plein du 29/08) et de
disque I/O lent sur /mnt/hermes-extra -- un rebuild complet des 3
services dépassait 2h. Le cache GitHub Actions (type=gha) devrait
ramener ça à quelques dizaines de secondes sur les runs suivants,
sur des runners dédiés (pas de contention RAM/IO avec dagster/n8n/
vigieproc-api qui tournent sur le même host).

vigiep1 passera de `docker compose build` à `docker compose pull` --
changement du docker-compose.yml de production (non versionné dans ce
repo) à faire séparément, avec provisionnement d'un PAT read:packages
pour l'authentification à ghcr.io (registre privé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtJccFqpSuP8tQ3Pwg1dYH
release: sync main into release
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant