agentlinkops 0.6.13 → 0.6.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +25 -2
- package/PACK-ARTIFACT.json +16 -0
- package/PACKS.json +591 -0
- package/README.md +34 -48
- package/cli/adapters/suppliers.js +1 -1
- package/cli/agent-setup.js +101 -17
- package/cli/campaign-bridge.js +325 -0
- package/cli/campaign-outcomes.js +107 -0
- package/cli/catalog.js +45702 -18674
- package/cli/check.js +40 -10
- package/cli/client.js +2 -1
- package/cli/commands.js +17 -8
- package/cli/connection.js +92 -18
- package/cli/contact-adoption.js +91 -0
- package/cli/context.js +13 -2
- package/cli/doctor.js +15 -4
- package/cli/import.js +7 -1
- package/cli/key-store.js +115 -0
- package/cli/ledger.js +1 -0
- package/cli/local-result.js +41 -6
- package/cli/main.js +51 -12
- package/cli/mcp-client.js +2 -2
- package/cli/migration.js +308 -0
- package/cli/prompt.js +25 -0
- package/cli/public-http.js +168 -0
- package/cli/state.js +14 -0
- package/cli/sync.js +1 -1
- package/package.json +7 -8
- package/references/citation-panels.md +2 -0
- package/references/cli.md +2 -2
- package/references/disavow.md +1 -1
- package/references/discovery-handoff.md +1 -1
- package/references/local-crm-draft-handoff.md +1 -1
- package/references/portable-ledger-handoff.md +11 -10
- package/references/recipes/catalog.json +263 -7
- package/references/recipes/command-notes.md +5 -3
- package/references/recipes/internal-link-improvement.md +25 -0
- package/references/recipes/placement-reconciliation.md +53 -1
- package/scripts/portable-handoff.py +17 -4
- package/shared/admission-contract.js +3 -2
- package/shared/admission-report-contract.js +3 -2
- package/shared/analytics-history-contract.js +15 -0
- package/shared/campaign-outcome-contract.js +12 -0
- package/shared/campaign-prospect-contract.js +23 -0
- package/shared/classification-contract.js +54 -0
- package/shared/contact-record-contract.js +27 -0
- package/shared/disavow-contract.js +37 -9
- package/shared/index-observation-contract.js +5 -4
- package/shared/internal-links-contract.js +143 -0
- package/shared/link-category-contract.js +35 -0
- package/shared/link-category.js +138 -0
- package/shared/link-labels.js +26 -0
- package/shared/link-summary-contract.js +17 -0
- package/shared/observation-event.js +1 -1
- package/shared/onboarding.js +20 -0
- package/shared/response-contract.js +38 -5
- package/shared/search-connection-contract.js +2 -1
- package/shared/table-page-contract.js +13 -0
- package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
- package/skills/agentlinkops-assets/SKILL.md +3 -1
- package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
- package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
- package/skills/agentlinkops-campaigns/SKILL.md +7 -5
- package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
- package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
- package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
- package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
- package/skills/agentlinkops-connect/SKILL.md +2 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +164 -62
- package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
- package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
- package/skills/agentlinkops-crm/SKILL.md +26 -9
- package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
- package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
- package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
- package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
- package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
- package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
- package/skills/agentlinkops-discovery/SKILL.md +4 -2
- package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
- package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
- package/src/competitors/contract.js +17 -1
- package/src/context/site-profile.js +34 -121
- package/src/corpus/acquire.js +68 -32
- package/src/corpus/extract.js +147 -0
- package/src/corpus/frontier.js +151 -0
- package/src/discovery/contract.js +8 -0
- package/src/discovery/import.js +13 -1
- package/src/mentions.js +193 -0
- package/src/page-acquisition/acquire.js +50 -0
- package/src/page-acquisition/bytes.js +138 -0
- package/src/page-acquisition/capture.js +11 -0
- package/src/page-acquisition/contracts.js +67 -0
- package/src/page-acquisition/documents.js +166 -0
- package/src/page-acquisition/http.js +307 -0
- package/src/page-acquisition/policy.js +20 -0
- package/src/page-acquisition/recovery.js +164 -0
- package/src/page-acquisition/render-failure.js +82 -0
- package/src/page-extraction/html.js +359 -0
- package/src/page-extraction/index.js +30 -0
- package/src/page-extraction/metadata.js +16 -0
- package/src/response-shaping.js +4 -3
- package/src/tool-views.js +22 -12
- package/src/verifier/fetch.js +4 -339
- package/src/verifier/index.js +81 -23
- package/src/verifier/page-quality.js +14 -0
- package/src/verifier/parser.js +2 -241
- package/src/verifier/readiness.js +64 -13
- package/src/verifier/render-evidence.js +25 -2
- package/src/verifier/target.js +201 -0
- package/src/verifier/transition.js +18 -3
- package/src/verifier/url.js +31 -0
- package/src/watch-crawlability.js +144 -0
- package/skills/content-defingerprinting/SKILL.md +0 -29
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
- package/skills/internal-linking-optimizer/SKILL.md +0 -56
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
- package/skills/pre-publish-review/SKILL.md +0 -179
- package/skills/source-cited-content-builder/SKILL.md +0 -56
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
- package/skills/source-cited-humanizer/SKILL.md +0 -20
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {createHash, randomUUID} from 'node:crypto';
|
|
2
|
+
import {readFile,writeFile,open,rename,unlink,lstat} from 'node:fs/promises';
|
|
3
|
+
import {resolve,dirname,basename,join} from 'node:path';
|
|
4
|
+
import {parseCsv} from './adapters/csv.js';
|
|
5
|
+
|
|
6
|
+
const VERSION='agentlinkops-campaign-bridge-v1';
|
|
7
|
+
const hash=value=>createHash('sha256').update(typeof value==='string'||Buffer.isBuffer(value)?value:JSON.stringify(value)).digest('hex');
|
|
8
|
+
const copy=value=>structuredClone(value);
|
|
9
|
+
const fail=code=>{throw Object.assign(new Error(code),{code});};
|
|
10
|
+
const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
11
|
+
const fields=['sourceUrl','publisherUrl','targetUrl','targetScope','campaign','context','expectedAnchor'];
|
|
12
|
+
const effects=()=>({sourceWrites:0,checks:0,credits:0,sends:0,enrollments:0});
|
|
13
|
+
const opaque=v=>typeof v==='string'&&v.length>0&&v.length<=200&&!/[\u0000-\u001f\u007f@]|https?:\/\//i.test(v);
|
|
14
|
+
const publicUrl=v=>{try{const u=new URL(v);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password&&!!u.hostname;}catch{return false;}};
|
|
15
|
+
const fromResponse=result=>result?.prospect??result;
|
|
16
|
+
const watchId=value=>value?.watchId??value?.id;
|
|
17
|
+
const url=value=>{if(value==null)return null;const u=new URL(value);u.hash='';return u.href;};
|
|
18
|
+
const prospectFields=value=>({sourceUrl:url(value.sourceUrl),publisherUrl:value.publisherUrl?new URL(value.publisherUrl).origin+'/':value.sourceUrl?new URL(value.sourceUrl).origin+'/':null,targetUrl:url(value.targetUrl),targetScope:value.targetScope??'exact',campaign:value.campaign??null,context:value.context??null,expectedAnchor:value.expectedAnchor??null});
|
|
19
|
+
const difference=(before,after)=>Object.fromEntries(fields.filter(key=>!equal(before[key],after[key])).map(key=>[key,after[key]]));
|
|
20
|
+
const seal=body=>({...body,sha256:hash(body)});
|
|
21
|
+
function unseal(value,kind){const {sha256,...body}=value??{};if(body.schemaVersion!==VERSION||body.kind!==kind||sha256!==hash(body))fail('CAMPAIGN_PLAN_CHANGED');return body;}
|
|
22
|
+
function assertProject(row,projectId){if(row&&row.projectId!==projectId)fail('CAMPAIGN_PROJECT_MISMATCH');}
|
|
23
|
+
function assertProspectState(row){
|
|
24
|
+
if(!opaque(watchId(row))||!opaque(row?.revision)||typeof row.archived!=='boolean'||typeof row.monitoring?.enabled!=='boolean'||!row.evidence||!(row.evidence.checkedAt===null||typeof row.evidence.checkedAt==='string'))fail('INVALID_CAMPAIGN_RESPONSE');
|
|
25
|
+
}
|
|
26
|
+
function validateDesired(row){
|
|
27
|
+
if(!row||!opaque(row.localReference)||!row.prospect||typeof row.prospect!=='object')return 'invalid_source_record';
|
|
28
|
+
const p=row.prospect;
|
|
29
|
+
if(Object.keys(p).some(k=>!fields.includes(k))||!publicUrl(p.targetUrl)||p.sourceUrl!=null&&!publicUrl(p.sourceUrl)||p.sourceUrl==null&&!publicUrl(p.publisherUrl))return 'invalid_prospect';
|
|
30
|
+
if(p.targetScope!=null&&!['exact','domain','subdomain','path'].includes(p.targetScope)||p.campaign!=null&&(typeof p.campaign!=='string'||p.campaign.length>80)||p.context!=null&&(typeof p.context!=='string'||p.context.length>1000)||p.expectedAnchor!=null&&(typeof p.expectedAnchor!=='string'||p.expectedAnchor.length>500))return 'invalid_prospect';
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Frozen local projection up; source-only research, sender and gate fields stay local. */
|
|
35
|
+
export function planCampaignPush({projectId,rows,prospects=[],sourceSha256=null,fieldMapSha256=null}){
|
|
36
|
+
if(!opaque(projectId)||!Array.isArray(rows)||rows.length>10000||!Array.isArray(prospects))fail('INVALID_CAMPAIGN_INPUT');
|
|
37
|
+
const hostedByRef=new Map();for(const p of prospects)hostedByRef.set(p.externalRef,[...(hostedByRef.get(p.externalRef)??[]),p]);
|
|
38
|
+
const results=rows.map((row,index)=>{
|
|
39
|
+
let reason=validateDesired(row);const matches=hostedByRef.get(row?.localReference)??[];
|
|
40
|
+
if(matches.length>1)reason='ambiguous_hosted_reference';
|
|
41
|
+
if(matches[0]?.projectId!==undefined&&matches[0].projectId!==projectId)reason='project_mismatch';
|
|
42
|
+
return{row:index+1,localReference:row?.localReference??null,status:reason?'held':'ready',reason,desired:reason?null:prospectFields(row.prospect),before:matches[0]?copy(matches[0]):null};
|
|
43
|
+
});
|
|
44
|
+
const groups=new Map();for(const row of results)groups.set(row.localReference,[...(groups.get(row.localReference)??[]),row]);
|
|
45
|
+
for(const group of groups.values()){
|
|
46
|
+
const row=group[0];
|
|
47
|
+
if(group.length>1&&!group.every(r=>equal(r.desired,row.desired)))for(const member of group){member.status='held';member.reason='conflicting_source_reference';}
|
|
48
|
+
}
|
|
49
|
+
return seal({schemaVersion:VERSION,kind:'push',projectId,sourceSha256,fieldMapSha256,sourceProjectionSha256:hash(rows),results,effects:effects()});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function findProspect(call,projectId,externalRef){
|
|
53
|
+
const page=await call('list_prospects',{projectId,externalRef,view:'all',limit:2});
|
|
54
|
+
if(!Array.isArray(page?.items)||page.nextAfter||page.items.length>1)fail('CAMPAIGN_IDENTITY_CONFLICT');
|
|
55
|
+
const record=page.items[0]??null;assertProject(record,projectId);
|
|
56
|
+
if(record&&record.externalRef!==externalRef)fail('CAMPAIGN_IDENTITY_CONFLICT');
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Effects are never automatically replayed after an uncertain response. Reconcile first. */
|
|
61
|
+
export async function applyCampaignPush(plan,call,{sourceSha256=plan?.sourceSha256}={}){
|
|
62
|
+
unseal(plan,'push');if(plan.sourceSha256!==sourceSha256)fail('CAMPAIGN_SOURCE_CHANGED');
|
|
63
|
+
const results=[],actual={...effects(),prospectsCreated:0,prospectsUpdated:0,rowsRequiringReadback:0};
|
|
64
|
+
for(const row of plan.results){
|
|
65
|
+
if(row.status!=='ready'){results.push({row:row.row,status:'held',reason:row.reason});continue;}
|
|
66
|
+
let attempted=false,current=null,observedWatchId=null;
|
|
67
|
+
try{
|
|
68
|
+
current=await findProspect(call,plan.projectId,row.localReference);
|
|
69
|
+
if(current){assertProspectState(current);if(current.archived)fail('CAMPAIGN_ARCHIVED_REQUIRES_EXPLICIT_RESTORE');}
|
|
70
|
+
// Desired content may replay safely; a changed or removed preview never wins a stale race.
|
|
71
|
+
if(current&&equal(prospectFields(current),row.desired)&&!current.archived){results.push({row:row.row,status:'unchanged',watchId:watchId(current),prospect:copy(current)});continue;}
|
|
72
|
+
if(row.before&&(!current||current.revision!==row.before.revision||!equal(prospectFields(current),prospectFields(row.before))))fail('CAMPAIGN_CHANGED_SINCE_PREVIEW');
|
|
73
|
+
if(!row.before&¤t)fail('CAMPAIGN_APPEARED_SINCE_PREVIEW');
|
|
74
|
+
attempted=true;
|
|
75
|
+
const response=current?await call('update_prospect',{projectId:plan.projectId,watchId:watchId(current),revision:current.revision,...difference(prospectFields(current),row.desired)}):await call('save_prospect',{projectId:plan.projectId,externalRef:row.localReference,revision:0,...row.desired});
|
|
76
|
+
const result=response?.prospect;
|
|
77
|
+
if(typeof response?.replayed!=='boolean')fail('INVALID_CAMPAIGN_RESPONSE');
|
|
78
|
+
assertProject(result,plan.projectId);assertProspectState(result);
|
|
79
|
+
if(result.externalRef!==row.localReference||current&&watchId(result)!==watchId(current)||!equal(prospectFields(result),row.desired))fail('INVALID_CAMPAIGN_RESPONSE');
|
|
80
|
+
observedWatchId=watchId(result);
|
|
81
|
+
if(result.archived)fail('CAMPAIGN_ARCHIVED_REQUIRES_EXPLICIT_RESTORE');
|
|
82
|
+
// A stable-reference replay may return an existing record. It creates no row
|
|
83
|
+
// and grants no right to archive somebody else's original record on rollback.
|
|
84
|
+
if(response.replayed){results.push({row:row.row,status:'unchanged',replayed:true,watchId:watchId(result),prospect:copy(result)});continue;}
|
|
85
|
+
if(!current&&(result.monitoring.enabled||result.monitoring.enrolled===true||result.evidence.checkedAt))fail('UNEXPECTED_CAMPAIGN_EFFECT');
|
|
86
|
+
actual[current?'prospectsUpdated':'prospectsCreated']++;
|
|
87
|
+
results.push({row:row.row,status:'applied',watchId:watchId(result),prospect:copy(result),rollback:{before:current?copy(current):null,after:copy(result)}});
|
|
88
|
+
}catch(error){if(attempted)actual.rowsRequiringReadback++;results.push({row:row.row,status:'held',reason:error.code??error.message,effectState:attempted?'requires_readback':'none',localReference:row.localReference,...(current||observedWatchId?{watchId:current?watchId(current):observedWatchId}:{})});}
|
|
89
|
+
}
|
|
90
|
+
return seal({schemaVersion:VERSION,kind:'push-receipt',projectId:plan.projectId,planSha256:plan.sha256,sourceSha256:plan.sourceSha256,results,effects:actual});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function reconcileCampaignPush(plan,call){
|
|
94
|
+
unseal(plan,'push');const results=[];
|
|
95
|
+
for(const row of plan.results){
|
|
96
|
+
if(!row.desired){results.push({row:row.row,status:'held',reason:row.reason});continue;}
|
|
97
|
+
try{const current=await findProspect(call,plan.projectId,row.localReference);results.push({row:row.row,status:current&&equal(prospectFields(current),row.desired)&&!current.archived?'matched':current?'changed':'absent',...(current?{watchId:watchId(current),revision:current.revision}:{})});}
|
|
98
|
+
catch(error){results.push({row:row.row,status:'held',reason:error.code??error.message});}
|
|
99
|
+
}
|
|
100
|
+
return{planSha256:plan.sha256,results,effects:effects()};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** New unscheduled rows are soft-removed; existing records restore only owned projection fields. */
|
|
104
|
+
export async function rollbackCampaignPush(receipt,call){
|
|
105
|
+
unseal(receipt,'push-receipt');const results=[],actual={...effects(),prospectsRestored:0,prospectsArchived:0,rowsRequiringReadback:0};
|
|
106
|
+
for(const row of [...receipt.results].reverse()){
|
|
107
|
+
if(!row.rollback){results.push({row:row.row,status:row.effectState==='requires_readback'?'held':'no_effect',...(row.effectState==='requires_readback'?{reason:'ORIGINAL_EFFECT_REQUIRES_READBACK'}:{})});continue;}
|
|
108
|
+
const {before,after}=row.rollback;let attempted=false;
|
|
109
|
+
try{
|
|
110
|
+
const current=fromResponse(await call('get_prospect',{projectId:receipt.projectId,watchId:watchId(after)}));assertProject(current,receipt.projectId);
|
|
111
|
+
if(!before&¤t.archived){results.push({row:row.row,status:'already_restored'});continue;}
|
|
112
|
+
if(before&&equal(prospectFields(current),prospectFields(before))){results.push({row:row.row,status:'already_restored'});continue;}
|
|
113
|
+
if(current.revision!==after.revision||!equal(prospectFields(current),prospectFields(after)))fail('ROLLBACK_CONCURRENT_CHANGE');
|
|
114
|
+
if(!before&&(current.monitoring?.enabled||current.evidence?.checkedAt||current.contact?.id))fail('ROLLBACK_RECORD_IN_USE');
|
|
115
|
+
attempted=true;await call('update_prospect',{projectId:receipt.projectId,watchId:watchId(after),revision:current.revision,...(before?difference(prospectFields(after),prospectFields(before)):{archived:true})});
|
|
116
|
+
actual[before?'prospectsRestored':'prospectsArchived']++;results.push({row:row.row,status:'restored',watchId:watchId(after),compensation:before?'projection_restored':'unused_prospect_archived'});
|
|
117
|
+
}catch(error){if(attempted)actual.rowsRequiringReadback++;results.push({row:row.row,status:'held',reason:error.code??error.message,effectState:attempted?'requires_readback':'none'});}
|
|
118
|
+
}
|
|
119
|
+
return{planSha256:receipt.planSha256,results,effects:actual};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Only independent verified presence / confirmed loss can change a local status. */
|
|
123
|
+
export function planCampaignPull({projectId,rows,prospects,statusField='status',referenceField='localReference',referencePrefix='',projectField=null,projectValue=null,statusMap={won:'live',lost:'lost'},sourceSha256=null,rowsPath=null,fieldMapSha256=null,selection=null}){
|
|
124
|
+
if(!opaque(projectId)||!Array.isArray(rows)||rows.length>50000||!Array.isArray(prospects)||!['status','linkStatus','link_status'].includes(statusField)||!safePath(referenceField)||typeof referencePrefix!=='string'||referencePrefix.length>100||projectField!==null&&!safePath(projectField)||!Object.hasOwn(statusMap,'won')||!Object.hasOwn(statusMap,'lost'))fail('INVALID_CAMPAIGN_PULL');
|
|
125
|
+
const hostedByRef=new Map(),sourceCounts=new Map();for(const p of prospects)hostedByRef.set(p.externalRef,[...(hostedByRef.get(p.externalRef)??[]),p]);
|
|
126
|
+
for(const row of rows)if(projectField===null||field(row,projectField)===projectValue){const ref=referencePrefix+String(field(row,referenceField)??'');sourceCounts.set(ref,(sourceCounts.get(ref)??0)+1);}
|
|
127
|
+
const results=rows.map((row,index)=>{
|
|
128
|
+
if(projectField!==null&&field(row,projectField)!==projectValue)return{row:index+1,status:'unchanged',reason:'unselected_project'};
|
|
129
|
+
const sourceReference=field(row,referenceField);
|
|
130
|
+
if(selection?.references&&!selection.references.includes(sourceReference)||selection?.field&&!selection.values.includes(field(row,selection.field)))return{row:index+1,status:'unchanged',reason:'unselected_record'};
|
|
131
|
+
if(!opaque(sourceReference))return{row:index+1,status:'held',reason:'missing_stable_reference'};
|
|
132
|
+
const ref=referencePrefix+sourceReference,matched=hostedByRef.get(ref)??[];
|
|
133
|
+
if(!opaque(ref)||matched.length!==1||sourceCounts.get(ref)!==1||matched[0]?.projectId!==projectId)return{row:index+1,localReference:ref??null,status:'held',reason:'ambiguous_or_missing_reference'};
|
|
134
|
+
const p=matched[0],e=p.evidence??{},observed=e.confirmedLost===true&&e.wasEverPresent===true&&e.checkedAt?'lost':(e.verifiedWon===true||e.wasEverPresent===true)&&e.complete===true&&['present','changed'].includes(e.state)&&e.checkedAt?'won':null;
|
|
135
|
+
if(!observed)return{row:index+1,localReference:ref,status:'unchanged',reason:'no_independent_outcome'};
|
|
136
|
+
if(typeof statusMap[observed]!=='string'||!statusMap[observed].trim()||statusMap[observed].length>100||/[\u0000-\u001f\u007f]/.test(statusMap[observed])||/^[=+@-]/.test(statusMap[observed]))fail('INVALID_CAMPAIGN_STATUS_MAP');
|
|
137
|
+
return{row:index+1,localReference:ref,status:row[statusField]===statusMap[observed]?'unchanged':'ready',field:statusField,before:row[statusField]??null,after:statusMap[observed],evidence:{watchId:watchId(p),checkedAt:e.checkedAt,observed}};
|
|
138
|
+
});
|
|
139
|
+
return seal({schemaVersion:VERSION,kind:'pull',projectId,referenceField,referencePrefix,projectField,projectValue,statusField,rowsPath,sourceSha256,fieldMapSha256,selection,sourceProjectionSha256:hash(rows),results,effects:effects()});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function applyCampaignPull(plan,rows,{sourceSha256=plan?.sourceSha256}={}){
|
|
143
|
+
unseal(plan,'pull');if(hash(rows)!==plan.sourceProjectionSha256||sourceSha256!==plan.sourceSha256)fail('CAMPAIGN_SOURCE_CHANGED');
|
|
144
|
+
const next=copy(rows);for(const item of plan.results)if(item.status==='ready')next[item.row-1][item.field]=item.after;
|
|
145
|
+
return{rows:next,planSha256:plan.sha256,changed:plan.results.filter(r=>r.status==='ready').length,effects:effects()};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const safeField=value=>typeof value==='string'&&/^[A-Za-z_][A-Za-z0-9_ -]{0,99}$/.test(value)&&!['__proto__','prototype','constructor'].includes(value);
|
|
149
|
+
const safePath=value=>typeof value==='string'&&value.split('.').every(safeField)&&value.split('.').length<=5;
|
|
150
|
+
const field=(row,path)=>path?.split('.').reduce((value,key)=>value&&Object.hasOwn(value,key)?value[key]:undefined,row);
|
|
151
|
+
|
|
152
|
+
/** Versioned explicit mapping; publisher/name/email never supply a record identity. */
|
|
153
|
+
export function projectCampaignSource(rows,mapping){
|
|
154
|
+
if(!mapping||mapping.schemaVersion!=='agentlinkops-local-campaign-map-v1'||!['global-placements','link-registry'].includes(mapping.kind)||!safePath(mapping.referenceField)||/(^|[._])(?:email|mail|domain|name|url|phone)(?:$|_)/i.test(mapping.referenceField)||!safePath(mapping.projectField)||typeof mapping.projectValue!=='string'||!mapping.projectValue||typeof(mapping.referencePrefix??'')!=='string'||(mapping.referencePrefix??'').length>100||!publicUrl(mapping.targetUrl)&&!safePath(mapping.targetUrlField))fail('INVALID_CAMPAIGN_FIELD_MAP');
|
|
155
|
+
for(const name of ['sourceUrlField','sourceUrlFallbackField','publisherUrlField','campaignField','contextField','targetUrlField'])if(mapping[name]!=null&&!safePath(mapping[name]))fail('INVALID_CAMPAIGN_FIELD_MAP');
|
|
156
|
+
if(mapping.kind==='global-placements'&&mapping.rowsPath!=='placements'||mapping.kind==='link-registry'&&mapping.rowsPath!=null)fail('INVALID_CAMPAIGN_FIELD_MAP');
|
|
157
|
+
const selection=mapping.selection;
|
|
158
|
+
if(mapping.kind==='global-placements'&&!selection)fail('CAMPAIGN_EXPLICIT_BACKLINK_SELECTION_REQUIRED');
|
|
159
|
+
if(selection&&!(Array.isArray(selection.references)&&selection.references.length>0&&selection.references.length<=10000&&selection.references.every(opaque)||safePath(selection.field)&&Array.isArray(selection.values)&&selection.values.length>0&&selection.values.length<=40&&selection.values.every(v=>typeof v==='string'||typeof v==='boolean')))fail('INVALID_CAMPAIGN_SELECTION');
|
|
160
|
+
const results=[],sourceIndices=[],foundReferences=new Set();
|
|
161
|
+
for(const [index,row]of rows.entries()){
|
|
162
|
+
if(field(row,mapping.projectField)!==mapping.projectValue)continue;
|
|
163
|
+
const rawReference=field(row,mapping.referenceField);
|
|
164
|
+
if(selection?.references&&!selection.references.includes(rawReference)||selection?.field&&!selection.values.includes(field(row,selection.field)))continue;
|
|
165
|
+
if(opaque(rawReference))foundReferences.add(rawReference);
|
|
166
|
+
const localReference=opaque(rawReference)?(mapping.referencePrefix??'')+rawReference:null;
|
|
167
|
+
const sourceUrl=field(row,mapping.sourceUrlField)||field(row,mapping.sourceUrlFallbackField)||null;
|
|
168
|
+
let publisherUrl=field(row,mapping.publisherUrlField)||null;
|
|
169
|
+
if(publisherUrl&&!/^https?:\/\//i.test(publisherUrl))publisherUrl='https://'+publisherUrl;
|
|
170
|
+
const prospect={sourceUrl,publisherUrl,targetUrl:mapping.targetUrl??field(row,mapping.targetUrlField),targetScope:mapping.targetScope??'exact',campaign:field(row,mapping.campaignField)||null,context:field(row,mapping.contextField)||null,expectedAnchor:null};
|
|
171
|
+
results.push({localReference,prospect});sourceIndices.push(index);
|
|
172
|
+
}
|
|
173
|
+
if(selection?.references&&selection.references.some(ref=>!foundReferences.has(ref)))fail('CAMPAIGN_SELECTION_REFERENCE_NOT_FOUND');
|
|
174
|
+
if(!results.length)fail('CAMPAIGN_SOURCE_SELECTION_EMPTY');
|
|
175
|
+
return{rows:results,sourceIndices,fieldMapSha256:hash(mapping)};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function csvSpans(text){
|
|
179
|
+
const rows=[];let start=0,row=[],quoted=false;
|
|
180
|
+
for(let i=0;i<=text.length;i++){
|
|
181
|
+
const c=text[i];
|
|
182
|
+
if(c==='"'){if(quoted&&text[i+1]==='"'){i++;continue;}quoted=!quoted;continue;}
|
|
183
|
+
if(!quoted&&(c===','||c==='\n'||c===undefined)){
|
|
184
|
+
const end=c==='\n'&&text[i-1]==='\r'?i-1:i;row.push({start,end});
|
|
185
|
+
if(c!==','){if(row.some(cell=>text.slice(cell.start,cell.end).length))rows.push(row);row=[];}
|
|
186
|
+
start=i+1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if(quoted)fail('INVALID_CAMPAIGN_CSV');return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function readCampaignDocument(text,{format='json',rowsPath=null}={}){
|
|
193
|
+
if(typeof text!=='string'||Buffer.byteLength(text)>64*1024*1024)fail('CAMPAIGN_SOURCE_TOO_LARGE');
|
|
194
|
+
let rows,document,spans,header,lineSpans;
|
|
195
|
+
const clean=text.replace(/^\uFEFF/,'');
|
|
196
|
+
if(format==='csv'){
|
|
197
|
+
if(rowsPath!==null)fail('CAMPAIGN_SOURCE_FORMAT_UNSUPPORTED');
|
|
198
|
+
const all=parseCsv(clean);if(!all.length)fail('INVALID_CAMPAIGN_CSV');header=all[0].map(v=>v.trim());
|
|
199
|
+
if(new Set(header).size!==header.length||header.some(h=>!h||h.length>2000||/[\u0000-\u001f\u007f]/.test(h)))fail('INVALID_CAMPAIGN_CSV_HEADER');
|
|
200
|
+
if(all.slice(1).some(cells=>cells.length!==header.length))fail('INVALID_CAMPAIGN_CSV_ROW');
|
|
201
|
+
rows=all.slice(1).map(cells=>Object.fromEntries(header.map((name,i)=>[name,cells[i]])));spans=csvSpans(text);
|
|
202
|
+
if(spans.length!==all.length)fail('INVALID_CAMPAIGN_CSV');
|
|
203
|
+
}else if(format==='jsonl'){
|
|
204
|
+
if(rowsPath!==null)fail('CAMPAIGN_SOURCE_FORMAT_UNSUPPORTED');
|
|
205
|
+
lineSpans=[];rows=[];let start=0;
|
|
206
|
+
for(const line of text.split(/(?<=\n)/)){if(line.trim()){rows.push(JSON.parse(line.replace(/^\uFEFF/,'')));lineSpans.push({start,end:start+line.length});}start+=line.length;}
|
|
207
|
+
}else if(format==='json'){
|
|
208
|
+
document=JSON.parse(clean);rows=rowsPath?field(document,rowsPath):document;
|
|
209
|
+
if(rowsPath&&!safePath(rowsPath))fail('INVALID_CAMPAIGN_ROWS_PATH');
|
|
210
|
+
}else fail('CAMPAIGN_SOURCE_FORMAT_UNSUPPORTED');
|
|
211
|
+
if(!Array.isArray(rows)||rows.length>50000||rows.some(row=>!row||typeof row!=='object'||Array.isArray(row)))fail('INVALID_CAMPAIGN_SOURCE');
|
|
212
|
+
return{format,rowsPath,rows,document,spans,header,lineSpans,text};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function parseCampaignSource(text,options={}){return readCampaignDocument(text,options).rows;}
|
|
216
|
+
|
|
217
|
+
function renderCampaignDocument(loaded,next,plan){
|
|
218
|
+
const newline=loaded.text.includes('\r\n')?'\r\n':'\n',ends=/\r?\n$/.test(loaded.text),bom=loaded.text.startsWith('\uFEFF')?'\uFEFF':'';
|
|
219
|
+
if(loaded.format==='csv'){
|
|
220
|
+
const column=loaded.header.indexOf(plan.statusField);if(column<0)fail('CAMPAIGN_STATUS_COLUMN_NOT_FOUND');
|
|
221
|
+
const replacements=plan.results.filter(r=>r.status==='ready').map(r=>{const span=loaded.spans[r.row][column],value=String(next[r.row-1][plan.statusField]),wasQuoted=loaded.text[span.start]==='"';return{...span,value:wasQuoted||/[",\r\n]/.test(value)?'"'+value.replaceAll('"','""')+'"':value};});
|
|
222
|
+
let text=loaded.text;for(const r of replacements.sort((a,b)=>b.start-a.start))text=text.slice(0,r.start)+r.value+text.slice(r.end);return text;
|
|
223
|
+
}
|
|
224
|
+
if(loaded.format==='jsonl'){
|
|
225
|
+
let text=loaded.text;for(const r of plan.results.filter(r=>r.status==='ready').sort((a,b)=>b.row-a.row)){const span=loaded.lineSpans[r.row-1],old=text.slice(span.start,span.end),suffix=old.endsWith('\r\n')?'\r\n':old.endsWith('\n')?'\n':'';text=text.slice(0,span.start)+(span.start===0&&old.startsWith('\uFEFF')?'\uFEFF':'')+JSON.stringify(next[r.row-1])+suffix+text.slice(span.end);}return text;
|
|
226
|
+
}
|
|
227
|
+
const indentation=loaded.text.match(/\r?\n([ \t]+)"/)?.[1]??(loaded.text.includes('\n')?2:0),doc=loaded.rowsPath?copy(loaded.document):next;
|
|
228
|
+
if(loaded.rowsPath){const parts=loaded.rowsPath.split('.'),last=parts.pop();let target=doc;for(const part of parts)target=target[part];target[last]=next;}
|
|
229
|
+
return bom+JSON.stringify(doc,null,indentation).replaceAll('\n',newline)+(ends?newline:'');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Explicit opt-in local patch with a private immutable backup, exclusive lock and byte CAS. */
|
|
233
|
+
export async function applyCampaignPullFile(plan,path,{format='json'}={}){
|
|
234
|
+
const target=resolve(path),lock=target+'.agentlinkops-campaign.lock';let handle,temp;
|
|
235
|
+
try{
|
|
236
|
+
handle=await open(lock,'wx',0o600);
|
|
237
|
+
const info=await lstat(target);if(!info.isFile()||info.isSymbolicLink()||info.size>64*1024*1024)fail('CAMPAIGN_SOURCE_NOT_REGULAR');
|
|
238
|
+
const original=await readFile(target);if(hash(original)!==plan.sourceSha256)fail('CAMPAIGN_SOURCE_CHANGED');
|
|
239
|
+
const text=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(original),loaded=readCampaignDocument(text,{format,rowsPath:plan.rowsPath??null});
|
|
240
|
+
const patched=applyCampaignPull(plan,loaded.rows,{sourceSha256:hash(original)});
|
|
241
|
+
if(!patched.changed)return{...patched,sourceWrites:0,backup:null};
|
|
242
|
+
const next=renderCampaignDocument(loaded,patched.rows,plan);
|
|
243
|
+
const backup=target+'.agentlinkops-backup-'+plan.sha256.slice(0,16);await writeFile(backup,original,{flag:'wx',mode:0o600});
|
|
244
|
+
temp=join(dirname(target),'.'+basename(target)+'.campaign-'+randomUUID());await writeFile(temp,next,{flag:'wx',mode:info.mode&0o777});
|
|
245
|
+
const currentInfo=await lstat(target);if(currentInfo.ino!==info.ino||hash(await readFile(target))!==plan.sourceSha256)fail('CAMPAIGN_SOURCE_CHANGED');
|
|
246
|
+
await rename(temp,target);temp=null;
|
|
247
|
+
return{planSha256:plan.sha256,changed:patched.changed,sourceWrites:1,backup,beforeSha256:hash(original),afterSha256:hash(next),effects:{...effects(),sourceWrites:1}};
|
|
248
|
+
}finally{if(temp)await unlink(temp).catch(()=>{});if(handle){await handle.close();await unlink(lock).catch(()=>{});}}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Restore only this exact local patch; a later writer keeps the file and gets a hold. */
|
|
252
|
+
export async function rollbackCampaignPullFile(receipt,path){
|
|
253
|
+
const target=resolve(path),lock=target+'.agentlinkops-campaign.lock';let handle,temp;
|
|
254
|
+
try{
|
|
255
|
+
handle=await open(lock,'wx',0o600);
|
|
256
|
+
if(!receipt||!receipt.backup||!/^[a-f0-9]{64}$/.test(receipt.beforeSha256??'')||!/^[a-f0-9]{64}$/.test(receipt.afterSha256??''))fail('INVALID_CAMPAIGN_PULL_RECEIPT');
|
|
257
|
+
const info=await lstat(target),backupInfo=await lstat(receipt.backup);if(!info.isFile()||info.isSymbolicLink()||!backupInfo.isFile()||backupInfo.isSymbolicLink()||info.size>64*1024*1024||backupInfo.size>64*1024*1024)fail('CAMPAIGN_SOURCE_NOT_REGULAR');
|
|
258
|
+
const current=await readFile(target);if(hash(current)===receipt.beforeSha256)return{planSha256:receipt.planSha256,sourceWrites:0,alreadyRestored:true};
|
|
259
|
+
if(hash(current)!==receipt.afterSha256)fail('ROLLBACK_CONCURRENT_CHANGE');
|
|
260
|
+
const before=await readFile(receipt.backup);if(hash(before)!==receipt.beforeSha256)fail('CAMPAIGN_BACKUP_CHANGED');
|
|
261
|
+
temp=join(dirname(target),'.'+basename(target)+'.campaign-'+randomUUID());await writeFile(temp,before,{flag:'wx',mode:info.mode&0o777});
|
|
262
|
+
const latest=await lstat(target);if(latest.ino!==info.ino||hash(await readFile(target))!==receipt.afterSha256)fail('ROLLBACK_CONCURRENT_CHANGE');
|
|
263
|
+
await rename(temp,target);temp=null;return{planSha256:receipt.planSha256,sourceWrites:1,alreadyRestored:false,restoredSha256:hash(before)};
|
|
264
|
+
}finally{if(temp)await unlink(temp).catch(()=>{});if(handle){await handle.close();await unlink(lock).catch(()=>{});}}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const CAMPAIGN_CLI_HELP=`agentlinkops campaign plan --source FILE --hosted FILE --project-id ID --out PLAN.json
|
|
268
|
+
agentlinkops campaign apply --plan PLAN.json --source FILE --out RECEIPT.json
|
|
269
|
+
agentlinkops campaign reconcile --plan PLAN.json --out READBACK.json
|
|
270
|
+
agentlinkops campaign rollback --receipt RECEIPT.json --out ROLLBACK.json
|
|
271
|
+
agentlinkops campaign pull-plan --source FILE --hosted FILE --project-id ID --out PLAN.json
|
|
272
|
+
agentlinkops campaign pull-apply --plan PLAN.json --source FILE --out RECEIPT.json
|
|
273
|
+
agentlinkops campaign pull-rollback --receipt RECEIPT.json --source FILE --out ROLLBACK.json
|
|
274
|
+
agentlinkops campaign outcomes-plan --source FILE --bindings FILE --mapping FILE --provider hunter|lemlist|instantly|manual|agent --account-ref OPAQUE --project-id ID --out PLAN.json
|
|
275
|
+
agentlinkops campaign outcomes-apply --plan PLAN.json --source FILE --out RECEIPT.json
|
|
276
|
+
Use --format json|jsonl|csv. --field-map FILE selects a versioned source/project/field mapping for a registry or global placements container. Plans are read-only.
|
|
277
|
+
Apply saves unscheduled prospects and supplied outcomes; it never sends, enrolls monitoring or checks links.
|
|
278
|
+
Pull-apply explicitly patches a selected local JSON/JSONL file with a private backup. Only independently verified wins or confirmed losses change local status.
|
|
279
|
+
Files may contain private contact details: plans and receipts are written mode0600, never to stdout.`;
|
|
280
|
+
|
|
281
|
+
export async function campaignMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch,call=null}={}){
|
|
282
|
+
const {loadConfig,ConfigError}=await import('./config.js');
|
|
283
|
+
const usage=()=>{throw new ConfigError(CAMPAIGN_CLI_HELP);};
|
|
284
|
+
const action=args._?.[1],allowed={plan:['source','hosted','project-id','format','field-map','out'],apply:['plan','source','out'],reconcile:['plan','out'],rollback:['receipt','out'],'pull-plan':['source','hosted','project-id','format','field-map','out'],'pull-apply':['plan','source','format','out'],'pull-rollback':['receipt','source','out'],'outcomes-plan':['source','bindings','mapping','provider','account-ref','project-id','format','out'],'outcomes-apply':['plan','source','out']}[action];
|
|
285
|
+
if(!allowed||args._.length!==2||args.tag?.length||Object.keys(args).some(k=>!['_','tag',...allowed].includes(k))||typeof args.out!=='string')usage();
|
|
286
|
+
const file=async name=>{
|
|
287
|
+
if(typeof args[name]!=='string')usage();const path=resolve(cwd,args[name]),s=await lstat(path);
|
|
288
|
+
if(!s.isFile()||s.isSymbolicLink()||s.size>64*1024*1024)throw new ConfigError('Campaign input must be a regular file of at most64MiB');
|
|
289
|
+
return new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(await readFile(path));
|
|
290
|
+
};
|
|
291
|
+
const json=async name=>JSON.parse((await file(name)).replace(/^\uFEFF/,''));
|
|
292
|
+
const outputPath=resolve(cwd,args.out);
|
|
293
|
+
// An output path must not overwrite input evidence or the selected source.
|
|
294
|
+
if(['source','hosted','plan','receipt','bindings','mapping','field-map'].some(k=>typeof args[k]==='string'&&resolve(cwd,args[k])===outputPath))throw new ConfigError('Campaign output must differ from every input');
|
|
295
|
+
const command=async()=>{
|
|
296
|
+
if(call)return call;
|
|
297
|
+
const config=await loadConfig({cwd});const {cloudConnection}=await import('./connection.js');const {createClient}=await import('./client.js');
|
|
298
|
+
const client=createClient({...cloudConnection(config,env),fetchImpl});return(name,input)=>client.callCommand(name,input);
|
|
299
|
+
};
|
|
300
|
+
let result;
|
|
301
|
+
if(action==='plan'||action==='pull-plan'){
|
|
302
|
+
if(typeof args['project-id']!=='string')usage();
|
|
303
|
+
const source=await file('source'),mapping=args['field-map']?await json('field-map'):null,rows=parseCampaignSource(source,{format:args.format??'json',rowsPath:mapping?.rowsPath??null}),hosted=await json('hosted');
|
|
304
|
+
const projection=mapping?projectCampaignSource(rows,mapping):{rows};
|
|
305
|
+
const input={projectId:args['project-id'],rows:action==='plan'?projection.rows:rows,prospects:Array.isArray(hosted)?hosted:hosted.items,sourceSha256:hash(source),fieldMapSha256:projection.fieldMapSha256??null};
|
|
306
|
+
if(!Array.isArray(input.prospects)||hosted.nextAfter)throw new ConfigError('Hosted snapshot must contain the complete selected bounded record set');
|
|
307
|
+
if(action==='plan')result=planCampaignPush(input);
|
|
308
|
+
else{if(mapping&&(!mapping.pull||!safeField(mapping.pull.statusField)||!mapping.pull.statusMap))fail('CAMPAIGN_PULL_FIELD_AUTHORITY_REQUIRED');result=planCampaignPull({...input,...(mapping?{referenceField:mapping.referenceField,referencePrefix:mapping.referencePrefix??'',projectField:mapping.projectField,projectValue:mapping.projectValue,statusField:mapping.pull.statusField,statusMap:mapping.pull.statusMap,rowsPath:mapping.rowsPath??null,selection:mapping.selection??null}:{})});}
|
|
309
|
+
}else if(action==='apply')result=await applyCampaignPush(await json('plan'),await command(),{sourceSha256:hash(await file('source'))});
|
|
310
|
+
else if(action==='reconcile')result=await reconcileCampaignPush(await json('plan'),await command());
|
|
311
|
+
else if(action==='rollback')result=await rollbackCampaignPush(await json('receipt'),await command());
|
|
312
|
+
else if(action==='pull-apply'){if(typeof args.source!=='string')usage();result=await applyCampaignPullFile(await json('plan'),resolve(cwd,args.source),{format:args.format??'json'});}
|
|
313
|
+
else if(action==='pull-rollback'){if(typeof args.source!=='string')usage();result=await rollbackCampaignPullFile(await json('receipt'),resolve(cwd,args.source));}
|
|
314
|
+
else{
|
|
315
|
+
const {planOutcomeImport,planOutcomeCsvImport,applyOutcomeImport}=await import('./campaign-outcomes.js');
|
|
316
|
+
if(action==='outcomes-plan'){
|
|
317
|
+
const source=await file('source'),mapping=await json('mapping'),bindings=await json('bindings');
|
|
318
|
+
const common={projectId:args['project-id'],provider:args.provider,accountRef:args['account-ref'],bindings,...mapping};
|
|
319
|
+
result=args.format==='csv'?planOutcomeCsvImport({...common,csv:source}):planOutcomeImport({...common,rows:parseCampaignSource(source,{format:args.format??'json'}),sourceSha256:hash(source)});
|
|
320
|
+
}else result=await applyOutcomeImport(await json('plan'),await command(),{sourceSha256:hash(await file('source'))});
|
|
321
|
+
}
|
|
322
|
+
await writeFile(outputPath,JSON.stringify(result,null,2)+'\n',{flag:'wx',mode:0o600});
|
|
323
|
+
out(JSON.stringify({file:outputPath,sha256:hash(JSON.stringify(result,null,2)+'\n'),rows:result.results?.length??null,effects:result.effects??null}));
|
|
324
|
+
return result.results?.some(row=>row.status==='held')?2:0;
|
|
325
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {readTable} from './adapters/csv.js';
|
|
3
|
+
|
|
4
|
+
const VERSION='agentlinkops-outcome-import-v1';
|
|
5
|
+
const hash=value=>createHash('sha256').update(typeof value==='string'?value:JSON.stringify(value)).digest('hex');
|
|
6
|
+
const fail=code=>{throw Object.assign(new Error(code),{code});};
|
|
7
|
+
const opaque=value=>typeof value==='string'&&value.length>0&&value.length<=200&&!/[\u0000-\u001f\u007f@]/.test(value)&&!/https?:\/\//i.test(value);
|
|
8
|
+
const uuid=value=>typeof value==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
9
|
+
const validDate=value=>typeof value==='string'&&/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value)&&Number.isFinite(Date.parse(value))&&new Date(value).toISOString().slice(0,10)===value.slice(0,10);
|
|
10
|
+
const seal=body=>({...body,sha256:hash(body)});
|
|
11
|
+
const OUTCOMES=['contacted','replied','publication_promised','declined','publication_claimed','bounced','send_error','wrong_person','do_not_contact','email_health'];
|
|
12
|
+
const IGNORED=new Set(['opened','clicked','open','click','pending','queued','drafted','draft','canceled','cancelled','completed','not yet contacted','not_yet_contacted']);
|
|
13
|
+
const COMMON=Object.freeze({sent:'contacted',contacted:'contacted',replied:'replied',reply:'replied',bounced:'bounced',bounce:'bounced',error:'send_error',failed:'send_error',unsubscribed:'do_not_contact',unsubscribe:'do_not_contact',won:'publication_claimed'});
|
|
14
|
+
|
|
15
|
+
/** The imported shape is explicit. Vendor docs do not promise universal stable CSV headers. */
|
|
16
|
+
export const OUTCOME_EXPORT_PROFILES=Object.freeze({
|
|
17
|
+
hunter:{version:'hunter-selected-leads-mapped-csv-v1',documentation:'https://help.hunter.io/en/articles/8572858-sequences-reporting',statusDocumentation:'https://hunter.io/api-documentation',checkedAt:'2026-10-24',statusMap:COMMON,requiresExplicitColumns:true},
|
|
18
|
+
lemlist:{version:'lemlist-step-timestamps-csv-v1',documentation:'https://help.lemlist.com/en/articles/7219811-export-campaign-results',checkedAt:'2026-10-24',statusMap:COMMON,requiresExplicitColumns:true,stepTimestampColumns:['sentAtN','repliedAtN']},
|
|
19
|
+
instantly:{version:'instantly-selected-leads-mapped-csv-v1',documentation:'https://help.instantly.ai/en/articles/6285719-how-to-download-leads-csv',checkedAt:'2026-10-24',statusMap:COMMON,requiresExplicitColumns:true},
|
|
20
|
+
manual:{version:'agentlinkops-supplied-outcomes-v1',requiresExplicitColumns:false,statusMap:Object.fromEntries(OUTCOMES.map(s=>[s,s]))},
|
|
21
|
+
agent:{version:'agentlinkops-supplied-outcomes-v1',requiresExplicitColumns:false,statusMap:Object.fromEntries(OUTCOMES.map(s=>[s,s]))},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function validateColumns(columns){
|
|
25
|
+
if(!columns||typeof columns!=='object'||Array.isArray(columns)||Object.keys(columns).some(k=>!['recordRef','status','occurredAt','eventId','dates','validity'].includes(k)))fail('INVALID_OUTCOME_COLUMNS');
|
|
26
|
+
for(const key of ['recordRef','status','occurredAt','eventId','validity'])if(columns[key]!=null&&(typeof columns[key]!=='string'||columns[key].length>100))fail('INVALID_OUTCOME_COLUMNS');
|
|
27
|
+
if(!columns.recordRef)fail('OUTCOME_STABLE_REFERENCE_COLUMN_REQUIRED');
|
|
28
|
+
if(columns.dates&&(typeof columns.dates!=='object'||Array.isArray(columns.dates)||Object.entries(columns.dates).some(([k,v])=>!OUTCOMES.includes(k)||typeof v!=='string'||v.length>100)))fail('INVALID_OUTCOME_COLUMNS');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Output contains only identity, date, normalized outcome and scoped contact-method metadata. */
|
|
32
|
+
export function planOutcomeImport({projectId,provider,accountRef,rows,bindings,columns=null,sourceSha256=null,statusMap=null}){
|
|
33
|
+
if(!opaque(projectId)||!opaque(accountRef)||!OUTCOME_EXPORT_PROFILES[provider]||!Array.isArray(rows)||rows.length>10000||!Array.isArray(bindings))fail('INVALID_OUTCOME_IMPORT');
|
|
34
|
+
const profile=OUTCOME_EXPORT_PROFILES[provider];
|
|
35
|
+
const mapping=columns??(profile.requiresExplicitColumns?null:{recordRef:'recordRef',status:'outcome',occurredAt:'occurredAt',eventId:'eventId',validity:'validity'});
|
|
36
|
+
validateColumns(mapping);
|
|
37
|
+
// A caller can map a vendor-specific status to a supported supplied fact; it cannot map to verified won.
|
|
38
|
+
const statuses={...profile.statusMap,...statusMap};
|
|
39
|
+
if(Object.values(statuses).some(v=>!OUTCOMES.includes(v)&&v!=='ignore'))fail('INVALID_OUTCOME_STATUS_MAP');
|
|
40
|
+
const results=[];
|
|
41
|
+
for(const [index,raw]of rows.entries()){
|
|
42
|
+
const row=index+1;
|
|
43
|
+
if(!raw||typeof raw!=='object'||Array.isArray(raw)||raw.__parseError){results.push({row,status:'held',reason:'invalid_export_row'});continue;}
|
|
44
|
+
const ref=raw[mapping.recordRef];
|
|
45
|
+
if(!opaque(ref)){results.push({row,status:'held',reason:'missing_stable_record_reference'});continue;}
|
|
46
|
+
const matches=bindings.filter(b=>b.namespace===provider&&b.accountRef===accountRef&&b.recordRef===ref);
|
|
47
|
+
if(matches.length!==1){results.push({row,status:'held',reason:matches.length?'ambiguous_binding':'unmapped_binding',recordRef:ref});continue;}
|
|
48
|
+
const b=matches[0];
|
|
49
|
+
if(b.projectId!==projectId||!opaque(b.watchId)||b.contactId!=null&&!opaque(b.contactId)||!['email','contact_url','none'].includes(b.method??'none')){results.push({row,status:'held',reason:'invalid_or_cross_project_binding',recordRef:ref});continue;}
|
|
50
|
+
if(b.contactId&&(!uuid(b.contactRevision)||!(b.associationRevision===0||uuid(b.associationRevision)))){results.push({row,status:'held',reason:'contact_method_provenance_required',recordRef:ref});continue;}
|
|
51
|
+
const events=[];
|
|
52
|
+
// lemlist documents sentAt1/repliedAt1 etc. Each timestamp remains its own supplied fact.
|
|
53
|
+
if(provider==='lemlist'&&!mapping.status){
|
|
54
|
+
for(const [name,value]of Object.entries(raw)){
|
|
55
|
+
const match=/^(sentAt|repliedAt)([1-9]\d*)$/.exec(name);
|
|
56
|
+
if(match&&value)events.push({outcome:match[1]==='sentAt'?'contacted':'replied',date:value,step:name});
|
|
57
|
+
}
|
|
58
|
+
if(!events.length){results.push({row,status:'ignored',reason:'no_supported_step_timestamp',recordRef:ref});continue;}
|
|
59
|
+
}else{
|
|
60
|
+
const original=String(raw[mapping.status]??'').trim().toLowerCase();
|
|
61
|
+
if(IGNORED.has(original)||statuses[original]==='ignore'){results.push({row,status:'ignored',reason:'no_campaign_progress_fact',recordRef:ref});continue;}
|
|
62
|
+
const outcome=statuses[original];
|
|
63
|
+
if(!outcome){results.push({row,status:'held',reason:'unmapped_outcome',recordRef:ref});continue;}
|
|
64
|
+
// A last-contact date is a send date, never evidence of a later reply or bounce time.
|
|
65
|
+
const dateColumn=mapping.dates?.[outcome]??mapping.occurredAt;
|
|
66
|
+
if(!dateColumn){results.push({row,status:'held',reason:'event_date_column_required',recordRef:ref});continue;}
|
|
67
|
+
events.push({outcome,date:raw[dateColumn],step:null});
|
|
68
|
+
}
|
|
69
|
+
for(const {outcome,date,step}of events){
|
|
70
|
+
if(!validDate(date)){results.push({row,status:'held',reason:'missing_or_invalid_event_date',recordRef:ref,...(step?{step}:{})});continue;}
|
|
71
|
+
if(['bounced','send_error','wrong_person','do_not_contact','email_health'].includes(outcome)&&(!b.contactId||!uuid(b.contactRevision)||!(b.associationRevision===0||uuid(b.associationRevision))||b.method==='none')){results.push({row,status:'held',reason:'contact_method_provenance_required',recordRef:ref});continue;}
|
|
72
|
+
const validity=outcome==='email_health'?raw[mapping.validity]:undefined;
|
|
73
|
+
if(outcome==='email_health'&&!['valid','role','invalid','catch-all','unknown'].includes(validity)){results.push({row,status:'held',reason:'unmapped_validity',recordRef:ref});continue;}
|
|
74
|
+
const suppliedEvent=raw[mapping.eventId];
|
|
75
|
+
if(suppliedEvent!=null&&suppliedEvent!==''&&!opaque(suppliedEvent)){results.push({row,status:'held',reason:'invalid_source_event_reference',recordRef:ref});continue;}
|
|
76
|
+
const eventId=suppliedEvent?(step?'step:'+hash([suppliedEvent,step]):String(suppliedEvent)):'export:'+hash([provider,accountRef,ref,step,outcome,date,validity??null]);
|
|
77
|
+
const input={projectId,watchId:b.watchId,...(b.contactId?{contactId:b.contactId}:{}),idempotencyKey:'outcome:'+hash([projectId,provider,accountRef,ref,eventId]),source:{namespace:provider,accountRef,recordRef:ref,eventId},outcome,occurredAt:date,method:b.method??'none',...(b.contactRevision?{contactRevision:b.contactRevision}:{}),...(b.associationRevision?{associationRevision:b.associationRevision}:{}),...(validity?{validity}:{}),...(outcome==='do_not_contact'?{restrictionScope:b.restrictionScope??'method'}:{})};
|
|
78
|
+
results.push({row,status:'ready',recordRef:ref,...(step?{step}:{}),input});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Same source event with different content is a conflict, never input-order precedence.
|
|
82
|
+
const groups=new Map();for(const row of results)if(row.input){const k=row.input.idempotencyKey;groups.set(k,[...(groups.get(k)??[]),row]);}
|
|
83
|
+
for(const group of groups.values())if(new Set(group.map(row=>hash(row.input))).size>1)for(const row of group){row.status='held';row.reason='conflicting_source_event';delete row.input;}
|
|
84
|
+
return seal({schemaVersion:VERSION,projectId,provider,accountRef,profile:profile.version,sourceSha256,sourceRowsSha256:hash(rows),results,effects:{sourceWrites:0,checks:0,credits:0,sends:0,enrollments:0}});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function planOutcomeCsvImport({csv,...options}){
|
|
88
|
+
if(typeof csv!=='string'||Buffer.byteLength(csv)>16*1024*1024)fail('OUTCOME_EXPORT_TOO_LARGE');
|
|
89
|
+
const table=readTable(csv);if(new Set(table.header).size!==table.header.length)fail('DUPLICATE_OUTCOME_HEADER');
|
|
90
|
+
return planOutcomeImport({...options,sourceSha256:hash(csv),rows:table.records.map(r=>r.error?{__parseError:true}:r.values)});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function applyOutcomeImport(plan,call,{sourceSha256=plan?.sourceSha256}={}){
|
|
94
|
+
const {sha256,...body}=plan??{};if(body.schemaVersion!==VERSION||sha256!==hash(body))fail('OUTCOME_PLAN_CHANGED');
|
|
95
|
+
if(plan.sourceSha256!==sourceSha256)fail('OUTCOME_SOURCE_CHANGED');
|
|
96
|
+
const results=[],effects={...plan.effects,recorded:0,replayed:0,rowsRequiringReadback:0};
|
|
97
|
+
for(const row of plan.results){
|
|
98
|
+
if(row.status!=='ready'){results.push({row:row.row,status:row.status,reason:row.reason});continue;}
|
|
99
|
+
try{
|
|
100
|
+
const result=await call('record_outreach_outcome',row.input);
|
|
101
|
+
if(!result?.event||typeof result.replayed!=='boolean'||result.event.outcome!==row.input.outcome)fail('INVALID_OUTCOME_RESPONSE');
|
|
102
|
+
effects[result.replayed?'replayed':'recorded']++;
|
|
103
|
+
results.push({row:row.row,status:result.event.disposition==='accepted'?'applied':'recorded',eventId:result.event.id,disposition:result.event.disposition,replayed:result.replayed});
|
|
104
|
+
}catch(error){effects.rowsRequiringReadback++;results.push({row:row.row,status:'held',reason:error.code??error.message,effectState:'requires_readback',idempotencyKey:row.input.idempotencyKey});}
|
|
105
|
+
}
|
|
106
|
+
return{schemaVersion:VERSION,projectId:plan.projectId,planSha256:plan.sha256,results,effects};
|
|
107
|
+
}
|