@tasksai/install 0.1.38 → 0.1.40
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/README.md +34 -0
- package/bootstrap/Install RealtorTasksAI.command +37 -0
- package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
- package/package.json +14 -3
- package/runtime/document_renderer.py +882 -0
- package/runtime/server.py +55 -581
- package/runtime/software_use.py +39 -0
- package/runtime/workflow-requirements.txt +5 -0
- package/runtime/workflows/__init__.py +0 -0
- package/runtime/workflows/account_snapshot.py +33 -0
- package/runtime/workflows/attachments.py +45 -0
- package/runtime/workflows/authority_guides.py +81 -0
- package/runtime/workflows/catalog.py +51 -0
- package/runtime/workflows/catalog_app.py +174 -0
- package/runtime/workflows/catalog_generation.py +186 -0
- package/runtime/workflows/catalog_output.py +312 -0
- package/runtime/workflows/catalog_suggestions.py +51 -0
- package/runtime/workflows/catalog_workspace.py +152 -0
- package/runtime/workflows/cli.py +66 -0
- package/runtime/workflows/document_answers.py +96 -0
- package/runtime/workflows/document_selection.py +50 -0
- package/runtime/workflows/documents.py +243 -0
- package/runtime/workflows/embedded/catalog.html +83 -0
- package/runtime/workflows/embedded/offer-review.html +516 -0
- package/runtime/workflows/embedded/preview.html +36 -0
- package/runtime/workflows/embedded_actions.py +96 -0
- package/runtime/workflows/embedded_demo.py +424 -0
- package/runtime/workflows/folders.py +120 -0
- package/runtime/workflows/gateway.py +157 -0
- package/runtime/workflows/generation_lock.py +26 -0
- package/runtime/workflows/launcher.py +61 -0
- package/runtime/workflows/licensed_delivery.py +46 -0
- package/runtime/workflows/mcp_dev.py +52 -0
- package/runtime/workflows/meeting_plan.py +92 -0
- package/runtime/workflows/model_client.py +26 -0
- package/runtime/workflows/numeric_consistency.py +72 -0
- package/runtime/workflows/public_source_fetch.py +66 -0
- package/runtime/workflows/realtor/__init__.py +0 -0
- package/runtime/workflows/realtor/adapter.py +179 -0
- package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
- package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
- package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
- package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
- package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
- package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
- package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
- package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
- package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
- package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
- package/runtime/workflows/realtor-release-registry.json +705 -0
- package/runtime/workflows/released_catalog.py +91 -0
- package/runtime/workflows/source_capture.py +82 -0
- package/runtime/workflows/store.py +492 -0
- package/runtime/workflows/table_calculations.py +132 -0
- package/runtime/workflows/template.py +65 -0
- package/runtime/workflows/workspace_launch.py +76 -0
- package/src/index.js +217 -118
- package/src/managed-python.js +63 -0
- package/src/operation-lock.js +22 -0
- package/src/prepared-update.js +86 -0
- package/src/private-workflow.js +116 -0
- package/src/python-runtime.js +50 -0
- package/src/recover-installation.js +30 -0
- package/src/recovery-lock.js +30 -0
- package/src/software-hash.js +24 -0
- package/src/software-use.js +32 -0
- package/src/update-journal.js +24 -0
- package/src/update-recovery.js +72 -0
- package/src/workspace-runtime.js +45 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""OS-held software-use lock; released by the OS if its process exits."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@contextmanager
|
|
8
|
+
def software_use(install_dir, *, exclusive=False):
|
|
9
|
+
lock = Path(install_dir) / '.software-use.lock'
|
|
10
|
+
if lock.is_symlink():
|
|
11
|
+
raise RuntimeError('Software-use lock cannot be a symbolic link')
|
|
12
|
+
with lock.open('a+b') as stream:
|
|
13
|
+
if os.name == 'nt':
|
|
14
|
+
import ctypes
|
|
15
|
+
from ctypes import wintypes
|
|
16
|
+
import msvcrt
|
|
17
|
+
class Overlapped(ctypes.Structure):
|
|
18
|
+
_fields_ = [('Internal',ctypes.c_size_t),('InternalHigh',ctypes.c_size_t),
|
|
19
|
+
('Offset',wintypes.DWORD),('OffsetHigh',wintypes.DWORD),('hEvent',wintypes.HANDLE)]
|
|
20
|
+
kernel = ctypes.WinDLL('kernel32',use_last_error=True)
|
|
21
|
+
kernel.LockFileEx.argtypes = [wintypes.HANDLE,wintypes.DWORD,wintypes.DWORD,wintypes.DWORD,wintypes.DWORD,ctypes.POINTER(Overlapped)]
|
|
22
|
+
kernel.LockFileEx.restype = wintypes.BOOL
|
|
23
|
+
kernel.UnlockFileEx.argtypes = [wintypes.HANDLE,wintypes.DWORD,wintypes.DWORD,wintypes.DWORD,ctypes.POINTER(Overlapped)]
|
|
24
|
+
kernel.UnlockFileEx.restype = wintypes.BOOL
|
|
25
|
+
handle = msvcrt.get_osfhandle(stream.fileno())
|
|
26
|
+
overlap = Overlapped()
|
|
27
|
+
if not kernel.LockFileEx(handle,1 | (2 if exclusive else 0),0,1,0,ctypes.byref(overlap)):
|
|
28
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
29
|
+
else:
|
|
30
|
+
import fcntl
|
|
31
|
+
fcntl.flock(stream.fileno(), (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
|
|
32
|
+
try:
|
|
33
|
+
yield
|
|
34
|
+
finally:
|
|
35
|
+
if os.name == 'nt':
|
|
36
|
+
if not kernel.UnlockFileEx(handle,0,1,0,ctypes.byref(overlap)):
|
|
37
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
38
|
+
else:
|
|
39
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Local display-only credit receipt. Never stores credentials or calls an API."""
|
|
2
|
+
from datetime import datetime,timezone
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from .licensed_delivery import safe_directory
|
|
8
|
+
from .store import JobError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read(root):
|
|
12
|
+
path=Path(root)/'account-display.json'
|
|
13
|
+
if path.is_symlink():raise JobError('Account display must not be a symbolic link')
|
|
14
|
+
if not path.exists():return {'credits':None,'updated_at':None,'license_type':None}
|
|
15
|
+
data=json.loads(path.read_text(encoding="utf-8"))
|
|
16
|
+
return {key:data.get(key) for key in ('credits','updated_at','license_type')}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def remember(root,result):
|
|
20
|
+
credits=result.get('credits_balance',result.get('credits_remaining'))
|
|
21
|
+
if isinstance(credits,bool) or not isinstance(credits,int) or credits<0:return
|
|
22
|
+
root=safe_directory(root);prior=read(root)
|
|
23
|
+
kind=result.get('license_type',prior.get('license_type'))
|
|
24
|
+
if not isinstance(kind,str) or len(kind)>80:kind=None
|
|
25
|
+
data={'credits':credits,'updated_at':datetime.now(timezone.utc).isoformat(),'license_type':kind}
|
|
26
|
+
temporary=root/('account-display-'+uuid.uuid4().hex+'.tmp')
|
|
27
|
+
descriptor=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
28
|
+
try:
|
|
29
|
+
with os.fdopen(descriptor,'w',encoding='utf-8') as stream:
|
|
30
|
+
json.dump(data,stream);stream.flush();os.fsync(stream.fileno())
|
|
31
|
+
temporary.replace(root/'account-display.json')
|
|
32
|
+
finally:
|
|
33
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Private-trial uploads backed by the existing immutable document store."""
|
|
2
|
+
import base64
|
|
3
|
+
import binascii
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
from .documents import Documents, EXTENSIONS
|
|
7
|
+
from .store import JobError, label
|
|
8
|
+
|
|
9
|
+
MAX_UPLOAD = 2_000_000
|
|
10
|
+
|
|
11
|
+
def upload(demo, job_id, filename, encoded, request_id):
|
|
12
|
+
label(request_id, 'request_id')
|
|
13
|
+
label(filename, 'filename')
|
|
14
|
+
job = demo.store.read(job_id)
|
|
15
|
+
if not job['current'] or job['current']['input'].get('example') is not True:
|
|
16
|
+
raise JobError('Only fictional projects are available in this trial')
|
|
17
|
+
if Path(filename).name != filename or '/' in filename or '\\' in filename or filename in ('.', '..'):
|
|
18
|
+
raise JobError('Choose a file by its filename, not a computer path')
|
|
19
|
+
if Path(filename).suffix.lower() not in EXTENSIONS:
|
|
20
|
+
raise JobError('Choose a PDF, Word document, or text file')
|
|
21
|
+
if not isinstance(encoded, str) or len(encoded) > 4 * ((MAX_UPLOAD + 2) // 3):
|
|
22
|
+
raise JobError('This trial accepts files up to 2 MB')
|
|
23
|
+
try:
|
|
24
|
+
raw = base64.b64decode(encoded, validate=True)
|
|
25
|
+
except (ValueError, binascii.Error):
|
|
26
|
+
raise JobError('The uploaded file could not be read; choose it again')
|
|
27
|
+
if not raw or len(raw) > MAX_UPLOAD:
|
|
28
|
+
raise JobError('Choose a nonempty file up to 2 MB')
|
|
29
|
+
# Isolated staging prevents same-name uploads from overwriting one another.
|
|
30
|
+
# Documents captures the original and extracted text before staging is removed.
|
|
31
|
+
with TemporaryDirectory(prefix='upload-', dir=demo.root) as temporary:
|
|
32
|
+
inbox = Path(temporary)
|
|
33
|
+
selected = inbox / filename
|
|
34
|
+
selected.write_bytes(raw); selected.chmod(0o600)
|
|
35
|
+
result = Documents(demo.store, inbox).import_document(job_id, filename, request_id, True)
|
|
36
|
+
view = demo.view(job_id)
|
|
37
|
+
view['notice'] = 'Document saved to this project. It is available to reuse; your generated documents have not changed.'
|
|
38
|
+
view['uploaded_document_id'] = result['document_id']
|
|
39
|
+
return view
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read(demo, job_id, document_id):
|
|
43
|
+
result = demo.gateway.documents.read(job_id, document_id)
|
|
44
|
+
# Never expose filesystem paths or storage implementation to the host/card.
|
|
45
|
+
return {key: result[key] for key in ('id', 'filename', 'source_id', 'created_at', 'parts', 'warnings')}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Immutable local copies of loader source metadata, not fetched source contents."""
|
|
2
|
+
from datetime import date, datetime, timezone
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from .licensed_delivery import safe_directory
|
|
10
|
+
from .store import JobError, canonical
|
|
11
|
+
|
|
12
|
+
PACK_FIELDS = ('status','coverage_level','state_name','state_code','county','locality','district','mls',
|
|
13
|
+
'registry_reviewed_on','registry_refresh_due_on','resolution_source')
|
|
14
|
+
SOURCE_FIELDS = ('title','authority','jurisdiction','scope_type','effective_date','verification_status',
|
|
15
|
+
'reviewed_on','refresh_due_on','url','purpose','limits')
|
|
16
|
+
LIST_FIELDS = ('authority_layers','required_user_authorities','instructions')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _text(value):
|
|
20
|
+
if value is None:return None
|
|
21
|
+
if not isinstance(value,str) or len(value)>10000:raise JobError('Invalid source guide metadata')
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def remember(root,skill_id,result):
|
|
26
|
+
if not re.fullmatch(r'realtor_[a-z0-9_]+',skill_id) or result.get('skill_id')!=skill_id:
|
|
27
|
+
raise JobError('Source guide does not match the requested skill')
|
|
28
|
+
packet={'skill_id':skill_id,**{k:_text(result.get(k)) for k in PACK_FIELDS}}
|
|
29
|
+
for key in LIST_FIELDS:
|
|
30
|
+
values=result.get(key) or []
|
|
31
|
+
if not isinstance(values,list) or len(values)>100:raise JobError('Source guide is too large')
|
|
32
|
+
packet[key]=[_text(v) for v in values]
|
|
33
|
+
sources=result.get('sources') or []
|
|
34
|
+
if not isinstance(sources,list) or len(sources)>100:raise JobError('Source guide is too large')
|
|
35
|
+
packet['sources']=[{k:_text(source.get(k)) for k in SOURCE_FIELDS} for source in sources]
|
|
36
|
+
encoded=canonical(packet).encode('utf-8')
|
|
37
|
+
if len(encoded)>200000:raise JobError('Source guide is too large')
|
|
38
|
+
identity=sha256(encoded).hexdigest();folder=safe_directory(Path(root)/'authority-guides')
|
|
39
|
+
destination=folder/(identity+'.json')
|
|
40
|
+
if destination.is_symlink():raise JobError('Source guide path must not be a symbolic link')
|
|
41
|
+
if not destination.exists():
|
|
42
|
+
temporary=folder/(uuid.uuid4().hex+'.tmp')
|
|
43
|
+
descriptor=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
44
|
+
try:
|
|
45
|
+
with os.fdopen(descriptor,'w',encoding='utf-8',newline='\n') as stream:
|
|
46
|
+
json.dump({'id':identity,'captured_at':datetime.now(timezone.utc).isoformat(),'packet':packet},stream)
|
|
47
|
+
stream.flush();os.fsync(stream.fileno())
|
|
48
|
+
# Exclusive destination creation preserves an existing immutable receipt.
|
|
49
|
+
try:
|
|
50
|
+
os.link(temporary,destination)
|
|
51
|
+
except FileExistsError:pass
|
|
52
|
+
finally:temporary.unlink(missing_ok=True)
|
|
53
|
+
return read(root,identity)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read(root,identity,today=None):
|
|
57
|
+
if not isinstance(identity,str) or not re.fullmatch(r'[a-f0-9]{64}',identity):raise JobError('Invalid source guide ID')
|
|
58
|
+
folder=Path(root)/'authority-guides';path=folder/(identity+'.json')
|
|
59
|
+
if folder.is_symlink() or path.is_symlink():raise JobError('Source guide path must not be a symbolic link')
|
|
60
|
+
record=json.loads(path.read_text(encoding='utf-8'));packet=record['packet']
|
|
61
|
+
if record.get('id')!=identity or sha256(canonical(packet).encode('utf-8')).hexdigest()!=identity:
|
|
62
|
+
raise JobError('Saved source guide changed')
|
|
63
|
+
today=today or date.today()
|
|
64
|
+
deadlines=[packet.get('registry_refresh_due_on')]+[s.get('refresh_due_on') for s in packet['sources']]
|
|
65
|
+
try:refresh_needed=any(not value or date.fromisoformat(value)<=today for value in deadlines)
|
|
66
|
+
except (ValueError,TypeError):refresh_needed=True
|
|
67
|
+
return {**record,'refresh_needed':refresh_needed,'metadata_only':True,'source_text_available':False}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def for_skill(root,skill_id):
|
|
71
|
+
folder=Path(root)/'authority-guides'
|
|
72
|
+
if folder.is_symlink():raise JobError('Source guide path must not be a symbolic link')
|
|
73
|
+
if not folder.exists():return {'guides':[],'unavailable':0}
|
|
74
|
+
guides=[];unavailable=0
|
|
75
|
+
for path in folder.glob('*.json'):
|
|
76
|
+
try:
|
|
77
|
+
guide=read(root,path.stem)
|
|
78
|
+
if guide['packet']['skill_id']==skill_id:guides.append(guide)
|
|
79
|
+
except (JobError,OSError,ValueError,KeyError,TypeError):unavailable+=1
|
|
80
|
+
guides.sort(key=lambda guide:guide.get('captured_at',''),reverse=True)
|
|
81
|
+
return {'guides':guides,'unavailable':unavailable}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Attach local presentation to an already selected and delivered skill.
|
|
2
|
+
|
|
3
|
+
No catalog search, API calls, billing, source rewriting, or content generation.
|
|
4
|
+
Bindings are application configuration, never user answers or uploaded files.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
|
|
9
|
+
from .store import JobError
|
|
10
|
+
from .template import InputField, OutputFile, WorkflowTemplate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CatalogBinding:
|
|
15
|
+
product_id: str
|
|
16
|
+
skill_id: str
|
|
17
|
+
content_sha256: str
|
|
18
|
+
title: str
|
|
19
|
+
inputs: tuple[InputField, ...]
|
|
20
|
+
input_evidence: tuple[str, ...]
|
|
21
|
+
outputs: tuple[OutputFile, ...]
|
|
22
|
+
no_input_evidence: str = ''
|
|
23
|
+
|
|
24
|
+
def attach(self, *, product_id, skill_id, content):
|
|
25
|
+
"""Fail closed on different delivered content; retain every instruction.
|
|
26
|
+
|
|
27
|
+
Exact quotes anchor field mappings to the source but do not establish
|
|
28
|
+
that a mapping is semantically correct. Developers must check optional
|
|
29
|
+
and conditional inputs before installing a binding.
|
|
30
|
+
"""
|
|
31
|
+
if (product_id, skill_id) != (self.product_id, self.skill_id):
|
|
32
|
+
raise JobError('Presentation does not match the selected skill')
|
|
33
|
+
if not isinstance(content, str) or not content.strip() or sha256(content.encode()).hexdigest() != self.content_sha256:
|
|
34
|
+
raise JobError('Skill content changed; its presentation needs updating')
|
|
35
|
+
if len(self.inputs) != len(self.input_evidence):
|
|
36
|
+
raise JobError('Each input needs its original instruction')
|
|
37
|
+
evidence = self.input_evidence if self.inputs else (self.no_input_evidence,)
|
|
38
|
+
if any(not quote.strip() or quote not in content for quote in evidence):
|
|
39
|
+
raise JobError('Input mapping needs evidence from the selected skill')
|
|
40
|
+
template = WorkflowTemplate(self.skill_id, self.product_id, self.title, self.inputs, self.outputs)
|
|
41
|
+
return AttachedSkill(template, content)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class AttachedSkill:
|
|
46
|
+
template: WorkflowTemplate
|
|
47
|
+
instructions: str
|
|
48
|
+
|
|
49
|
+
def intake(self, answers=None):
|
|
50
|
+
# Partial answers are allowed; missing fields remain visible.
|
|
51
|
+
return self.template.input_state(answers)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Private-loopback customer workspace. No public HTTP routes or account secrets."""
|
|
2
|
+
import argparse
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import secrets
|
|
8
|
+
import socket
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
from starlette.responses import HTMLResponse,JSONResponse,PlainTextResponse,FileResponse
|
|
13
|
+
from starlette.routing import Route
|
|
14
|
+
from starlette.background import BackgroundTask
|
|
15
|
+
from .catalog_workspace import CatalogWorkspace
|
|
16
|
+
from .documents import Documents,EXTENSIONS,MAX_BYTES
|
|
17
|
+
from .folders import Folders
|
|
18
|
+
from .released_catalog import registry
|
|
19
|
+
from .store import JobError
|
|
20
|
+
from .workspace_launch import runtime_fingerprint
|
|
21
|
+
|
|
22
|
+
ASSETS=Path(__file__).parent/'embedded'
|
|
23
|
+
MAX_ENCODED_BYTES=4*((MAX_BYTES+2)//3)
|
|
24
|
+
MAX_REQUEST_BYTES=MAX_ENCODED_BYTES+64_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def bounded_json(request):
|
|
28
|
+
declared=request.headers.get('content-length')
|
|
29
|
+
if declared is not None:
|
|
30
|
+
try:size=int(declared)
|
|
31
|
+
except ValueError:raise JobError('Invalid request size')
|
|
32
|
+
if size<0 or size>MAX_REQUEST_BYTES:raise JobError('Input too large; choose a smaller document')
|
|
33
|
+
body=bytearray()
|
|
34
|
+
async for chunk in request.stream():
|
|
35
|
+
if len(body)+len(chunk)>MAX_REQUEST_BYTES:raise JobError('Input too large; choose a smaller document')
|
|
36
|
+
body.extend(chunk)
|
|
37
|
+
return json.loads(body)
|
|
38
|
+
|
|
39
|
+
def app(workspace,token,*,on_shutdown=None):
|
|
40
|
+
active_actions=0
|
|
41
|
+
closing=False
|
|
42
|
+
prefix='/workspace/'+token
|
|
43
|
+
started_fingerprint=runtime_fingerprint()
|
|
44
|
+
page_html=(ASSETS/'catalog.html').read_text(encoding="utf-8").replace('__UPLOAD_LIMIT_BYTES__',str(MAX_BYTES)).replace('__UPLOAD_LIMIT_MB__',str(MAX_BYTES//1_000_000))
|
|
45
|
+
def local(request): return request.url.hostname in ('127.0.0.1','localhost')
|
|
46
|
+
async def health(request):
|
|
47
|
+
if not local(request):return PlainTextResponse('Not found',404)
|
|
48
|
+
from .workspace_launch import workspace_identity
|
|
49
|
+
return JSONResponse({'workspace_id':workspace_identity(workspace.root,workspace.store.owner),'runtime_fingerprint':started_fingerprint,'closing':closing})
|
|
50
|
+
async def page(request):
|
|
51
|
+
if not local(request):return PlainTextResponse('Not found',404)
|
|
52
|
+
return HTMLResponse(page_html,headers={'Cache-Control':'no-store','Content-Security-Policy':"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"})
|
|
53
|
+
async def action(request):
|
|
54
|
+
nonlocal active_actions
|
|
55
|
+
if not local(request) or request.headers.get('origin')!=str(request.base_url).rstrip('/') or request.headers.get('content-type')!='application/json':
|
|
56
|
+
return PlainTextResponse('Not found',404)
|
|
57
|
+
if closing:return JSONResponse({'error':'Workspace is closing. Reopen it from your AI assistant.'},status_code=409)
|
|
58
|
+
active_actions+=1
|
|
59
|
+
try:
|
|
60
|
+
if runtime_fingerprint()!=started_fingerprint:
|
|
61
|
+
return JSONResponse({'error':'TasksAI has been updated. Reopen the workspace from your AI assistant to use the updated version. Your saved projects and documents are still available.'},status_code=409)
|
|
62
|
+
payload=await bounded_json(request);name=payload['action'];job_id=payload.get('job_id')
|
|
63
|
+
if name=='account':
|
|
64
|
+
from .account_snapshot import read
|
|
65
|
+
result=read(workspace.root)
|
|
66
|
+
elif name=='projects': result=workspace.projects(50,payload.get('offset',0))
|
|
67
|
+
elif name=='skills':
|
|
68
|
+
result={'skills':[]}
|
|
69
|
+
for skill_id,release in registry().items():
|
|
70
|
+
path=workspace.root/'licensed-skills'/skill_id/release['content_sha256']/'instructions.md'
|
|
71
|
+
if path.is_file(): result['skills'].append({'skill_id':skill_id,**release})
|
|
72
|
+
elif name=='create':result=workspace.create(payload['skill_id'],payload['version'],payload['content_hash'],payload['matter'],payload['request_id'])
|
|
73
|
+
elif name=='open':result=workspace.view(job_id)
|
|
74
|
+
elif name=='answers':result=workspace.save_answers(job_id,payload['answers'],payload['sequence'],payload.get('user_request'))
|
|
75
|
+
elif name=='documents':result={'documents':Documents(workspace.store,allow_customer_documents=True).list(job_id)}
|
|
76
|
+
elif name=='capture-source':
|
|
77
|
+
from .source_capture import capture
|
|
78
|
+
result=await asyncio.to_thread(capture,workspace,job_id,payload['guide_id'],payload['source_index'])
|
|
79
|
+
elif name=='select-authority':result=workspace.select_authority_guide(job_id,payload.get('guide_id'),payload['sequence'])
|
|
80
|
+
elif name=='authority-guides':
|
|
81
|
+
from .authority_guides import for_skill
|
|
82
|
+
result=for_skill(workspace.root,workspace.binding(job_id)['skill_id'])
|
|
83
|
+
elif name=='recommend-documents':
|
|
84
|
+
from .document_selection import recommend
|
|
85
|
+
result=await asyncio.to_thread(recommend,workspace,job_id)
|
|
86
|
+
elif name=='upload':
|
|
87
|
+
workspace.view(job_id);filename=payload['filename']
|
|
88
|
+
if not isinstance(filename,str) or Path(filename).name!=filename or '\\' in filename or Path(filename).suffix.lower() not in EXTENSIONS:raise JobError('Choose a PDF, Word or text document')
|
|
89
|
+
encoded=payload['encoded']
|
|
90
|
+
if not isinstance(encoded,str) or len(encoded)>MAX_ENCODED_BYTES:raise JobError(f'Choose a file up to {MAX_BYTES//1_000_000} MB')
|
|
91
|
+
raw=base64.b64decode(encoded,validate=True)
|
|
92
|
+
if not 0<len(raw)<=MAX_BYTES:raise JobError(f'Choose a file up to {MAX_BYTES//1_000_000} MB')
|
|
93
|
+
with tempfile.TemporaryDirectory(dir=workspace.root) as directory:
|
|
94
|
+
selected=Path(directory)/filename;selected.write_bytes(raw)
|
|
95
|
+
result=Documents(workspace.store,Path(directory),allow_customer_documents=True).import_document(job_id,filename,payload['request_id'],False)
|
|
96
|
+
elif name=='suggest':
|
|
97
|
+
if payload.get('consent') is not True:raise JobError('Confirm sending the selected document text to your AI assistant')
|
|
98
|
+
from .catalog_suggestions import suggest
|
|
99
|
+
result=await asyncio.to_thread(suggest,workspace,job_id,payload.get('document_ids',[]))
|
|
100
|
+
elif name=='apply-suggestion':
|
|
101
|
+
from .catalog_suggestions import apply
|
|
102
|
+
result=apply(workspace,job_id,payload['suggestion_id'],payload['candidate_id'],payload['sequence'])
|
|
103
|
+
elif name=='generate':
|
|
104
|
+
if payload.get('consent') is not True:raise JobError('Confirm sending the saved answers and selected document text to your AI assistant')
|
|
105
|
+
from .catalog_generation import generate
|
|
106
|
+
result=await asyncio.to_thread(generate,workspace,job_id,payload['sequence'],payload.get('revision'),payload['request_id'],payload['request'],payload.get('document_ids',[]))
|
|
107
|
+
elif name=='review':
|
|
108
|
+
if workspace.view(job_id)['needs_regeneration']:raise JobError('Saved answers are newer than the files. Generate the updated documents before reviewing them.')
|
|
109
|
+
if payload.get('confirmed') is not True:raise JobError('Confirm that you reviewed this version')
|
|
110
|
+
ticket=workspace.store.request_review(job_id,payload['revision'],payload['request_id'],'Current catalog work product and supporting evidence')
|
|
111
|
+
workspace.store.record_local_review(ticket['id'],payload['reviewer'],'review_recorded',workspace.store.review_attestation(ticket,'review_recorded'),evidence_kind='card_attestation')
|
|
112
|
+
result=workspace.view(job_id)
|
|
113
|
+
elif name=='folders':result=Folders(workspace).settings(job_id)
|
|
114
|
+
elif name in ('input','output'):result=await asyncio.to_thread(Folders(workspace).select,job_id,name)
|
|
115
|
+
elif name=='scan':result=await asyncio.to_thread(Folders(workspace).scan,job_id)
|
|
116
|
+
elif name=='export':result=await asyncio.to_thread(Folders(workspace).save,job_id)
|
|
117
|
+
else:raise JobError('Unknown workspace action')
|
|
118
|
+
return JSONResponse(result,headers={'Cache-Control':'no-store'})
|
|
119
|
+
except (JobError,ValueError,KeyError,TypeError,OSError) as exc:
|
|
120
|
+
return JSONResponse({'error':str(exc)},status_code=400)
|
|
121
|
+
finally:
|
|
122
|
+
active_actions-=1
|
|
123
|
+
async def shutdown(request):
|
|
124
|
+
nonlocal closing
|
|
125
|
+
if not local(request) or request.headers.get('origin')!=str(request.base_url).rstrip('/') or request.headers.get('content-type')!='application/json':
|
|
126
|
+
return PlainTextResponse('Not found',404)
|
|
127
|
+
try:
|
|
128
|
+
payload=await bounded_json(request)
|
|
129
|
+
if payload.get('confirmed') is not True:raise JobError('Confirm closing this local workspace')
|
|
130
|
+
if active_actions:return JSONResponse({'error':'TasksAI is still working. Wait for the current action to finish before closing.'},status_code=409)
|
|
131
|
+
if closing:return JSONResponse({'status':'closing'})
|
|
132
|
+
if on_shutdown is None:raise JobError('This workspace cannot be closed from this screen')
|
|
133
|
+
draft=payload.get('draft')
|
|
134
|
+
if draft is not None:
|
|
135
|
+
if not isinstance(draft,dict):raise JobError('Invalid answers to save before closing')
|
|
136
|
+
workspace.save_answers(draft['job_id'],draft['answers'],draft['sequence'],draft.get('user_request'))
|
|
137
|
+
closing=True
|
|
138
|
+
return JSONResponse({'status':'closing'},background=BackgroundTask(on_shutdown))
|
|
139
|
+
except (JobError,ValueError,TypeError,AttributeError,KeyError) as exc:return JSONResponse({'error':str(exc)},status_code=400)
|
|
140
|
+
async def download(request):
|
|
141
|
+
if not local(request):return PlainTextResponse('Not found',404)
|
|
142
|
+
try:
|
|
143
|
+
job=workspace.store.read(request.path_params['job_id'])
|
|
144
|
+
if not job['current'] or request.query_params.get('revision')!=job['current_revision']:raise JobError('Reopen the project to get its current files')
|
|
145
|
+
names={'word':'work-product.docx','excel':'work-product.xlsx'};filename=names.get(request.path_params['kind'])
|
|
146
|
+
if not filename:raise JobError('Unknown document')
|
|
147
|
+
path=Path(job['current']['directory'])/'package'/filename
|
|
148
|
+
if not path.is_file():raise JobError('This project does not include that document')
|
|
149
|
+
return FileResponse(path,filename=filename,headers={'Cache-Control':'no-store'})
|
|
150
|
+
except JobError as exc:return PlainTextResponse(str(exc),400)
|
|
151
|
+
async def source(request):
|
|
152
|
+
if not local(request):return PlainTextResponse('Not found',404)
|
|
153
|
+
try:
|
|
154
|
+
document=Documents(workspace.store,allow_customer_documents=True).read(request.path_params['job_id'],request.path_params['document_id'])
|
|
155
|
+
return FileResponse(document['original_file'],filename=document['filename'],
|
|
156
|
+
headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
|
157
|
+
except (JobError,OSError,ValueError):
|
|
158
|
+
return PlainTextResponse('This saved source is unavailable. Reopen the project and add the original again.',400)
|
|
159
|
+
return Starlette(routes=[Route(prefix,page),Route(prefix+'/health',health),Route(prefix+'/shutdown',shutdown,methods=['POST']),Route(prefix+'/action',action,methods=['POST']),Route(prefix+'/file/{job_id}/{kind}',download),Route(prefix+'/source/{job_id}/{document_id}',source)])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main():
|
|
163
|
+
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--root',type=Path,required=True);parser.add_argument('--owner',required=True);parser.add_argument('--port',type=int,default=0);args=parser.parse_args()
|
|
164
|
+
workspace=CatalogWorkspace(args.root.resolve(),args.owner);token=secrets.token_urlsafe(32)
|
|
165
|
+
sock=socket.socket();sock.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);sock.bind(('127.0.0.1',args.port));port=sock.getsockname()[1]
|
|
166
|
+
connection=workspace.root/'workspace-connection.json'
|
|
167
|
+
connection.write_text(json.dumps({'url':f'http://127.0.0.1:{port}/workspace/{token}'}), encoding="utf-8");connection.chmod(0o600)
|
|
168
|
+
import uvicorn
|
|
169
|
+
server=uvicorn.Server(uvicorn.Config(app(workspace,token,on_shutdown=lambda:setattr(server,'should_exit',True)),host='127.0.0.1',port=port,access_log=False))
|
|
170
|
+
server.run(sockets=[sock])
|
|
171
|
+
if __name__=='__main__':
|
|
172
|
+
from software_use import software_use
|
|
173
|
+
with software_use(Path(__file__).resolve().parents[2]):
|
|
174
|
+
main()
|